Compare commits

...

24 Commits

Author SHA1 Message Date
dependabot[bot] 7bda588808 Bump the npm-dependencies group across 1 directory with 57 updates
Bumps the npm-dependencies group with 57 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [@aws-sdk/client-s3](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-s3) | `3.1056.0` | `3.1081.0` |
| [@radix-ui/react-avatar](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/avatar) | `1.1.11` | `1.2.2` |
| [@radix-ui/react-checkbox](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/checkbox) | `1.3.3` | `1.3.7` |
| [@radix-ui/react-collapsible](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/collapsible) | `1.1.12` | `1.1.16` |
| [@radix-ui/react-dialog](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/dialog) | `1.1.15` | `1.1.19` |
| [@radix-ui/react-dropdown-menu](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/dropdown-menu) | `2.1.16` | `2.1.20` |
| [@radix-ui/react-label](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/label) | `2.1.8` | `2.1.11` |
| [@radix-ui/react-popover](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/popover) | `1.1.15` | `1.1.19` |
| [@radix-ui/react-progress](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/progress) | `1.1.8` | `1.1.12` |
| [@radix-ui/react-radio-group](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/radio-group) | `1.3.8` | `1.4.3` |
| [@radix-ui/react-scroll-area](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/scroll-area) | `1.2.10` | `1.2.14` |
| [@radix-ui/react-select](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/select) | `2.2.6` | `2.3.3` |
| [@radix-ui/react-separator](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/separator) | `1.1.8` | `1.1.11` |
| [@radix-ui/react-slot](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/slot) | `1.2.4` | `1.3.0` |
| [@radix-ui/react-switch](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/switch) | `1.2.6` | `1.3.3` |
| [@radix-ui/react-tabs](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/tabs) | `1.1.13` | `1.1.17` |
| [@radix-ui/react-toast](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/toast) | `1.2.15` | `1.2.19` |
| [@radix-ui/react-tooltip](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/tooltip) | `1.2.8` | `1.2.12` |
| [@react-email/render](https://github.com/resend/react-email/tree/HEAD/packages/render) | `2.0.8` | `2.0.10` |
| [@simplewebauthn/server](https://github.com/MasterKale/SimpleWebAuthn/tree/HEAD/packages/server) | `13.3.1` | `13.3.2` |
| [@tanstack/react-query](https://github.com/TanStack/query/tree/HEAD/packages/react-query) | `5.100.14` | `5.101.2` |
| [axios](https://github.com/axios/axios) | `1.16.1` | `1.18.1` |
| [ioredis](https://github.com/luin/ioredis) | `5.11.0` | `5.11.1` |
| [js-yaml](https://github.com/nodeca/js-yaml) | `4.2.0` | `5.2.1` |
| [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react) | `1.17.0` | `1.23.0` |
| [next](https://github.com/vercel/next.js) | `16.2.6` | `16.2.10` |
| [next-intl](https://github.com/amannn/next-intl) | `4.13.0` | `4.13.1` |
| [nodemailer](https://github.com/nodemailer/nodemailer) | `9.0.1` | `9.0.3` |
| [pg](https://github.com/brianc/node-postgres/tree/HEAD/packages/pg) | `8.21.0` | `8.22.0` |
| [posthog-node](https://github.com/PostHog/posthog-js/tree/HEAD/packages/node) | `5.35.6` | `5.40.0` |
| [react](https://github.com/facebook/react/tree/HEAD/packages/react) | `19.2.6` | `19.2.7` |
| [@types/react](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react) | `19.2.15` | `19.2.17` |
| [react-day-picker](https://github.com/gpbl/react-day-picker/tree/HEAD/packages/react-day-picker) | `9.14.0` | `10.0.1` |
| [react-dom](https://github.com/facebook/react/tree/HEAD/packages/react-dom) | `19.2.6` | `19.2.7` |
| [react-hook-form](https://github.com/react-hook-form/react-hook-form) | `7.76.1` | `7.81.0` |
| [react-icons](https://github.com/react-icons/react-icons) | `5.6.0` | `5.7.0` |
| [recharts](https://github.com/recharts/recharts) | `3.8.1` | `3.9.2` |
| [semver](https://github.com/npm/node-semver) | `7.8.1` | `7.8.5` |
| [stripe](https://github.com/stripe/stripe-node) | `22.2.0` | `22.3.0` |
| [uuid](https://github.com/uuidjs/uuid) | `14.0.0` | `14.0.1` |
| [@dotenvx/dotenvx](https://github.com/dotenvx/dotenvx) | `1.69.1` | `2.1.5` |
| [@react-email/ui](https://github.com/resend/react-email/tree/HEAD/packages/ui) | `6.5.0` | `6.6.8` |
| [@tailwindcss/postcss](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/@tailwindcss-postcss) | `4.3.0` | `4.3.2` |
| [@tanstack/react-query-devtools](https://github.com/TanStack/query/tree/HEAD/packages/react-query-devtools) | `5.100.14` | `5.101.2` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `25.9.1` | `26.1.0` |
| [@types/nodemailer](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/nodemailer) | `8.0.0` | `8.0.1` |
| [@types/sshpk](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/sshpk) | `1.17.4` | `1.17.5` |
| [esbuild-node-externals](https://github.com/pradel/esbuild-node-externals) | `1.22.0` | `1.23.1` |
| [eslint](https://github.com/eslint/eslint) | `10.4.0` | `10.6.0` |
| [eslint-config-next](https://github.com/vercel/next.js/tree/HEAD/packages/eslint-config-next) | `16.2.6` | `16.2.10` |
| [postcss](https://github.com/postcss/postcss) | `8.5.15` | `8.5.16` |
| [prettier](https://github.com/prettier/prettier) | `3.8.3` | `3.9.4` |
| [react-email](https://github.com/resend/react-email/tree/HEAD/packages/react-email) | `6.5.0` | `6.6.8` |
| [tailwindcss](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/tailwindcss) | `4.3.0` | `4.3.2` |
| [tsc-alias](https://github.com/justkey007/tsc-alias) | `1.8.17` | `1.9.0` |
| [tsx](https://github.com/privatenumber/tsx) | `4.22.3` | `4.23.0` |
| [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) | `8.60.0` | `8.63.0` |



Updates `@aws-sdk/client-s3` from 3.1056.0 to 3.1081.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-s3/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1081.0/clients/client-s3)

Updates `@radix-ui/react-avatar` from 1.1.11 to 1.2.2
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/avatar/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/avatar)

Updates `@radix-ui/react-checkbox` from 1.3.3 to 1.3.7
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/checkbox/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/checkbox)

Updates `@radix-ui/react-collapsible` from 1.1.12 to 1.1.16
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/collapsible/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/collapsible)

Updates `@radix-ui/react-dialog` from 1.1.15 to 1.1.19
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/dialog/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/dialog)

Updates `@radix-ui/react-dropdown-menu` from 2.1.16 to 2.1.20
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/dropdown-menu/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/dropdown-menu)

Updates `@radix-ui/react-label` from 2.1.8 to 2.1.11
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/label/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/label)

Updates `@radix-ui/react-popover` from 1.1.15 to 1.1.19
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/popover/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/popover)

Updates `@radix-ui/react-progress` from 1.1.8 to 1.1.12
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/progress/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/progress)

Updates `@radix-ui/react-radio-group` from 1.3.8 to 1.4.3
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/radio-group/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/radio-group)

Updates `@radix-ui/react-scroll-area` from 1.2.10 to 1.2.14
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/scroll-area/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/scroll-area)

Updates `@radix-ui/react-select` from 2.2.6 to 2.3.3
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/select/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/select)

Updates `@radix-ui/react-separator` from 1.1.8 to 1.1.11
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/separator/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/separator)

Updates `@radix-ui/react-slot` from 1.2.4 to 1.3.0
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/slot/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/slot)

Updates `@radix-ui/react-switch` from 1.2.6 to 1.3.3
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/switch/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/switch)

Updates `@radix-ui/react-tabs` from 1.1.13 to 1.1.17
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/tabs/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/tabs)

Updates `@radix-ui/react-toast` from 1.2.15 to 1.2.19
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/toast/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/toast)

Updates `@radix-ui/react-tooltip` from 1.2.8 to 1.2.12
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/tooltip/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/tooltip)

Updates `@react-email/render` from 2.0.8 to 2.0.10
- [Release notes](https://github.com/resend/react-email/releases)
- [Changelog](https://github.com/resend/react-email/blob/canary/packages/render/CHANGELOG.md)
- [Commits](https://github.com/resend/react-email/commits/@react-email/render@2.0.10/packages/render)

Updates `@simplewebauthn/server` from 13.3.1 to 13.3.2
- [Release notes](https://github.com/MasterKale/SimpleWebAuthn/releases)
- [Changelog](https://github.com/MasterKale/SimpleWebAuthn/blob/master/CHANGELOG.md)
- [Commits](https://github.com/MasterKale/SimpleWebAuthn/commits/v13.3.2/packages/server)

Updates `@tanstack/react-query` from 5.100.14 to 5.101.2
- [Release notes](https://github.com/TanStack/query/releases)
- [Changelog](https://github.com/TanStack/query/blob/main/packages/react-query/CHANGELOG.md)
- [Commits](https://github.com/TanStack/query/commits/@tanstack/react-query@5.101.2/packages/react-query)

Updates `axios` from 1.16.1 to 1.18.1
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.16.1...v1.18.1)

Updates `ioredis` from 5.11.0 to 5.11.1
- [Release notes](https://github.com/luin/ioredis/releases)
- [Changelog](https://github.com/redis/ioredis/blob/main/CHANGELOG.md)
- [Commits](https://github.com/luin/ioredis/compare/v5.11.0...v5.11.1)

Updates `js-yaml` from 4.2.0 to 5.2.1
- [Changelog](https://github.com/nodeca/js-yaml/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodeca/js-yaml/compare/4.2.0...5.2.1)

Updates `lucide-react` from 1.17.0 to 1.23.0
- [Release notes](https://github.com/lucide-icons/lucide/releases)
- [Commits](https://github.com/lucide-icons/lucide/commits/1.23.0/packages/lucide-react)

Updates `next` from 16.2.6 to 16.2.10
- [Release notes](https://github.com/vercel/next.js/releases)
- [Commits](https://github.com/vercel/next.js/compare/v16.2.6...v16.2.10)

Updates `next-intl` from 4.13.0 to 4.13.1
- [Release notes](https://github.com/amannn/next-intl/releases)
- [Changelog](https://github.com/amannn/next-intl/blob/main/CHANGELOG.md)
- [Commits](https://github.com/amannn/next-intl/compare/v4.13.0...v4.13.1)

Updates `nodemailer` from 9.0.1 to 9.0.3
- [Release notes](https://github.com/nodemailer/nodemailer/releases)
- [Changelog](https://github.com/nodemailer/nodemailer/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodemailer/nodemailer/compare/v9.0.1...v9.0.3)

Updates `pg` from 8.21.0 to 8.22.0
- [Changelog](https://github.com/brianc/node-postgres/blob/master/CHANGELOG.md)
- [Commits](https://github.com/brianc/node-postgres/commits/pg@8.22.0/packages/pg)

Updates `posthog-node` from 5.35.6 to 5.40.0
- [Release notes](https://github.com/PostHog/posthog-js/releases)
- [Changelog](https://github.com/PostHog/posthog-js/blob/main/packages/node/CHANGELOG.md)
- [Commits](https://github.com/PostHog/posthog-js/commits/posthog-node@5.40.0/packages/node)

Updates `react` from 19.2.6 to 19.2.7
- [Release notes](https://github.com/facebook/react/releases)
- [Changelog](https://github.com/react/react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/facebook/react/commits/v19.2.7/packages/react)

Updates `@types/react` from 19.2.15 to 19.2.17
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/react)

Updates `react-day-picker` from 9.14.0 to 10.0.1
- [Release notes](https://github.com/gpbl/react-day-picker/releases)
- [Changelog](https://github.com/gpbl/react-day-picker/blob/main/packages/react-day-picker/CHANGELOG.md)
- [Commits](https://github.com/gpbl/react-day-picker/commits/v10.0.1/packages/react-day-picker)

Updates `react-dom` from 19.2.6 to 19.2.7
- [Release notes](https://github.com/facebook/react/releases)
- [Changelog](https://github.com/react/react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/facebook/react/commits/v19.2.7/packages/react-dom)

Updates `react-hook-form` from 7.76.1 to 7.81.0
- [Release notes](https://github.com/react-hook-form/react-hook-form/releases)
- [Changelog](https://github.com/react-hook-form/react-hook-form/blob/master/CHANGELOG.md)
- [Commits](https://github.com/react-hook-form/react-hook-form/compare/v7.76.1...v7.81.0)

Updates `react-icons` from 5.6.0 to 5.7.0
- [Release notes](https://github.com/react-icons/react-icons/releases)
- [Commits](https://github.com/react-icons/react-icons/compare/v5.6.0...v5.7.0)

Updates `recharts` from 3.8.1 to 3.9.2
- [Release notes](https://github.com/recharts/recharts/releases)
- [Changelog](https://github.com/recharts/recharts/blob/main/CHANGELOG.md)
- [Commits](https://github.com/recharts/recharts/compare/v3.8.1...v3.9.2)

Updates `semver` from 7.8.1 to 7.8.5
- [Release notes](https://github.com/npm/node-semver/releases)
- [Changelog](https://github.com/npm/node-semver/blob/main/CHANGELOG.md)
- [Commits](https://github.com/npm/node-semver/compare/v7.8.1...v7.8.5)

Updates `stripe` from 22.2.0 to 22.3.0
- [Release notes](https://github.com/stripe/stripe-node/releases)
- [Changelog](https://github.com/stripe/stripe-node/blob/master/CHANGELOG.md)
- [Commits](https://github.com/stripe/stripe-node/compare/v22.2.0...v22.3.0)

Updates `uuid` from 14.0.0 to 14.0.1
- [Release notes](https://github.com/uuidjs/uuid/releases)
- [Changelog](https://github.com/uuidjs/uuid/blob/main/CHANGELOG.md)
- [Commits](https://github.com/uuidjs/uuid/compare/v14.0.0...v14.0.1)

Updates `@dotenvx/dotenvx` from 1.69.1 to 2.1.5
- [Release notes](https://github.com/dotenvx/dotenvx/releases)
- [Changelog](https://github.com/dotenvx/dotenvx/blob/main/CHANGELOG.md)
- [Commits](https://github.com/dotenvx/dotenvx/compare/v1.69.1...v2.1.5)

Updates `@react-email/ui` from 6.5.0 to 6.6.8
- [Release notes](https://github.com/resend/react-email/releases)
- [Changelog](https://github.com/resend/react-email/blob/canary/packages/ui/CHANGELOG.md)
- [Commits](https://github.com/resend/react-email/commits/@react-email/ui@6.6.8/packages/ui)

Updates `@tailwindcss/postcss` from 4.3.0 to 4.3.2
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.3.2/packages/@tailwindcss-postcss)

Updates `@tanstack/react-query-devtools` from 5.100.14 to 5.101.2
- [Release notes](https://github.com/TanStack/query/releases)
- [Changelog](https://github.com/TanStack/query/blob/main/packages/react-query-devtools/CHANGELOG.md)
- [Commits](https://github.com/TanStack/query/commits/@tanstack/react-query-devtools@5.101.2/packages/react-query-devtools)

Updates `@types/node` from 25.9.1 to 26.1.0
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `@types/nodemailer` from 8.0.0 to 8.0.1
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/nodemailer)

Updates `@types/react` from 19.2.15 to 19.2.17
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/react)

Updates `@types/sshpk` from 1.17.4 to 1.17.5
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/sshpk)

Updates `esbuild-node-externals` from 1.22.0 to 1.23.1
- [Release notes](https://github.com/pradel/esbuild-node-externals/releases)
- [Commits](https://github.com/pradel/esbuild-node-externals/compare/esbuild-node-externals-v1.22.0...esbuild-node-externals-v1.23.1)

Updates `eslint` from 10.4.0 to 10.6.0
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/compare/v10.4.0...v10.6.0)

Updates `eslint-config-next` from 16.2.6 to 16.2.10
- [Release notes](https://github.com/vercel/next.js/releases)
- [Commits](https://github.com/vercel/next.js/commits/v16.2.10/packages/eslint-config-next)

Updates `postcss` from 8.5.15 to 8.5.16
- [Release notes](https://github.com/postcss/postcss/releases)
- [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/postcss/postcss/compare/8.5.15...8.5.16)

Updates `prettier` from 3.8.3 to 3.9.4
- [Release notes](https://github.com/prettier/prettier/releases)
- [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/prettier/compare/3.8.3...3.9.4)

Updates `react-email` from 6.5.0 to 6.6.8
- [Release notes](https://github.com/resend/react-email/releases)
- [Changelog](https://github.com/resend/react-email/blob/canary/packages/react-email/CHANGELOG.md)
- [Commits](https://github.com/resend/react-email/commits/react-email@6.6.8/packages/react-email)

Updates `tailwindcss` from 4.3.0 to 4.3.2
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.3.2/packages/tailwindcss)

Updates `tsc-alias` from 1.8.17 to 1.9.0
- [Release notes](https://github.com/justkey007/tsc-alias/releases)
- [Commits](https://github.com/justkey007/tsc-alias/compare/v1.8.17...v1.9.0)

Updates `tsx` from 4.22.3 to 4.23.0
- [Release notes](https://github.com/privatenumber/tsx/releases)
- [Changelog](https://github.com/privatenumber/tsx/blob/master/release.config.cjs)
- [Commits](https://github.com/privatenumber/tsx/compare/v4.22.3...v4.23.0)

Updates `typescript-eslint` from 8.60.0 to 8.63.0
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.63.0/packages/typescript-eslint)

---
updated-dependencies:
- dependency-name: "@aws-sdk/client-s3"
  dependency-version: 3.1079.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: "@dotenvx/dotenvx"
  dependency-version: 2.1.4
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: npm-dependencies
- dependency-name: "@radix-ui/react-avatar"
  dependency-version: 1.2.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: "@radix-ui/react-checkbox"
  dependency-version: 1.3.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-dependencies
- dependency-name: "@radix-ui/react-collapsible"
  dependency-version: 1.1.15
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-dependencies
- dependency-name: "@radix-ui/react-dialog"
  dependency-version: 1.1.18
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-dependencies
- dependency-name: "@radix-ui/react-dropdown-menu"
  dependency-version: 2.1.19
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-dependencies
- dependency-name: "@radix-ui/react-label"
  dependency-version: 2.1.11
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-dependencies
- dependency-name: "@radix-ui/react-popover"
  dependency-version: 1.1.18
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-dependencies
- dependency-name: "@radix-ui/react-progress"
  dependency-version: 1.1.11
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-dependencies
- dependency-name: "@radix-ui/react-radio-group"
  dependency-version: 1.4.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: "@radix-ui/react-scroll-area"
  dependency-version: 1.2.13
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-dependencies
- dependency-name: "@radix-ui/react-select"
  dependency-version: 2.3.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: "@radix-ui/react-separator"
  dependency-version: 1.1.11
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-dependencies
- dependency-name: "@radix-ui/react-slot"
  dependency-version: 1.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: "@radix-ui/react-switch"
  dependency-version: 1.3.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: "@radix-ui/react-tabs"
  dependency-version: 1.1.16
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-dependencies
- dependency-name: "@radix-ui/react-toast"
  dependency-version: 1.2.18
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-dependencies
- dependency-name: "@radix-ui/react-tooltip"
  dependency-version: 1.2.11
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-dependencies
- dependency-name: "@react-email/render"
  dependency-version: 2.0.10
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-dependencies
- dependency-name: "@react-email/ui"
  dependency-version: 6.6.6
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: "@simplewebauthn/server"
  dependency-version: 13.3.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-dependencies
- dependency-name: "@tailwindcss/postcss"
  dependency-version: 4.3.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-dependencies
- dependency-name: "@tanstack/react-query"
  dependency-version: 5.101.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: "@tanstack/react-query-devtools"
  dependency-version: 5.101.2
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: "@types/node"
  dependency-version: 26.1.0
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: npm-dependencies
- dependency-name: "@types/nodemailer"
  dependency-version: 8.0.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-dependencies
- dependency-name: "@types/react"
  dependency-version: 19.2.17
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-dependencies
- dependency-name: "@types/react"
  dependency-version: 19.2.17
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-dependencies
- dependency-name: "@types/sshpk"
  dependency-version: 1.17.5
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-dependencies
- dependency-name: axios
  dependency-version: 1.18.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: esbuild-node-externals
  dependency-version: 1.23.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: eslint
  dependency-version: 10.6.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: eslint-config-next
  dependency-version: 16.2.10
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-dependencies
- dependency-name: ioredis
  dependency-version: 5.11.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-dependencies
- dependency-name: js-yaml
  dependency-version: 5.2.1
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: npm-dependencies
- dependency-name: lucide-react
  dependency-version: 1.23.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: next
  dependency-version: 16.2.10
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-dependencies
- dependency-name: next-intl
  dependency-version: 4.13.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-dependencies
- dependency-name: nodemailer
  dependency-version: 9.0.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-dependencies
- dependency-name: pg
  dependency-version: 8.22.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: postcss
  dependency-version: 8.5.16
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-dependencies
- dependency-name: posthog-node
  dependency-version: 5.39.4
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: prettier
  dependency-version: 3.9.4
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: react
  dependency-version: 19.2.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-dependencies
- dependency-name: react-day-picker
  dependency-version: 10.0.1
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: npm-dependencies
- dependency-name: react-dom
  dependency-version: 19.2.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-dependencies
- dependency-name: react-email
  dependency-version: 6.6.6
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: react-hook-form
  dependency-version: 7.81.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: react-icons
  dependency-version: 5.7.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: recharts
  dependency-version: 3.9.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: semver
  dependency-version: 7.8.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-dependencies
- dependency-name: stripe
  dependency-version: 22.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: tailwindcss
  dependency-version: 4.3.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-dependencies
- dependency-name: tsc-alias
  dependency-version: 1.9.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: tsx
  dependency-version: 4.23.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: typescript-eslint
  dependency-version: 8.62.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: uuid
  dependency-version: 14.0.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-08 01:44:56 +00:00
Owen Schwartz 5cb316f4e9 Merge pull request #3404 from fosrl/dev
Add ? to support undefined
2026-07-07 10:59:56 -04:00
Owen 83de8576bf Add ? to support undefined 2026-07-07 10:59:01 -04:00
Owen Schwartz 6b6c9cf4d8 Merge pull request #3403 from fosrl/dev
Move jit mode to a config param
2026-07-07 10:41:59 -04:00
Owen e9d424eb80 Move jit mode to a config param
Ref #3262
2026-07-07 10:41:12 -04:00
Owen Schwartz 05617c63c0 Merge pull request #3402 from fosrl/dev
1.19.4-s.5
2026-07-07 10:21:57 -04:00
Owen 8cceed91cf Make feilds optional 2026-07-07 09:02:25 -04:00
Owen 633159fb77 Email whitelist should respect policies 2026-07-07 08:37:20 -04:00
Owen d4549f1c33 log error 2026-07-06 14:44:30 -04:00
Owen 71da22328c Quiet logs 2026-07-06 14:44:30 -04:00
miloschwartz 0b5b732a48 Merge branch 'resource-launcher-compact' into dev 2026-07-06 14:19:58 -04:00
miloschwartz a82bae8f93 add resource info to side panel 2026-07-06 12:45:07 -04:00
Owen e59176149b Disable jit
Fixes #3262
2026-07-06 11:01:40 -04:00
Owen 2c3151da9b Fix #3374 2026-07-03 17:36:06 -04:00
Owen f60b8795ad Fix #3383 2026-07-03 17:26:49 -04:00
Owen 4054976388 Fix #3395 2026-07-03 17:15:48 -04:00
miloschwartz 4087d7fb6b remove text from refresh button 2026-07-03 11:18:06 -04:00
miloschwartz 9edb86fd73 add invalidate launcher cache on refresh 2026-07-03 10:30:41 -04:00
miloschwartz 811119a9a6 reduce filter dropdown page size 2026-07-03 10:13:58 -04:00
miloschwartz b53f80d317 use column for default view 2026-07-03 09:59:49 -04:00
miloschwartz a89509c8bd add more caching 2026-07-02 23:32:16 -04:00
miloschwartz f0546eb622 add save default view 2026-07-02 22:27:51 -04:00
miloschwartz 12f9ac94fd allow grouping when filter applied 2026-07-02 22:07:30 -04:00
miloschwartz 1717a99cee add compact mode for large orgs 2026-07-02 21:53:52 -04:00
53 changed files with 4645 additions and 2952 deletions
+29 -5
View File
@@ -864,8 +864,8 @@
"policyAuthHeaderAuthTitle": "Basic Header Auth",
"policyAuthHeaderAuthDescription": "Validate a custom HTTP header name and value on each request",
"policyAuthHeaderAuthSummary": "Header configured",
"policyAuthHeaderName": "Header name",
"policyAuthHeaderValue": "Expected value",
"policyAuthHeaderName": "Username",
"policyAuthHeaderValue": "Password",
"policyAuthSetPasscode": "Set Passcode",
"policyAuthSetPincode": "Set PIN Code",
"policyAuthSetEmailWhitelist": "Set Email Whitelist",
@@ -3577,18 +3577,23 @@
"memberPortalResourceDisabled": "Resource Disabled",
"memberPortalShowingResources": "Showing {start}-{end} of {total} resources",
"resourceLauncherTitle": "Resource Launcher",
"resourceLauncherDescription": "View resource details and launch them from one place",
"resourceLauncherSearchPlaceholder": "Search all sites...",
"resourceLauncherDescription": "View all available resources and launch them from one central hub",
"resourceLauncherSearchPlaceholder": "Search your resources...",
"resourceLauncherDefaultView": "Default",
"resourceLauncherSaveView": "Save View",
"resourceLauncherSaveToCurrentView": "Save to Current View",
"resourceLauncherSaveDefaultPersonal": "Save for Me",
"resourceLauncherResetView": "Reset View",
"resourceLauncherResetSystemDefault": "Reset to System Default",
"resourceLauncherSystemDefaultRestored": "System default restored",
"resourceLauncherSystemDefaultRestoredDescription": "The default view has been reset to the original settings.",
"resourceLauncherSaveAsNewView": "Save as New View",
"resourceLauncherSaveAsNewViewDescription": "Give this view a name to save your current filters and layout.",
"resourceLauncherSaveForEveryone": "Save for Everyone",
"resourceLauncherSaveForEveryoneDescription": "Share this view with all organization members. When unchecked, the view is only visible to you.",
"resourceLauncherMakePersonal": "Make Personal",
"resourceLauncherFilter": "Filter",
"resourceLauncherFilterWithCount": "Filter, {count} applied",
"resourceLauncherSort": "Sort",
"resourceLauncherSortAscending": "Sort ascending",
"resourceLauncherSortDescending": "Sort descending",
@@ -3596,6 +3601,7 @@
"resourceLauncherGroupBy": "Group By",
"resourceLauncherGroupBySite": "Site",
"resourceLauncherGroupByLabel": "Label",
"resourceLauncherGroupByNone": "None",
"resourceLauncherLayout": "Layout",
"resourceLauncherLayoutGrid": "Grid",
"resourceLauncherLayoutList": "List",
@@ -3603,8 +3609,20 @@
"resourceLauncherShowSiteTags": "Show Site Tags",
"resourceLauncherShowRecents": "Show Recents",
"resourceLauncherDeleteView": "Delete View",
"resourceLauncherDeleteViewTitle": "Delete View",
"resourceLauncherDeleteViewQuestion": "Are you sure you want to delete this launcher view?",
"resourceLauncherDeleteViewConfirm": "Delete View",
"resourceLauncherViewAsAdmin": "View as Admin",
"resourceLauncherResourceDetailsDescription": "View details for this resource.",
"resourceLauncherResourceDetailsDescription": "Connection information and status for this resource.",
"resourceLauncherResourceDetails": "Resource Details",
"resourceLauncherAuthMethodsDescription": "Authentication methods enabled for this resource.",
"resourceLauncherPrivateClientRequired": "Connect with a client on your device to access this resource privately.",
"resourceLauncherPrivateClientRequiredTitle": "Client Connection Required",
"resourceLauncherDownloadClient": "Download client",
"resourceLauncherFailedToLoadDetails": "Could not load resource details. You may no longer have access to this resource.",
"resourceLauncherNoPortRestrictions": "No port restrictions",
"resourceLauncherTcp": "TCP",
"resourceLauncherUdp": "UDP",
"resourceLauncherUnlabeled": "Unlabeled",
"resourceLauncherNoSite": "No Site",
"resourceLauncherNoResourcesInGroup": "No resources in this group",
@@ -3613,6 +3631,12 @@
"resourceLauncherEmptyStateNoResultsTitle": "No Resources Found",
"resourceLauncherEmptyStateNoResultsDescription": "No resources match your current search or filters. Try adjusting them to find what you are looking for.",
"resourceLauncherEmptyStateNoResultsWithQuery": "No resources match \"{query}\". Try adjusting your search or clearing filters to see all resources.",
"resourceLauncherSearchFirstTitle": "Search or Filter to Browse",
"resourceLauncherSearchFirstDescription": "You have access to many resources. Use search or filter by site or label to find what you need.",
"resourceLauncherSiteGroupingDisabled": "Site grouping is unavailable at this scale. Filter by site to group a smaller set.",
"resourceLauncherLabelGroupingDisabled": "Label grouping is unavailable at this scale.",
"resourceLauncherCompactModeHint": "Showing a simplified list for faster browsing. Use search or filters to narrow results.",
"resourceLauncherCompactGroupingHint": "Apply site or label filters to enable grouping.",
"resourceLauncherCopiedToClipboard": "Copied to clipboard",
"resourceLauncherCopiedAccessDescription": "Resource access has been copied to your clipboard.",
"resourceLauncherViewNamePlaceholder": "View name",
+1589 -2476
View File
File diff suppressed because it is too large Load Diff
+58 -58
View File
@@ -35,7 +35,7 @@
"@asteasolutions/zod-to-openapi": "8.5.0",
"@devolutions/iron-remote-desktop": "https://static.pangolin.net/packages/devolutions-iron-remote-desktop-0.0.0.tgz",
"@devolutions/iron-remote-desktop-rdp": "https://static.pangolin.net/packages/devolutions-iron-remote-desktop-rdp-0.0.0.tgz",
"@aws-sdk/client-s3": "3.1056.0",
"@aws-sdk/client-s3": "3.1081.0",
"@headlessui/react": "2.2.10",
"@hookform/resolvers": "5.4.0",
"@monaco-editor/react": "4.7.0",
@@ -43,38 +43,38 @@
"@novnc/novnc": "^1.7.0",
"@oslojs/crypto": "1.0.1",
"@oslojs/encoding": "1.1.0",
"@radix-ui/react-avatar": "1.1.11",
"@radix-ui/react-checkbox": "1.3.3",
"@radix-ui/react-collapsible": "1.1.12",
"@radix-ui/react-dialog": "1.1.15",
"@radix-ui/react-dropdown-menu": "2.1.16",
"@radix-ui/react-avatar": "1.2.2",
"@radix-ui/react-checkbox": "1.3.7",
"@radix-ui/react-collapsible": "1.1.16",
"@radix-ui/react-dialog": "1.1.19",
"@radix-ui/react-dropdown-menu": "2.1.20",
"@radix-ui/react-icons": "1.3.2",
"@radix-ui/react-label": "2.1.8",
"@radix-ui/react-popover": "1.1.15",
"@radix-ui/react-progress": "1.1.8",
"@radix-ui/react-radio-group": "1.3.8",
"@radix-ui/react-scroll-area": "1.2.10",
"@radix-ui/react-select": "2.2.6",
"@radix-ui/react-separator": "1.1.8",
"@radix-ui/react-slot": "1.2.4",
"@radix-ui/react-switch": "1.2.6",
"@radix-ui/react-tabs": "1.1.13",
"@radix-ui/react-toast": "1.2.15",
"@radix-ui/react-tooltip": "1.2.8",
"@radix-ui/react-label": "2.1.11",
"@radix-ui/react-popover": "1.1.19",
"@radix-ui/react-progress": "1.1.12",
"@radix-ui/react-radio-group": "1.4.3",
"@radix-ui/react-scroll-area": "1.2.14",
"@radix-ui/react-select": "2.3.3",
"@radix-ui/react-separator": "1.1.11",
"@radix-ui/react-slot": "1.3.0",
"@radix-ui/react-switch": "1.3.3",
"@radix-ui/react-tabs": "1.1.17",
"@radix-ui/react-toast": "1.2.19",
"@radix-ui/react-tooltip": "1.2.12",
"@react-email/body": "0.3.0",
"@react-email/components": "1.0.12",
"@react-email/render": "2.0.8",
"@react-email/render": "2.0.10",
"@react-email/tailwind": "2.0.7",
"@simplewebauthn/browser": "13.3.0",
"@simplewebauthn/server": "13.3.1",
"@simplewebauthn/server": "13.3.2",
"@tailwindcss/forms": "0.5.11",
"@tanstack/react-query": "5.100.14",
"@tanstack/react-query": "5.101.2",
"@tanstack/react-table": "8.21.3",
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-web-links": "^0.12.0",
"@xterm/xterm": "^6.0.0",
"arctic": "3.7.0",
"axios": "1.16.1",
"axios": "1.18.1",
"better-sqlite3": "11.9.1",
"canvas-confetti": "1.9.4",
"class-variance-authority": "0.7.1",
@@ -91,40 +91,40 @@
"helmet": "8.2.0",
"http-errors": "2.0.1",
"input-otp": "1.4.2",
"ioredis": "5.11.0",
"ioredis": "5.11.1",
"jmespath": "0.16.0",
"js-yaml": "4.2.0",
"js-yaml": "5.2.1",
"jsonwebtoken": "9.0.3",
"lucide-react": "1.17.0",
"lucide-react": "1.23.0",
"maxmind": "5.0.6",
"moment": "2.30.1",
"next": "16.2.6",
"next-intl": "4.13.0",
"next": "16.2.10",
"next-intl": "4.13.1",
"next-themes": "0.4.6",
"nextjs-toploader": "3.9.17",
"node-cache": "5.1.2",
"nodemailer": "9.0.1",
"nodemailer": "9.0.3",
"oslo": "1.2.1",
"pg": "8.21.0",
"posthog-node": "5.35.6",
"pg": "8.22.0",
"posthog-node": "5.40.0",
"qrcode.react": "4.2.0",
"react": "19.2.6",
"react-day-picker": "9.14.0",
"react-dom": "19.2.6",
"react": "19.2.7",
"react-day-picker": "10.0.1",
"react-dom": "19.2.7",
"react-easy-sort": "1.8.0",
"react-hook-form": "7.76.1",
"react-icons": "5.6.0",
"recharts": "3.8.1",
"react-hook-form": "7.81.0",
"react-icons": "5.7.0",
"recharts": "3.9.2",
"reodotdev": "1.1.0",
"semver": "7.8.1",
"semver": "7.8.5",
"sshpk": "1.18.0",
"stripe": "22.2.0",
"stripe": "22.3.0",
"swagger-ui-express": "5.0.1",
"tailwind-merge": "3.6.0",
"topojson-client": "3.1.0",
"tw-animate-css": "1.4.0",
"use-debounce": "10.1.1",
"uuid": "14.0.0",
"uuid": "14.0.1",
"vaul": "1.1.2",
"visionscarto-world-atlas": "1.0.0",
"winston": "3.19.0",
@@ -136,11 +136,11 @@
"zod-validation-error": "5.0.0"
},
"devDependencies": {
"@dotenvx/dotenvx": "1.69.1",
"@dotenvx/dotenvx": "2.1.5",
"@esbuild-plugins/tsconfig-paths": "0.1.2",
"@react-email/ui": "^6.5.0",
"@tailwindcss/postcss": "4.3.0",
"@tanstack/react-query-devtools": "5.100.14",
"@react-email/ui": "^6.6.8",
"@tailwindcss/postcss": "4.3.2",
"@tanstack/react-query-devtools": "5.101.2",
"@types/better-sqlite3": "7.6.13",
"@types/cookie-parser": "1.4.10",
"@types/cors": "2.8.19",
@@ -151,14 +151,14 @@
"@types/jmespath": "0.15.2",
"@types/js-yaml": "4.0.9",
"@types/jsonwebtoken": "9.0.10",
"@types/node": "25.9.1",
"@types/nodemailer": "8.0.0",
"@types/node": "26.1.0",
"@types/nodemailer": "8.0.1",
"@types/nprogress": "0.2.3",
"@types/pg": "8.20.0",
"@types/react": "19.2.15",
"@types/react": "19.2.17",
"@types/react-dom": "19.2.3",
"@types/semver": "7.7.1",
"@types/sshpk": "1.17.4",
"@types/sshpk": "1.17.5",
"@types/swagger-ui-express": "4.1.8",
"@types/topojson-client": "3.1.5",
"@types/ws": "8.18.1",
@@ -166,21 +166,21 @@
"babel-plugin-react-compiler": "1.0.0",
"drizzle-kit": "0.31.10",
"esbuild": "0.28.1",
"esbuild-node-externals": "1.22.0",
"eslint": "10.4.0",
"eslint-config-next": "16.2.6",
"postcss": "8.5.15",
"prettier": "3.8.3",
"react-email": "6.5.0",
"tailwindcss": "4.3.0",
"tsc-alias": "1.8.17",
"tsx": "4.22.3",
"esbuild-node-externals": "1.23.1",
"eslint": "10.6.0",
"eslint-config-next": "16.2.10",
"postcss": "8.5.16",
"prettier": "3.9.4",
"react-email": "6.6.8",
"tailwindcss": "4.3.2",
"tsc-alias": "1.9.0",
"tsx": "4.23.0",
"typescript": "6.0.3",
"typescript-eslint": "8.60.0"
"typescript-eslint": "8.63.0"
},
"overrides": {
"esbuild": "0.28.1",
"dompurify": "3.4.0",
"postcss": "8.5.15"
"postcss": "8.5.16"
}
}
+23
View File
@@ -1,3 +1,4 @@
import config from "@server/lib/config";
import { Pool, PoolConfig } from "pg";
export function createPoolConfig(
@@ -39,6 +40,28 @@ export function attachPoolErrorHandlers(pool: Pool, label: string): void {
`Failed to set statement_timeout on ${label} client: ${err.message}`
);
});
// Disable JIT compilation for this connection. Our hot-path queries
// (e.g. resource-by-domain lookups) join many tables but only ever
// return a handful of rows. When planner row estimates drift (e.g.
// due to autovacuum lag under write-heavy load), Postgres decides
// these plans are expensive enough to JIT-compile, which can add
// multiple seconds of pure compilation overhead per query and
// saturate the connection pool. JIT never pays off for these
// short-lived OLTP queries, so it's disabled outright rather than
// relying on statistics staying fresh.
//
// Set via a runtime SET command rather than the `options: "-c
// jit=off"` startup parameter: connections in SaaS mode go through
// a pooler (e.g. PgBouncer) that rejects arbitrary startup packet
// options with a protocol_violation (08P01) error.
if (config.getRawConfig().postgres?.pool.jit_mode == false) {
client.query("SET jit = off").catch((err: Error) => {
console.warn(
`Failed to set jit=off on ${label} client: ${err.message}`
);
});
}
});
}
+1
View File
@@ -232,6 +232,7 @@ export const launcherViews = pgTable("launcherViews", {
}),
name: varchar("name").notNull(),
config: text("config").notNull(),
isDefault: boolean("isDefault").notNull().default(false),
createdAt: varchar("createdAt").notNull(),
updatedAt: varchar("updatedAt").notNull()
});
+3
View File
@@ -233,6 +233,9 @@ export const launcherViews = sqliteTable("launcherViews", {
}),
name: text("name").notNull(),
config: text("config").notNull(),
isDefault: integer("isDefault", { mode: "boolean" })
.notNull()
.default(false),
createdAt: text("createdAt").notNull(),
updatedAt: text("updatedAt").notNull()
});
@@ -116,7 +116,7 @@ export async function applyNewtDockerBlueprint(
source: "NEWT"
});
} catch (error) {
logger.error(`Failed to update database from config: ${error}`);
logger.debug(`Failed to update database from config: ${error}`);
await sendToClient(newtId, {
type: "newt/blueprint/results",
data: {
+2 -1
View File
@@ -184,7 +184,8 @@ export const configSchema = z
.number()
.positive()
.optional()
.default(5000)
.default(5000),
jit_mode: z.boolean().default(true)
})
.optional()
.prefault({})
@@ -17,6 +17,7 @@ import HttpCode from "@server/types/HttpCode";
import { build } from "@server/build";
import { getOrgTierData } from "#private/lib/billing";
import { Tier } from "@server/types/Tiers";
import logger from "@server/logger";
export function verifyValidSubscription(tiers: Tier[]) {
return async function (
@@ -30,9 +31,9 @@ export function verifyValidSubscription(tiers: Tier[]) {
}
const orgId =
req.params.orgId ||
req.body.orgId ||
req.query.orgId ||
req.params?.orgId ||
req.body?.orgId ||
req.query?.orgId ||
req.userOrgId;
if (!orgId) {
@@ -65,6 +66,7 @@ export function verifyValidSubscription(tiers: Tier[]) {
return next();
} catch (e) {
logger.error(e);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
@@ -99,7 +99,7 @@ export async function applyJSONBlueprint(
source: "API"
});
} catch (error) {
logger.error(`Failed to update database from config: ${error}`);
logger.debug(`Failed to update database from config: ${error}`);
return next(
createHttpError(
HttpCode.BAD_REQUEST,
+32
View File
@@ -327,6 +327,14 @@ authenticated.get(
siteResource.listAllSiteResourcesByOrg
);
authenticated.get(
"/org/:orgId/site-resource/:siteResourceId",
verifyOrgAccess,
verifySiteResourceAccess,
verifyUserHasAction(ActionsEnum.getSiteResource),
siteResource.getSiteResource
);
authenticated.get(
"/site-resource/:siteResourceId",
verifySiteResourceAccess,
@@ -470,6 +478,12 @@ authenticated.get(
launcher.listLauncherGroups
);
authenticated.get(
"/org/:orgId/launcher/scale",
verifyOrgAccess,
launcher.listLauncherScale
);
authenticated.get(
"/org/:orgId/launcher/resources",
verifyOrgAccess,
@@ -494,6 +508,12 @@ authenticated.get(
launcher.listLauncherViews
);
authenticated.post(
"/org/:orgId/launcher/invalidate-cache",
verifyOrgAccess,
launcher.invalidateLauncherCache
);
authenticated.post(
"/org/:orgId/launcher/views",
verifyOrgAccess,
@@ -506,6 +526,18 @@ authenticated.put(
launcher.updateLauncherView
);
authenticated.put(
"/org/:orgId/launcher/default-view",
verifyOrgAccess,
launcher.upsertLauncherDefaultView
);
authenticated.delete(
"/org/:orgId/launcher/default-view",
verifyOrgAccess,
launcher.deleteLauncherDefaultView
);
authenticated.delete(
"/org/:orgId/launcher/views/:viewId",
verifyOrgAccess,
@@ -79,7 +79,8 @@ export async function createLauncherView(
),
createdAt: created.createdAt,
updatedAt: created.updatedAt,
isOrgWide: created.userId == null
isOrgWide: created.userId == null,
isDefault: created.isDefault
},
success: true,
error: false,
@@ -0,0 +1,104 @@
import { response } from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors";
import { fromZodError } from "zod-validation-error";
import { z } from "zod";
import { ActionsEnum, checkUserActionPermission } from "@server/auth/actions";
import {
deleteAllDefaultViewOverrides,
deleteDefaultViewOverride
} from "./launcherDefaultView";
const deleteLauncherDefaultViewBodySchema = z.strictObject({
orgWide: z.boolean().optional().default(false),
all: z.boolean().optional().default(false)
});
export async function deleteLauncherDefaultView(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const orgId = req.userOrgId;
const userId = req.user!.userId;
if (!orgId) {
return next(
createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID")
);
}
const parsed = deleteLauncherDefaultViewBodySchema.safeParse(req.body);
if (!parsed.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromZodError(parsed.error)
)
);
}
if (parsed.data.all) {
const canManageOrgWide = await checkUserActionPermission(
ActionsEnum.createOrgWideLauncherView,
req
);
if (!canManageOrgWide) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"User does not have permission perform this action"
)
);
}
await deleteAllDefaultViewOverrides(orgId, userId);
} else if (parsed.data.orgWide) {
const canManageOrgWide = await checkUserActionPermission(
ActionsEnum.createOrgWideLauncherView,
req
);
if (!canManageOrgWide) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"User does not have permission perform this action"
)
);
}
await deleteDefaultViewOverride({
orgId,
userId,
orgWide: true
});
} else {
await deleteDefaultViewOverride({
orgId,
userId,
orgWide: false
});
}
return response(res, {
data: null,
success: true,
error: false,
message: "Launcher default view reset successfully",
status: HttpCode.OK
});
} catch (error) {
if (createHttpError.isHttpError(error)) {
return next(error);
}
console.error("Error resetting launcher default view:", error);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Internal server error"
)
);
}
}
@@ -46,6 +46,15 @@ export async function deleteLauncherView(
);
}
if (existing.isDefault) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"The default view cannot be deleted from here"
)
);
}
const isPersonalView = existing.userId === userId;
const isOrgWideView = existing.userId == null;
const canManageOrgWide = await checkUserActionPermission(
+4
View File
@@ -1,5 +1,6 @@
export * from "./types";
export { listLauncherGroups } from "./listLauncherGroups";
export { listLauncherScale } from "./listLauncherScale";
export { listLauncherResources } from "./listLauncherResources";
export { listLauncherSites } from "./listLauncherSites";
export { listLauncherLabels } from "./listLauncherLabels";
@@ -7,3 +8,6 @@ export { listLauncherViews } from "./listLauncherViews";
export { createLauncherView } from "./createLauncherView";
export { updateLauncherView } from "./updateLauncherView";
export { deleteLauncherView } from "./deleteLauncherView";
export { upsertLauncherDefaultView } from "./upsertLauncherDefaultView";
export { deleteLauncherDefaultView } from "./deleteLauncherDefaultView";
export { invalidateLauncherCache } from "./invalidateLauncherCache";
@@ -0,0 +1,65 @@
import { regionalCache as cache } from "#dynamic/lib/cache";
import { response } from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors";
async function invalidateLauncherCacheForUser(
orgId: string,
userId: string
): Promise<void> {
const prefixes = [
`launcherAccessibleIds:${orgId}:${userId}:`,
`launcher:groups:${orgId}:${userId}:`,
`launcher:results:${orgId}:${userId}:`,
`launcher:scale:counts:${orgId}:${userId}:`
];
const keys = (
await Promise.all(
prefixes.map((prefix) => cache.keysWithPrefix(prefix))
)
).flat();
if (keys.length > 0) {
await cache.del(keys);
}
}
export async function invalidateLauncherCache(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const orgId = req.userOrgId;
const userId = req.user!.userId;
if (!orgId) {
return next(
createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID")
);
}
await invalidateLauncherCacheForUser(orgId, userId);
return response(res, {
data: null,
success: true,
error: false,
message: "Launcher cache invalidated successfully",
status: HttpCode.OK
});
} catch (error) {
if (createHttpError.isHttpError(error)) {
return next(error);
}
console.error("Error invalidating launcher cache:", error);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Internal server error"
)
);
}
}
@@ -0,0 +1,137 @@
import { db, launcherViews } from "@server/db";
import { and, eq, isNull } from "drizzle-orm";
import moment from "moment";
import {
launcherViewConfigSchema,
type LauncherDefaultViewOverrides,
type LauncherViewConfig,
type LauncherViewRecord
} from "./types";
export function mapViewRow(
row: typeof launcherViews.$inferSelect
): LauncherViewRecord {
return {
viewId: row.viewId,
orgId: row.orgId,
userId: row.userId,
name: row.name,
config: launcherViewConfigSchema.parse(JSON.parse(row.config)),
createdAt: row.createdAt,
updatedAt: row.updatedAt,
isOrgWide: row.userId == null,
isDefault: row.isDefault
};
}
export function extractDefaultViewOverrides(
rows: Array<typeof launcherViews.$inferSelect>
): LauncherDefaultViewOverrides {
const overrideRows = rows.filter((row) => row.isDefault);
const personalRow = overrideRows.find((row) => row.userId !== null);
const orgWideRow = overrideRows.find((row) => row.userId === null);
return {
personal: personalRow ? mapViewRow(personalRow) : null,
orgWide: orgWideRow ? mapViewRow(orgWideRow) : null
};
}
export function listVisibleLauncherViews(
rows: Array<typeof launcherViews.$inferSelect>
): LauncherViewRecord[] {
return rows.filter((row) => !row.isDefault).map(mapViewRow);
}
export async function findDefaultViewOverride(
orgId: string,
orgWide: boolean,
userId: string
) {
const [existing] = await db
.select()
.from(launcherViews)
.where(
and(
eq(launcherViews.orgId, orgId),
eq(launcherViews.isDefault, true),
orgWide
? isNull(launcherViews.userId)
: eq(launcherViews.userId, userId)
)
)
.limit(1);
return existing ?? null;
}
export async function upsertDefaultViewOverride({
orgId,
userId,
orgWide,
config
}: {
orgId: string;
userId: string;
orgWide: boolean;
config: LauncherViewConfig;
}) {
const now = moment().toISOString();
const existing = await findDefaultViewOverride(orgId, orgWide, userId);
if (existing) {
const [updated] = await db
.update(launcherViews)
.set({
config: JSON.stringify(config),
updatedAt: now
})
.where(eq(launcherViews.viewId, existing.viewId))
.returning();
return mapViewRow(updated);
}
const [created] = await db
.insert(launcherViews)
.values({
orgId,
userId: orgWide ? null : userId,
name: "",
isDefault: true,
config: JSON.stringify(config),
createdAt: now,
updatedAt: now
})
.returning();
return mapViewRow(created);
}
export async function deleteDefaultViewOverride({
orgId,
userId,
orgWide
}: {
orgId: string;
userId: string;
orgWide: boolean;
}) {
const existing = await findDefaultViewOverride(orgId, orgWide, userId);
if (!existing) {
return;
}
await db
.delete(launcherViews)
.where(eq(launcherViews.viewId, existing.viewId));
}
export async function deleteAllDefaultViewOverrides(
orgId: string,
userId: string
) {
await deleteDefaultViewOverride({ orgId, userId, orgWide: false });
await deleteDefaultViewOverride({ orgId, userId, orgWide: true });
}
+262 -40
View File
@@ -1,3 +1,4 @@
import { createHash } from "node:crypto";
import { db } from "@server/db";
import {
exitNodes,
@@ -31,13 +32,15 @@ import {
isNull,
like,
or,
sql
sql,
type SQL
} from "drizzle-orm";
import {
formatPublicResourceAccess,
formatSiteResourceAccess
} from "./formatLauncherAccess";
import {
LAUNCHER_FLAT_GROUP_KEY,
LAUNCHER_NO_SITE_GROUP_KEY,
LAUNCHER_UNLABELED_GROUP_KEY,
type LauncherFilterListQuery,
@@ -59,6 +62,68 @@ export type AccessibleIds = {
};
const LAUNCHER_ACCESSIBLE_IDS_TTL_SEC = 60;
const LAUNCHER_RESOURCES_RESULT_TTL_SEC = 60;
const LAUNCHER_GROUPS_RESULT_TTL_SEC = 60;
type LauncherResourcesCacheEntry = {
items: LauncherResource[];
total: number;
};
type LauncherGroupsCacheEntry = {
groups: LauncherGroup[];
total: number;
};
function launcherListQueryHash(
userRoleIds: number[],
query: LauncherListQuery,
extra?: Record<string, string>
) {
const payload = JSON.stringify({
roles: [...userRoleIds].sort((a, b) => a - b),
query: query.query,
groupBy: query.groupBy,
siteIds: query.siteIds ?? "",
labelIds: query.labelIds ?? "",
sort_by: query.sort_by,
order: query.order,
...extra
});
return createHash("sha256").update(payload).digest("hex").slice(0, 16);
}
function launcherResourcesQueryHash(
userRoleIds: number[],
query: LauncherListQuery & { groupKey: string }
) {
return launcherListQueryHash(userRoleIds, query, {
groupKey: query.groupKey
});
}
function launcherGroupsQueryHash(
userRoleIds: number[],
query: LauncherListQuery
) {
return launcherListQueryHash(userRoleIds, query);
}
function launcherResourcesCacheKey(
orgId: string,
userId: string,
queryHash: string
) {
return `launcher:results:${orgId}:${userId}:${queryHash}`;
}
function launcherGroupsCacheKey(
orgId: string,
userId: string,
queryHash: string
) {
return `launcher:groups:${orgId}:${userId}:${queryHash}`;
}
function launcherAccessibleIdsCacheKey(
orgId: string,
@@ -194,6 +259,21 @@ function searchPattern(query: string) {
return `%${query.trim()}%`;
}
function combineOrConditions(
...conditions: (SQL | undefined)[]
): SQL | undefined {
const parts = conditions.filter(
(condition): condition is SQL => !!condition
);
if (parts.length === 0) {
return undefined;
}
if (parts.length === 1) {
return parts[0];
}
return or(...parts);
}
function buildSearchConditionForPublic(
query: string,
labelsFeatureEnabled: boolean
@@ -205,7 +285,17 @@ function buildSearchConditionForPublic(
const queryList = [
like(sql`LOWER(${resources.name})`, pattern),
like(sql`LOWER(${resources.fullDomain})`, pattern),
like(sql`LOWER(cast(${resources.proxyPort} as text))`, pattern)
like(sql`LOWER(cast(${resources.proxyPort} as text))`, pattern),
inArray(
resources.resourceId,
db
.select({ id: resources.resourceId })
.from(resources)
.leftJoin(targets, eq(targets.resourceId, resources.resourceId))
.leftJoin(sites, eq(targets.siteId, sites.siteId))
.leftJoin(exitNodes, eq(sites.exitNodeId, exitNodes.exitNodeId))
.where(like(sql`LOWER(${exitNodes.endpoint})`, pattern))
)
];
if (labelsFeatureEnabled) {
@@ -238,6 +328,11 @@ function buildSearchConditionForSiteResource(
const queryList = [
like(sql`LOWER(${siteResources.name})`, pattern),
like(sql`LOWER(${siteResources.destination})`, pattern),
like(
sql`LOWER(cast(${siteResources.destinationPort} as text))`,
pattern
),
like(sql`LOWER(${siteResources.scheme})`, pattern),
like(sql`LOWER(${siteResources.alias})`, pattern),
like(sql`LOWER(${siteResources.fullDomain})`, pattern),
like(sql`LOWER(${siteResources.aliasAddress})`, pattern)
@@ -262,6 +357,79 @@ function buildSearchConditionForSiteResource(
return or(...queryList);
}
async function filterPublicResourceIdsByTextSearch(
orgId: string,
resourceIds: number[],
query: string,
labelsFeatureEnabled: boolean
): Promise<number[]> {
if (!query.trim() || resourceIds.length === 0) {
return resourceIds;
}
const textMatch = combineOrConditions(
buildSearchConditionForPublic(query, labelsFeatureEnabled),
buildSiteNameSearchCondition(query)
);
if (!textMatch) {
return resourceIds;
}
const rows = await db
.selectDistinct({ resourceId: resources.resourceId })
.from(resources)
.leftJoin(targets, eq(targets.resourceId, resources.resourceId))
.leftJoin(sites, eq(targets.siteId, sites.siteId))
.where(
and(
inArray(resources.resourceId, resourceIds),
eq(resources.orgId, orgId),
eq(resources.enabled, true),
textMatch
)
);
return rows.map((row) => row.resourceId);
}
async function filterSiteResourceIdsByTextSearch(
orgId: string,
siteResourceIds: number[],
query: string,
labelsFeatureEnabled: boolean
): Promise<number[]> {
if (!query.trim() || siteResourceIds.length === 0) {
return siteResourceIds;
}
const textMatch = combineOrConditions(
buildSearchConditionForSiteResource(query, labelsFeatureEnabled),
buildSiteNameSearchCondition(query)
);
if (!textMatch) {
return siteResourceIds;
}
const rows = await db
.selectDistinct({ siteResourceId: siteResources.siteResourceId })
.from(siteResources)
.leftJoin(
siteNetworks,
eq(siteResources.networkId, siteNetworks.networkId)
)
.leftJoin(sites, eq(siteNetworks.siteId, sites.siteId))
.where(
and(
inArray(siteResources.siteResourceId, siteResourceIds),
eq(siteResources.orgId, orgId),
eq(siteResources.enabled, true),
textMatch
)
);
return rows.map((row) => row.siteResourceId);
}
async function labelsEnabled(orgId: string): Promise<boolean> {
return isLicensedOrSubscribed(orgId, tierMatrix.labels);
}
@@ -588,9 +756,8 @@ async function listSiteGroups(
});
const total = groups.length;
const offset = (query.page - 1) * query.pageSize;
return {
groups: groups.slice(offset, offset + query.pageSize),
groups,
total
};
}
@@ -788,26 +955,53 @@ async function listLabelGroups(
});
const total = groups.length;
const offset = (query.page - 1) * query.pageSize;
return {
groups: groups.slice(offset, offset + query.pageSize),
groups,
total
};
}
async function listLauncherGroupsForUserUncached(
orgId: string,
userId: string,
userRoleIds: number[],
query: LauncherListQuery
): Promise<LauncherGroupsCacheEntry> {
const accessible = await resolveAccessibleIds(orgId, userId, userRoleIds);
if (query.groupBy === "label") {
return listLabelGroups(orgId, accessible, query);
}
return listSiteGroups(orgId, accessible, query);
}
export async function listLauncherGroupsForUser(
orgId: string,
userId: string,
userRoleIds: number[],
query: LauncherListQuery
): Promise<{ groups: LauncherGroup[]; total: number }> {
const accessible = await resolveAccessibleIds(orgId, userId, userRoleIds);
const queryHash = launcherGroupsQueryHash(userRoleIds, query);
const cacheKey = launcherGroupsCacheKey(orgId, userId, queryHash);
const cached = await cache.get<LauncherGroupsCacheEntry>(cacheKey);
if (query.groupBy === "label") {
return listLabelGroups(orgId, accessible, query);
let result = cached;
if (!result) {
result = await listLauncherGroupsForUserUncached(
orgId,
userId,
userRoleIds,
query
);
await cache.set(cacheKey, result, LAUNCHER_GROUPS_RESULT_TTL_SEC);
}
return listSiteGroups(orgId, accessible, query);
const offset = (query.page - 1) * query.pageSize;
return {
groups: result.groups.slice(offset, offset + query.pageSize),
total: result.total
};
}
async function mapPublicResources(
@@ -1018,26 +1212,6 @@ function filterResourcesByLabel(
);
}
function filterResourcesBySearch(
items: LauncherResource[],
query: string
): LauncherResource[] {
if (!query.trim()) {
return items;
}
const pattern = query.trim().toLowerCase();
return items.filter(
(item) =>
item.name.toLowerCase().includes(pattern) ||
item.accessDisplay.toLowerCase().includes(pattern) ||
item.accessCopyValue.toLowerCase().includes(pattern) ||
item.labels.some((label) =>
label.name.toLowerCase().includes(pattern)
) ||
item.site?.name.toLowerCase().includes(pattern)
);
}
function sortLauncherResources(
items: LauncherResource[],
order: "asc" | "desc"
@@ -1050,12 +1224,12 @@ function sortLauncherResources(
});
}
export async function listLauncherResourcesForUser(
async function listLauncherResourcesForUserUncached(
orgId: string,
userId: string,
userRoleIds: number[],
query: LauncherListQuery & { groupKey: string }
): Promise<{ resources: LauncherResource[]; total: number }> {
): Promise<LauncherResourcesCacheEntry> {
const accessible = await resolveAccessibleIds(orgId, userId, userRoleIds);
const siteFilterIds = parseIdListParam(query.siteIds);
@@ -1128,6 +1302,27 @@ export async function listLauncherResourcesForUser(
}
}
const labelsFeatureEnabled = await labelsEnabled(orgId);
if (query.query.trim()) {
if (filteredResourceIds.length > 0) {
filteredResourceIds = await filterPublicResourceIdsByTextSearch(
orgId,
filteredResourceIds,
query.query,
labelsFeatureEnabled
);
}
if (filteredSiteResourceIds.length > 0) {
filteredSiteResourceIds = await filterSiteResourceIdsByTextSearch(
orgId,
filteredSiteResourceIds,
query.query,
labelsFeatureEnabled
);
}
}
const labelMaps = await fetchLabelsForResources(
orgId,
filteredResourceIds,
@@ -1159,21 +1354,48 @@ export async function listLauncherResourcesForUser(
]);
let items = [...publicItems, ...siteItems];
items = filterResourcesBySearch(items, query.query);
if (query.groupBy === "label") {
items = filterResourcesByLabel(items, query.groupKey);
} else if (query.groupBy === "site") {
items = filterResourcesBySite(items, query.groupKey);
if (query.groupKey !== LAUNCHER_FLAT_GROUP_KEY) {
if (query.groupBy === "label") {
items = filterResourcesByLabel(items, query.groupKey);
} else if (query.groupBy === "site") {
items = filterResourcesBySite(items, query.groupKey);
}
}
items = sortLauncherResources(items, query.order);
const total = items.length;
return {
items,
total: items.length
};
}
export async function listLauncherResourcesForUser(
orgId: string,
userId: string,
userRoleIds: number[],
query: LauncherListQuery & { groupKey: string }
): Promise<{ resources: LauncherResource[]; total: number }> {
const queryHash = launcherResourcesQueryHash(userRoleIds, query);
const cacheKey = launcherResourcesCacheKey(orgId, userId, queryHash);
const cached = await cache.get<LauncherResourcesCacheEntry>(cacheKey);
let result = cached;
if (!result) {
result = await listLauncherResourcesForUserUncached(
orgId,
userId,
userRoleIds,
query
);
await cache.set(cacheKey, result, LAUNCHER_RESOURCES_RESULT_TTL_SEC);
}
const offset = (query.page - 1) * query.pageSize;
return {
resources: items.slice(offset, offset + query.pageSize),
total
resources: result.items.slice(offset, offset + query.pageSize),
total: result.total
};
}
+133
View File
@@ -0,0 +1,133 @@
import { regionalCache as cache } from "#dynamic/lib/cache";
import {
listLauncherGroupsForUser,
resolveAccessibleIds
} from "./launcherResourceAccess";
import {
parseIdListParam,
type LauncherScaleInfo,
type LauncherScaleQuery
} from "./types";
export const LAUNCHER_FULL_MAX_RESOURCES = 2000;
export const LAUNCHER_FULL_MAX_SITE_GROUPS = 200;
export const LAUNCHER_FULL_MAX_LABEL_GROUPS = 100;
export const LAUNCHER_FILTERED_SITE_GROUPING_MAX = 20;
const LAUNCHER_SCALE_COUNTS_TTL_SEC = 60;
type LauncherScaleCountsCacheEntry = {
resourceCount: number;
siteGroupCount: number;
labelGroupCount: number;
mode: LauncherScaleInfo["mode"];
};
function launcherScaleCountsCacheKey(
orgId: string,
userId: string,
roleIds: number[]
) {
const rolesKey = [...roleIds].sort((a, b) => a - b).join(",");
return `launcher:scale:counts:${orgId}:${userId}:${rolesKey}`;
}
function buildScaleCapabilities(
counts: LauncherScaleCountsCacheEntry,
query: LauncherScaleQuery
): LauncherScaleInfo["capabilities"] {
const siteFilterIds = parseIdListParam(query.siteIds);
const labelFilterIds = parseIdListParam(query.labelIds);
return {
allowSiteGrouping:
counts.siteGroupCount <= LAUNCHER_FULL_MAX_SITE_GROUPS ||
(siteFilterIds.length > 0 &&
siteFilterIds.length <= LAUNCHER_FILTERED_SITE_GROUPING_MAX),
allowLabelGrouping:
counts.labelGroupCount <= LAUNCHER_FULL_MAX_LABEL_GROUPS ||
(labelFilterIds.length > 0 &&
labelFilterIds.length <= LAUNCHER_FILTERED_SITE_GROUPING_MAX),
requireSearchOrFilter:
counts.mode === "compact" &&
counts.resourceCount > LAUNCHER_FULL_MAX_RESOURCES
};
}
async function getLauncherScaleCountsForUser(
orgId: string,
userId: string,
userRoleIds: number[]
): Promise<LauncherScaleCountsCacheEntry> {
const cacheKey = launcherScaleCountsCacheKey(orgId, userId, userRoleIds);
const cached = await cache.get<LauncherScaleCountsCacheEntry>(cacheKey);
if (cached) {
return cached;
}
const accessible = await resolveAccessibleIds(orgId, userId, userRoleIds);
const resourceCount =
accessible.resourceIds.length + accessible.siteResourceIds.length;
const baselineQuery = {
query: "",
groupBy: "site" as const,
siteIds: undefined,
labelIds: undefined,
sort_by: "name" as const,
order: "asc" as const,
page: 1,
pageSize: 1
};
const [{ total: siteGroupCount }, { total: labelGroupCount }] =
await Promise.all([
listLauncherGroupsForUser(
orgId,
userId,
userRoleIds,
baselineQuery
),
listLauncherGroupsForUser(orgId, userId, userRoleIds, {
...baselineQuery,
groupBy: "label"
})
]);
const mode =
resourceCount <= LAUNCHER_FULL_MAX_RESOURCES &&
siteGroupCount <= LAUNCHER_FULL_MAX_SITE_GROUPS
? "full"
: "compact";
const result: LauncherScaleCountsCacheEntry = {
resourceCount,
siteGroupCount,
labelGroupCount,
mode
};
await cache.set(cacheKey, result, LAUNCHER_SCALE_COUNTS_TTL_SEC);
return result;
}
export async function getLauncherScaleForUser(
orgId: string,
userId: string,
userRoleIds: number[],
query: LauncherScaleQuery
): Promise<LauncherScaleInfo> {
const counts = await getLauncherScaleCountsForUser(
orgId,
userId,
userRoleIds
);
return {
mode: counts.mode,
resourceCount: counts.resourceCount,
siteGroupCount: counts.siteGroupCount,
labelGroupCount: counts.labelGroupCount,
capabilities: buildScaleCapabilities(counts, query)
};
}
@@ -0,0 +1,60 @@
import { response } from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors";
import { fromZodError } from "zod-validation-error";
import { getLauncherScaleForUser } from "./launcherScale";
import { launcherScaleQuerySchema } from "./types";
export async function listLauncherScale(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const orgId = req.userOrgId;
const userId = req.user!.userId;
if (!orgId) {
return next(
createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID")
);
}
const parsed = launcherScaleQuerySchema.safeParse(req.query);
if (!parsed.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromZodError(parsed.error)
)
);
}
const scale = await getLauncherScaleForUser(
orgId,
userId,
req.userOrgRoleIds ?? [],
parsed.data
);
return response(res, {
data: { scale },
success: true,
error: false,
message: "Launcher scale retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
if (createHttpError.isHttpError(error)) {
return next(error);
}
console.error("Error listing launcher scale:", error);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Internal server error"
)
);
}
}
+6 -17
View File
@@ -4,22 +4,10 @@ import HttpCode from "@server/types/HttpCode";
import { and, eq, isNull, or } from "drizzle-orm";
import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors";
import { launcherViewConfigSchema, type LauncherViewRecord } from "./types";
function mapViewRow(
row: typeof launcherViews.$inferSelect
): LauncherViewRecord {
return {
viewId: row.viewId,
orgId: row.orgId,
userId: row.userId,
name: row.name,
config: launcherViewConfigSchema.parse(JSON.parse(row.config)),
createdAt: row.createdAt,
updatedAt: row.updatedAt,
isOrgWide: row.userId == null
};
}
import {
extractDefaultViewOverrides,
listVisibleLauncherViews
} from "./launcherDefaultView";
export async function listLauncherViews(
req: Request,
@@ -51,7 +39,8 @@ export async function listLauncherViews(
return response(res, {
data: {
views: rows.map(mapViewRow)
views: listVisibleLauncherViews(rows),
defaultViewOverrides: extractDefaultViewOverrides(rows)
},
success: true,
error: false,
+55 -4
View File
@@ -2,9 +2,10 @@ import { z } from "zod";
export const LAUNCHER_UNLABELED_GROUP_KEY = "unlabeled";
export const LAUNCHER_NO_SITE_GROUP_KEY = "no-site";
export const LAUNCHER_FLAT_GROUP_KEY = "__all__";
export const launcherViewConfigSchema = z.object({
groupBy: z.enum(["site", "label"]).default("site"),
groupBy: z.enum(["site", "label", "none"]).default("site"),
layout: z.enum(["grid", "list"]).default("grid"),
sortBy: z.literal("name").default("name"),
order: z.enum(["asc", "desc"]).default("asc"),
@@ -88,10 +89,31 @@ export type LauncherViewRecord = {
createdAt: string;
updatedAt: string;
isOrgWide: boolean;
isDefault: boolean;
};
export type LauncherDefaultViewOverrides = {
personal: LauncherViewRecord | null;
orgWide: LauncherViewRecord | null;
};
export function getEffectiveDefaultLauncherConfig(
overrides: LauncherDefaultViewOverrides
): LauncherViewConfig {
if (overrides.personal) {
return overrides.personal.config;
}
if (overrides.orgWide) {
return overrides.orgWide.config;
}
return defaultLauncherViewConfig;
}
export type ListLauncherViewsResponse = {
views: LauncherViewRecord[];
defaultViewOverrides: LauncherDefaultViewOverrides;
};
export const launcherFilterListQuerySchema = z.strictObject({
@@ -100,8 +122,8 @@ export const launcherFilterListQuerySchema = z.strictObject({
.int()
.positive()
.optional()
.catch(500)
.default(500),
.catch(20)
.default(20),
page: z.coerce.number().int().min(1).optional().catch(1).default(1),
query: z.string().optional().default("")
});
@@ -138,7 +160,7 @@ export const launcherListQuerySchema = z.strictObject({
.default(20),
page: z.coerce.number().int().min(1).optional().catch(1).default(1),
query: z.string().optional().default(""),
groupBy: z.enum(["site", "label"]).optional().default("site"),
groupBy: z.enum(["site", "label", "none"]).optional().default("site"),
groupKey: z.string().optional(),
siteIds: z.string().optional(),
labelIds: z.string().optional(),
@@ -163,3 +185,32 @@ export const DEFAULT_LAUNCHER_VIEW_ID = "default" as const;
export type LauncherViewSelection =
| { type: "default" }
| { type: "saved"; viewId: number };
export type LauncherScaleCapabilities = {
allowSiteGrouping: boolean;
allowLabelGrouping: boolean;
requireSearchOrFilter: boolean;
};
export type LauncherScaleInfo = {
mode: "full" | "compact";
resourceCount: number;
siteGroupCount: number;
labelGroupCount: number;
capabilities: LauncherScaleCapabilities;
};
export type ListLauncherScaleResponse = {
scale: LauncherScaleInfo;
};
export const launcherScaleQuerySchema = z.strictObject({
query: z.string().optional().default(""),
groupBy: z.enum(["site", "label", "none"]).optional().default("site"),
siteIds: z.string().optional(),
labelIds: z.string().optional(),
sort_by: z.literal("name").optional().default("name"),
order: z.enum(["asc", "desc"]).optional().default("asc")
});
export type LauncherScaleQuery = z.infer<typeof launcherScaleQuerySchema>;
+11 -1
View File
@@ -66,6 +66,15 @@ export async function updateLauncherView(
);
}
if (existing.isDefault) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Use the default view save endpoint for this view"
)
);
}
const isPersonalView = existing.userId === userId;
const isOrgWideView = existing.userId == null;
const canManageOrgWide = await checkUserActionPermission(
@@ -135,7 +144,8 @@ export async function updateLauncherView(
),
createdAt: updated.createdAt,
updatedAt: updated.updatedAt,
isOrgWide: updated.userId == null
isOrgWide: updated.userId == null,
isDefault: updated.isDefault
},
success: true,
error: false,
@@ -0,0 +1,82 @@
import { response } from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors";
import { fromZodError } from "zod-validation-error";
import { z } from "zod";
import { ActionsEnum, checkUserActionPermission } from "@server/auth/actions";
import { upsertDefaultViewOverride } from "./launcherDefaultView";
import { launcherViewConfigSchema } from "./types";
const upsertLauncherDefaultViewBodySchema = z.strictObject({
config: launcherViewConfigSchema,
orgWide: z.boolean().optional().default(false)
});
export async function upsertLauncherDefaultView(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const orgId = req.userOrgId;
const userId = req.user!.userId;
if (!orgId) {
return next(
createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID")
);
}
const parsed = upsertLauncherDefaultViewBodySchema.safeParse(req.body);
if (!parsed.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromZodError(parsed.error)
)
);
}
if (parsed.data.orgWide) {
const canManageOrgWide = await checkUserActionPermission(
ActionsEnum.createOrgWideLauncherView,
req
);
if (!canManageOrgWide) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"User does not have permission perform this action"
)
);
}
}
const view = await upsertDefaultViewOverride({
orgId,
userId,
orgWide: parsed.data.orgWide,
config: parsed.data.config
});
return response(res, {
data: view,
success: true,
error: false,
message: "Launcher default view saved successfully",
status: HttpCode.OK
});
} catch (error) {
if (createHttpError.isHttpError(error)) {
return next(error);
}
console.error("Error saving launcher default view:", error);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Internal server error"
)
);
}
}
@@ -50,7 +50,7 @@ export const handleApplyBlueprintMessage: MessageHandler = async (context) => {
source: "NEWT"
});
} catch (error) {
logger.error(`Failed to update database from config: ${error}`);
logger.debug(`Failed to update database from config: ${error}`);
return {
message: {
type: "newt/blueprint/results",
@@ -1,7 +1,12 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { resources, resourceWhitelist } from "@server/db";
import {
resources,
resourceWhitelist,
resourcePolicies,
resourcePolicyWhiteList
} from "@server/db";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
@@ -103,40 +108,99 @@ export async function addEmailToResourceWhitelist(
);
}
if (!resource.emailWhitelistEnabled) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Email whitelist is not enabled for this resource"
)
);
// A shared policy takes precedence over the resource's inline
// (default) policy, which takes precedence over the resource's own
// direct whitelist fields. This mirrors the precedence used at
// request time in authWithWhitelist.ts / getResourceAuthInfo.ts.
const policyId =
resource.resourcePolicyId ?? resource.defaultResourcePolicyId;
if (policyId !== null) {
const [policy] = await db
.select()
.from(resourcePolicies)
.where(eq(resourcePolicies.resourcePolicyId, policyId));
if (!policy) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
"Resource policy not found"
)
);
}
if (!policy.emailWhitelistEnabled) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Email whitelist is not enabled for this resource"
)
);
}
const existingEntry = await db
.select()
.from(resourcePolicyWhiteList)
.where(
and(
eq(
resourcePolicyWhiteList.resourcePolicyId,
policyId
),
eq(resourcePolicyWhiteList.email, email)
)
);
if (existingEntry.length > 0) {
return next(
createHttpError(
HttpCode.CONFLICT,
"Email already exists in whitelist"
)
);
}
await db.insert(resourcePolicyWhiteList).values({
email,
resourcePolicyId: policyId
});
} else {
if (!resource.emailWhitelistEnabled) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Email whitelist is not enabled for this resource"
)
);
}
// Check if email already exists in whitelist
const existingEntry = await db
.select()
.from(resourceWhitelist)
.where(
and(
eq(resourceWhitelist.resourceId, resourceId),
eq(resourceWhitelist.email, email)
)
);
if (existingEntry.length > 0) {
return next(
createHttpError(
HttpCode.CONFLICT,
"Email already exists in whitelist"
)
);
}
await db.insert(resourceWhitelist).values({
email,
resourceId
});
}
// Check if email already exists in whitelist
const existingEntry = await db
.select()
.from(resourceWhitelist)
.where(
and(
eq(resourceWhitelist.resourceId, resourceId),
eq(resourceWhitelist.email, email)
)
);
if (existingEntry.length > 0) {
return next(
createHttpError(
HttpCode.CONFLICT,
"Email already exists in whitelist"
)
);
}
await db.insert(resourceWhitelist).values({
email,
resourceId
});
return response(res, {
data: {},
success: true,
@@ -96,13 +96,17 @@ export async function getResourceWhitelist(
);
}
const isInlinePolicy =
resource.resourcePolicyId === null &&
resource.defaultResourcePolicyId !== null;
// A shared policy takes precedence over the resource's inline
// (default) policy, which takes precedence over the resource's own
// direct whitelist fields. This mirrors the precedence used at
// request time in authWithWhitelist.ts / getResourceAuthInfo.ts.
const policyId =
resource.resourcePolicyId ?? resource.defaultResourcePolicyId;
const whitelist = isInlinePolicy
? await queryPolicyWhitelist(resource.defaultResourcePolicyId!)
: await queryWhitelist(resourceId);
const whitelist =
policyId !== null
? await queryPolicyWhitelist(policyId)
: await queryWhitelist(resourceId);
return response<GetResourceWhitelistResponse>(res, {
data: {
+4 -4
View File
@@ -616,8 +616,8 @@ export async function listResources(
and(
inArray(resources.mode, browserGatewayModes),
or(
eq(effectiveSso, true),
eq(effectiveWhitelist, true),
effectiveSso,
effectiveWhitelist,
not(isNull(effectiveHeaderAuthId)),
not(isNull(effectivePincodeId)),
not(isNull(effectivePasswordId))
@@ -629,8 +629,8 @@ export async function listResources(
conditions.push(
and(
inArray(resources.mode, browserGatewayModes),
not(eq(effectiveSso, true)),
not(eq(effectiveWhitelist, true)),
not(effectiveSso),
not(effectiveWhitelist),
isNull(effectiveHeaderAuthId),
isNull(effectivePincodeId),
isNull(effectivePasswordId)
@@ -1,7 +1,12 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { resources, resourceWhitelist } from "@server/db";
import {
resources,
resourceWhitelist,
resourcePolicies,
resourcePolicyWhiteList
} from "@server/db";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
@@ -102,44 +107,110 @@ export async function removeEmailFromResourceWhitelist(
);
}
if (!resource.emailWhitelistEnabled) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Email whitelist is not enabled for this resource"
)
);
// A shared policy takes precedence over the resource's inline
// (default) policy, which takes precedence over the resource's own
// direct whitelist fields. This mirrors the precedence used at
// request time in authWithWhitelist.ts / getResourceAuthInfo.ts.
const policyId =
resource.resourcePolicyId ?? resource.defaultResourcePolicyId;
if (policyId !== null) {
const [policy] = await db
.select()
.from(resourcePolicies)
.where(eq(resourcePolicies.resourcePolicyId, policyId));
if (!policy) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
"Resource policy not found"
)
);
}
if (!policy.emailWhitelistEnabled) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Email whitelist is not enabled for this resource"
)
);
}
const existingEntry = await db
.select()
.from(resourcePolicyWhiteList)
.where(
and(
eq(
resourcePolicyWhiteList.resourcePolicyId,
policyId
),
eq(resourcePolicyWhiteList.email, email)
)
);
if (existingEntry.length === 0) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
"Email not found in whitelist"
)
);
}
await db
.delete(resourcePolicyWhiteList)
.where(
and(
eq(
resourcePolicyWhiteList.resourcePolicyId,
policyId
),
eq(resourcePolicyWhiteList.email, email)
)
);
} else {
if (!resource.emailWhitelistEnabled) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Email whitelist is not enabled for this resource"
)
);
}
// Check if email exists in whitelist
const existingEntry = await db
.select()
.from(resourceWhitelist)
.where(
and(
eq(resourceWhitelist.resourceId, resourceId),
eq(resourceWhitelist.email, email)
)
);
if (existingEntry.length === 0) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
"Email not found in whitelist"
)
);
}
await db
.delete(resourceWhitelist)
.where(
and(
eq(resourceWhitelist.resourceId, resourceId),
eq(resourceWhitelist.email, email)
)
);
}
// Check if email exists in whitelist
const existingEntry = await db
.select()
.from(resourceWhitelist)
.where(
and(
eq(resourceWhitelist.resourceId, resourceId),
eq(resourceWhitelist.email, email)
)
);
if (existingEntry.length === 0) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
"Email not found in whitelist"
)
);
}
await db
.delete(resourceWhitelist)
.where(
and(
eq(resourceWhitelist.resourceId, resourceId),
eq(resourceWhitelist.email, email)
)
);
return response(res, {
data: {},
success: true,
@@ -109,13 +109,14 @@ export async function setResourceWhitelist(
);
}
const isInlinePolicy =
resource.resourcePolicyId === null &&
resource.defaultResourcePolicyId !== null;
if (isInlinePolicy) {
const policyId = resource.defaultResourcePolicyId!;
// A shared policy takes precedence over the resource's inline
// (default) policy, which takes precedence over the resource's own
// direct whitelist fields. This mirrors the precedence used at
// request time in authWithWhitelist.ts / getResourceAuthInfo.ts.
const policyId =
resource.resourcePolicyId ?? resource.defaultResourcePolicyId;
if (policyId !== null) {
const [policy] = await db
.select()
.from(resourcePolicies)
+125 -91
View File
@@ -68,9 +68,9 @@ const updateSiteResourceSchema = z
"Fully qualified domain name with optional wildcards, e.g., example.internal, *.example.internal, or host-0?.example.internal",
example: "service.example.internal"
}),
userIds: z.array(z.string()),
roleIds: z.array(z.int()),
clientIds: z.array(z.int()),
userIds: z.array(z.string()).optional(),
roleIds: z.array(z.int()).optional(),
clientIds: z.array(z.int()).optional(),
tcpPortRangeString: portRangeStringSchema,
udpPortRangeString: portRangeStringSchema,
disableIcmp: z.boolean().optional(),
@@ -167,6 +167,10 @@ const updateSiteResourceSchema = z
)
.refine(
(data) => {
// if neither is provided, the existing site associations are left unchanged
if (data.siteIds === undefined && data.siteId === undefined) {
return true;
}
return (
(data.siteIds !== undefined && data.siteIds.length > 0) ||
data.siteId !== undefined
@@ -263,7 +267,7 @@ export async function updateSiteResource(
const { siteResourceId } = parsedParams.data;
const {
name,
siteIds: siteIdsInput = [], // because it can change
siteIds: siteIdsInput,
siteId,
niceId,
mode,
@@ -287,9 +291,14 @@ export async function updateSiteResource(
} = parsedBody.data;
// Backward compatibility: merge deprecated siteId into siteIds array
const siteIds = [...siteIdsInput];
if (siteId !== undefined && !siteIds.includes(siteId)) {
siteIds.push(siteId);
const siteIdsProvided =
siteIdsInput !== undefined || siteId !== undefined;
let siteIds: number[] | undefined;
if (siteIdsProvided) {
siteIds = [...(siteIdsInput ?? [])];
if (siteId !== undefined && !siteIds.includes(siteId)) {
siteIds.push(siteId);
}
}
// Check if site resource exists
@@ -356,20 +365,22 @@ export async function updateSiteResource(
}
// Verify the site exists and belongs to the org
const sitesToAssign = await db
.select()
.from(sites)
.where(
and(
inArray(sites.siteId, siteIds),
eq(sites.orgId, existingSiteResource.orgId)
)
);
if (siteIds !== undefined) {
const sitesToAssign = await db
.select()
.from(sites)
.where(
and(
inArray(sites.siteId, siteIds),
eq(sites.orgId, existingSiteResource.orgId)
)
);
if (sitesToAssign.length !== siteIds.length) {
return next(
createHttpError(HttpCode.NOT_FOUND, "Some site not found")
);
if (sitesToAssign.length !== siteIds.length) {
return next(
createHttpError(HttpCode.NOT_FOUND, "Some site not found")
);
}
}
// Only check if destination is an IP address
@@ -463,7 +474,8 @@ export async function updateSiteResource(
}
let updatedSiteResource: SiteResource | undefined;
let updatedSiteIds: number[] = [];
// defaults to the existing sites; only overwritten below if siteIds/siteId was provided
let updatedSiteIds: number[] = [...existingSiteIds];
await db.transaction(async (trx) => {
// Update the site resource
const sshPamSet =
@@ -522,81 +534,103 @@ export async function updateSiteResource(
//////////////////// update the associations ////////////////////
// delete the site - site resources associations
await trx
.delete(siteNetworks)
.where(
eq(siteNetworks.networkId, updatedSiteResource.networkId!)
);
for (const siteId of siteIds) {
await trx.insert(siteNetworks).values({
siteId: siteId,
networkId: updatedSiteResource.networkId!
});
updatedSiteIds.push(siteId);
}
await trx
.delete(clientSiteResources)
.where(eq(clientSiteResources.siteResourceId, siteResourceId));
if (clientIds.length > 0) {
await trx.insert(clientSiteResources).values(
clientIds.map((clientId) => ({
clientId,
siteResourceId
}))
);
}
await trx
.delete(userSiteResources)
.where(eq(userSiteResources.siteResourceId, siteResourceId));
if (userIds.length > 0) {
await trx.insert(userSiteResources).values(
userIds.map((userId) => ({
userId,
siteResourceId
}))
);
}
// Get all admin role IDs for this org to exclude from deletion
const adminRoles = await trx
.select()
.from(roles)
.where(
and(
eq(roles.isAdmin, true),
eq(roles.orgId, updatedSiteResource.orgId)
)
);
const adminRoleIds = adminRoles.map((role) => role.roleId);
if (adminRoleIds.length > 0) {
await trx.delete(roleSiteResources).where(
and(
eq(roleSiteResources.siteResourceId, siteResourceId),
ne(roleSiteResources.roleId, adminRoleIds[0]) // delete all but the admin role
)
);
} else {
if (siteIds !== undefined) {
// delete the site - site resources associations
await trx
.delete(roleSiteResources)
.delete(siteNetworks)
.where(
eq(roleSiteResources.siteResourceId, siteResourceId)
eq(
siteNetworks.networkId,
updatedSiteResource.networkId!
)
);
updatedSiteIds = [];
for (const siteId of siteIds) {
await trx.insert(siteNetworks).values({
siteId: siteId,
networkId: updatedSiteResource.networkId!
});
updatedSiteIds.push(siteId);
}
}
if (roleIds.length > 0) {
await trx.insert(roleSiteResources).values(
roleIds.map((roleId) => ({
roleId,
siteResourceId
}))
);
if (clientIds !== undefined) {
await trx
.delete(clientSiteResources)
.where(
eq(clientSiteResources.siteResourceId, siteResourceId)
);
if (clientIds.length > 0) {
await trx.insert(clientSiteResources).values(
clientIds.map((clientId) => ({
clientId,
siteResourceId
}))
);
}
}
if (userIds !== undefined) {
await trx
.delete(userSiteResources)
.where(
eq(userSiteResources.siteResourceId, siteResourceId)
);
if (userIds.length > 0) {
await trx.insert(userSiteResources).values(
userIds.map((userId) => ({
userId,
siteResourceId
}))
);
}
}
if (roleIds !== undefined) {
// Get all admin role IDs for this org to exclude from deletion
const adminRoles = await trx
.select()
.from(roles)
.where(
and(
eq(roles.isAdmin, true),
eq(roles.orgId, updatedSiteResource.orgId)
)
);
const adminRoleIds = adminRoles.map((role) => role.roleId);
if (adminRoleIds.length > 0) {
await trx.delete(roleSiteResources).where(
and(
eq(
roleSiteResources.siteResourceId,
siteResourceId
),
ne(roleSiteResources.roleId, adminRoleIds[0]) // delete all but the admin role
)
);
} else {
await trx
.delete(roleSiteResources)
.where(
eq(
roleSiteResources.siteResourceId,
siteResourceId
)
);
}
if (roleIds.length > 0) {
await trx.insert(roleSiteResources).values(
roleIds.map((roleId) => ({
roleId,
siteResourceId
}))
);
}
}
logger.info(`Updated site resource ${siteResourceId}`);
+2
View File
@@ -82,9 +82,11 @@ export default async function OrgPage(props: OrgPageProps) {
orgId={orgId}
isAdmin={isAdminOrOwner}
views={launcherData.views}
defaultViewOverrides={launcherData.defaultViewOverrides}
activeViewId={launcherData.activeViewId}
config={launcherData.config}
savedConfig={launcherData.savedConfig}
scale={launcherData.scale}
groups={launcherData.groups}
groupsPagination={launcherData.groupsPagination}
/>
@@ -41,7 +41,7 @@ import {
import { AxiosResponse } from "axios";
import { useTranslations } from "next-intl";
import { useParams, useRouter } from "next/navigation";
import { useActionState } from "react";
import { useActionState, useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
@@ -137,11 +137,21 @@ function ProxyResourceHttpForm({
});
const [, formAction, saveLoading] = useActionState(onSubmit, null);
const [headersValid, setHeadersValid] = useState(true);
async function onSubmit() {
const isValid = await form.trigger();
if (!isValid) return;
if (!headersValid) {
toast({
variant: "destructive",
title: t("settingsErrorUpdate"),
description: t("headersValidationError")
});
return;
}
const data = form.getValues();
const res = await api
@@ -318,6 +328,9 @@ function ProxyResourceHttpForm({
onChange={
field.onChange
}
onValidityChange={
setHeadersValid
}
rows={4}
/>
</FormControl>
@@ -341,7 +354,7 @@ function ProxyResourceHttpForm({
<Button
type="submit"
loading={saveLoading}
disabled={saveLoading}
disabled={saveLoading || !headersValid}
form="http-settings-form"
>
{t("saveSettings")}
+12 -3
View File
@@ -1,3 +1,4 @@
import { cn } from "@app/lib/cn";
import { Check, Copy } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
@@ -7,12 +8,14 @@ type CopyToClipboardProps = {
text: string;
displayText?: string;
isLink?: boolean;
className?: string;
};
const CopyToClipboard = ({
text,
displayText,
isLink
isLink,
className
}: CopyToClipboardProps) => {
const [copied, setCopied] = useState(false);
@@ -48,14 +51,20 @@ const CopyToClipboard = ({
href={text}
target="_blank"
rel="noopener noreferrer"
className="truncate hover:underline text-sm min-w-0 max-w-full"
className={cn(
"truncate hover:underline text-sm min-w-0 max-w-full",
className
)}
title={text} // Shows full text on hover
>
{displayValue}
</Link>
) : (
<span
className="truncate text-sm min-w-0 max-w-full"
className={cn(
"truncate text-sm min-w-0 max-w-full",
className
)}
style={{
whiteSpace: "nowrap",
overflow: "hidden",
+77 -52
View File
@@ -2,24 +2,36 @@
import { useEffect, useState, useRef } from "react";
import { Textarea } from "@/components/ui/textarea";
import { useTranslations } from "next-intl";
interface HeadersInputProps {
value?: { name: string; value: string }[] | null;
onChange: (value: { name: string; value: string }[] | null) => void;
onValidityChange?: (isValid: boolean) => void;
placeholder?: string;
rows?: number;
className?: string;
}
// Mirrors the server side validation in updateResource.ts so that invalid
// input is caught (and shown to the user) before it is ever submitted,
// instead of being silently dropped in favor of the last known good value.
const validHeaderNamePattern = /^[a-zA-Z0-9!#$%&'*+\-.^_`|~]+$/;
const validHeaderValuePattern = /^[\t\x20-\x7E]*$/;
const templatePattern = /\{\{[^}]+\}\}/;
export function HeadersInput({
value = [],
onChange,
onValidityChange,
placeholder = `X-Example-Header: example-value
X-Another-Header: another-value`,
rows = 4,
className
}: HeadersInputProps) {
const t = useTranslations();
const [internalValue, setInternalValue] = useState("");
const [error, setError] = useState<string | null>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const isUserEditingRef = useRef(false);
@@ -34,37 +46,56 @@ X-Another-Header: another-value`,
.join("\n");
};
// Convert newline-separated string to header objects array
const convertToHeadersArray = (
// Parse newline-separated text into header objects, validating each line
// against the same rules enforced by the server. Returns either the
// parsed headers or an error message describing the first invalid line.
const parseHeaders = (
newlineSeparated: string
): { name: string; value: string }[] | null => {
if (!newlineSeparated || newlineSeparated.trim() === "") return [];
):
| { headers: { name: string; value: string }[]; error: null }
| { headers: null; error: string } => {
if (!newlineSeparated || newlineSeparated.trim() === "") {
return { headers: [], error: null };
}
return newlineSeparated
const lines = newlineSeparated
.split("\n")
.map((line) => line.trim())
.filter((line) => line.length > 0 && line.includes(":"))
.map((line) => {
const colonIndex = line.indexOf(":");
const name = line.substring(0, colonIndex).trim();
const value = line.substring(colonIndex + 1).trim();
.filter((line) => line.length > 0);
// Ensure header name conforms to HTTP header requirements
// Header names should be case-insensitive, contain only ASCII letters, digits, and hyphens
const normalizedName = name
.replace(/[^a-zA-Z0-9\-]/g, "")
.toLowerCase();
const headers: { name: string; value: string }[] = [];
return { name: normalizedName, value };
})
.filter((header) => header.name.length > 0); // Filter out headers with invalid names
for (const line of lines) {
const colonIndex = line.indexOf(":");
if (colonIndex === -1) {
return { headers: null, error: t("headersValidationError") };
}
const name = line.substring(0, colonIndex).trim();
const value = line.substring(colonIndex + 1).trim();
if (
!validHeaderNamePattern.test(name) ||
!validHeaderValuePattern.test(value) ||
templatePattern.test(name) ||
templatePattern.test(value)
) {
return { headers: null, error: t("headersValidationError") };
}
headers.push({ name, value });
}
return { headers, error: null };
};
// Update internal value when external value changes
// But only if the user is not currently editing (textarea not focused)
useEffect(() => {
if (!isUserEditingRef.current) {
setInternalValue(convertToNewlineSeparated(value));
setInternalValue(convertToNewlineSeparated(value ?? []));
setError(null);
onValidityChange?.(true);
}
}, [value]);
@@ -75,31 +106,20 @@ X-Another-Header: another-value`,
// Mark that user is actively editing
isUserEditingRef.current = true;
// Only update parent if the input is in a valid state
// Valid states: empty/whitespace only, or contains properly formatted headers
const result = parseHeaders(newValue);
if (newValue.trim() === "") {
// Empty input is valid - represents no headers
onChange([]);
} else {
// Check if all non-empty lines are properly formatted (contain ':')
const lines = newValue.split("\n");
const nonEmptyLines = lines
.map((line) => line.trim())
.filter((line) => line.length > 0);
// If there are no non-empty lines, or all non-empty lines contain ':', it's valid
const isValid =
nonEmptyLines.length === 0 ||
nonEmptyLines.every((line) => line.includes(":"));
if (isValid) {
// Safe to convert and update parent
const headersArray = convertToHeadersArray(newValue);
onChange(headersArray);
}
// If not valid, don't call onChange - let user continue typing
if (result.error) {
// Surface the error and do not touch the last known good value.
// Silently dropping the update here (without telling the user)
// is what previously let stale data get saved without warning.
setError(result.error);
onValidityChange?.(false);
return;
}
setError(null);
onValidityChange?.(true);
onChange(result.headers);
};
const handleFocus = () => {
@@ -114,15 +134,20 @@ X-Another-Header: another-value`,
};
return (
<Textarea
ref={textareaRef}
value={internalValue}
onChange={handleChange}
onFocus={handleFocus}
onBlur={handleBlur}
placeholder={placeholder}
rows={rows}
className={className}
/>
<div>
<Textarea
ref={textareaRef}
value={internalValue}
onChange={handleChange}
onFocus={handleFocus}
onBlur={handleBlur}
placeholder={placeholder}
rows={rows}
className={className}
/>
{error && (
<p className="text-sm text-destructive mt-1.5">{error}</p>
)}
</div>
);
}
+11 -3
View File
@@ -5,12 +5,15 @@ import { cn } from "@app/lib/cn";
export function InfoSections({
children,
cols,
columnSizing = "content"
columnSizing = "content",
layout = "default"
}: {
children: React.ReactNode;
cols?: number;
/** content (default): fixed gap, columns hug content, left-aligned; fill: equal-width columns across the row */
columnSizing?: "fill" | "content";
/** panel: 2 columns until xl for narrow containers such as side panels */
layout?: "default" | "panel";
}) {
const n = cols || 1;
const track =
@@ -19,9 +22,14 @@ export function InfoSections({
return (
<div
className={cn(
"grid w-full min-w-0 grid-cols-2 md:grid-cols-(--columns) md:space-x-16 gap-4 md:items-start",
"grid w-full min-w-0 gap-4",
layout === "panel"
? "grid-cols-2 xl:grid-cols-(--columns) xl:items-start xl:space-x-16"
: "grid-cols-2 md:grid-cols-(--columns) md:items-start md:space-x-16",
columnSizing === "content" &&
"md:justify-items-start md:justify-start"
(layout === "panel"
? "xl:justify-items-start xl:justify-start"
: "md:justify-items-start md:justify-start")
)}
style={{
// @ts-expect-error dynamic props don't work with tailwind, but we can set the
+17 -3
View File
@@ -11,7 +11,7 @@ import {
import { launcherQueries, orgQueries } from "@app/lib/queries";
import { useQuery } from "@tanstack/react-query";
import { useTranslations } from "next-intl";
import { useState } from "react";
import { useMemo, useState } from "react";
import { useDebounce } from "use-debounce";
import { Checkbox } from "./ui/checkbox";
@@ -25,6 +25,7 @@ type LabelsFilterSelectorProps = {
orgId: string;
isSelected: (label: LabelFilterOption) => boolean;
onToggle: (label: LabelFilterOption) => void;
selectedLabels?: LabelFilterOption[];
onClear?: () => void;
showClear?: boolean;
scope?: "org" | "launcher";
@@ -34,6 +35,7 @@ export function LabelsFilterSelector({
orgId,
isSelected,
onToggle,
selectedLabels = [],
onClear,
showClear = false,
scope = "org"
@@ -54,7 +56,7 @@ export function LabelsFilterSelector({
...launcherQueries.labels({
orgId,
query: debouncedQuery,
perPage: 500
perPage: 20
}),
enabled: scope === "launcher"
});
@@ -63,6 +65,18 @@ export function LabelsFilterSelector({
? (launcherLabelsQuery.data ?? [])
: (orgLabelsQuery.data ?? []);
const labelsShown = useMemo(() => {
const base = [...labels];
if (debouncedQuery.trim().length === 0 && selectedLabels.length > 0) {
const selectedNotInBase = selectedLabels.filter(
(selected) =>
!base.some((label) => label.labelId === selected.labelId)
);
return [...selectedNotInBase, ...base];
}
return base;
}, [debouncedQuery, labels, selectedLabels]);
return (
<Command shouldFilter={false}>
<CommandInput
@@ -81,7 +95,7 @@ export function LabelsFilterSelector({
{t("accessFilterClear")}
</CommandItem>
)}
{labels.map((label) => (
{labelsShown.map((label) => (
<CommandItem
key={label.labelId}
value={label.name}
+14 -2
View File
@@ -20,6 +20,8 @@ export type MultiSitesSelectorProps = {
onSelectionChange: (sites: Selectedsite[]) => void;
filterTypes?: string[];
scope?: "org" | "launcher";
onClear?: () => void;
showClear?: boolean;
};
export function formatMultiSitesSelectorLabel(
@@ -42,7 +44,9 @@ export function MultiSitesSelector({
selectedSites,
onSelectionChange,
filterTypes,
scope = "org"
scope = "org",
onClear,
showClear = false
}: MultiSitesSelectorProps) {
const t = useTranslations();
const [siteSearchQuery, setSiteSearchQuery] = useState("");
@@ -60,7 +64,7 @@ export function MultiSitesSelector({
...launcherQueries.sites({
orgId,
query: debouncedQuery,
perPage: 500
perPage: 20
}),
enabled: scope === "launcher"
});
@@ -107,6 +111,14 @@ export function MultiSitesSelector({
<CommandList>
<CommandEmpty>{t("siteNotFound")}</CommandEmpty>
<CommandGroup>
{showClear && onClear && (
<CommandItem
onSelect={onClear}
className="text-muted-foreground"
>
{t("accessFilterClear")}
</CommandItem>
)}
{sitesShown.map((site) => (
<CommandItem
key={site.siteId}
@@ -10,6 +10,7 @@ import {
type SelectedLabel
} from "@app/components/labels-selector";
import { LabelsFilterSelector } from "@app/components/LabelsFilterSelector";
import { Badge } from "@app/components/ui/badge";
import { Button } from "@app/components/ui/button";
import {
Popover,
@@ -46,14 +47,14 @@ export function LauncherFilterPopover({
const { data: labels = [] } = useQuery(
launcherQueries.labels({
orgId,
perPage: 500
perPage: 20
})
);
const { data: sites = [] } = useQuery(
launcherQueries.sites({
orgId,
perPage: 500
perPage: 20
})
);
@@ -96,14 +97,32 @@ export function LauncherFilterPopover({
[labels, selectedLabels]
);
const activeFilterCount = selectedSites.length + selectedLabels.length;
return (
<Popover modal={false}>
<PopoverTrigger asChild>
<Button variant="outline" size="icon" className="shrink-0">
<Button
variant="outline"
size="icon"
className="relative shrink-0"
>
<Funnel className="size-4" />
<span className="sr-only">
{t("resourceLauncherFilter")}
{activeFilterCount > 0
? t("resourceLauncherFilterWithCount", {
count: activeFilterCount
})
: t("resourceLauncherFilter")}
</span>
{activeFilterCount > 0 && (
<Badge
variant="secondary"
className="absolute -top-1 -right-1 flex h-5 min-w-5 items-center justify-center px-1.5 text-xs"
>
{activeFilterCount > 99 ? "99+" : activeFilterCount}
</Badge>
)}
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-72">
@@ -139,6 +158,10 @@ export function LauncherFilterPopover({
selectedSites={resolvedSelectedSites}
onSelectionChange={onSitesChange}
scope="launcher"
showClear={selectedSites.length > 0}
onClear={() => {
onSitesChange([]);
}}
/>
</PopoverContent>
</Popover>
@@ -172,6 +195,7 @@ export function LauncherFilterPopover({
<LabelsFilterSelector
orgId={orgId}
scope="launcher"
selectedLabels={resolvedSelectedLabels}
isSelected={(label) =>
selectedLabelIds.has(label.labelId)
}
@@ -0,0 +1,125 @@
"use client";
import type { LauncherActiveViewId } from "@app/lib/launcherLocalStorage";
import { hasActiveLauncherFilters } from "@app/lib/launcherScale";
import { launcherQueries } from "@app/lib/queries";
import type {
LauncherResource,
LauncherViewConfig
} from "@server/routers/launcher/types";
import { LAUNCHER_FLAT_GROUP_KEY } from "@server/routers/launcher/types";
import { useInfiniteQuery } from "@tanstack/react-query";
import { Loader2 } from "lucide-react";
import { useEffect, useMemo, useRef } from "react";
import { LauncherEmptyState } from "./LauncherEmptyState";
import { LauncherResourceGrid } from "./LauncherResourceGrid";
import { LauncherResourceList } from "./LauncherResourceList";
type LauncherFlatResourceListProps = {
orgId: string;
activeViewId: LauncherActiveViewId;
config: LauncherViewConfig;
onClearFilters?: () => void;
onResourceSelect?: (resource: LauncherResource) => void;
};
export function LauncherFlatResourceList({
orgId,
config,
onClearFilters,
onResourceSelect
}: LauncherFlatResourceListProps) {
const loadMoreRef = useRef<HTMLDivElement | null>(null);
const resourceFilters = useMemo(
() => ({
query: config.query,
groupBy: config.groupBy,
groupKey: LAUNCHER_FLAT_GROUP_KEY,
siteIds: config.siteIds,
labelIds: config.labelIds,
sort_by: config.sortBy,
order: config.order,
pageSize: 20
}),
[
config.groupBy,
config.labelIds,
config.order,
config.query,
config.siteIds,
config.sortBy
]
);
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isFetching } =
useInfiniteQuery({
...launcherQueries.resources(orgId, resourceFilters)
});
const resources = data?.pages.flatMap((page) => page.resources) ?? [];
useEffect(() => {
const node = loadMoreRef.current;
if (!node || !hasNextPage) {
return;
}
const observer = new IntersectionObserver(
(entries) => {
if (entries[0]?.isIntersecting && !isFetchingNextPage) {
void fetchNextPage();
}
},
{ rootMargin: "200px" }
);
observer.observe(node);
return () => observer.disconnect();
}, [fetchNextPage, hasNextPage, isFetchingNextPage]);
if (resources.length === 0) {
if (isFetching) {
return (
<div className="flex items-center justify-center py-16 text-muted-foreground">
<Loader2 className="size-6 animate-spin" />
</div>
);
}
return (
<LauncherEmptyState
variant={
hasActiveLauncherFilters(config) ? "noResults" : "empty"
}
layout={config.layout}
query={config.query}
onClearFilters={onClearFilters}
/>
);
}
return (
<div className="flex flex-col gap-2.5">
{config.layout === "grid" ? (
<LauncherResourceGrid
resources={resources}
showLabels={config.showLabels}
onResourceSelect={onResourceSelect}
/>
) : (
<LauncherResourceList
resources={resources}
showLabels={config.showLabels}
onResourceSelect={onResourceSelect}
/>
)}
<div ref={loadMoreRef} className="h-4" />
{isFetchingNextPage ? (
<div className="flex justify-center py-2">
<Loader2 className="size-4 animate-spin text-muted-foreground" />
</div>
) : null}
</div>
);
}
@@ -69,16 +69,20 @@ export function LauncherGroupList({
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isFetching } =
useInfiniteQuery({
...launcherQueries.groups(orgId, groupFilters),
initialData: {
pages: [
{
groups: initialGroups,
pagination: groupsPagination
}
],
pageParams: [1]
},
refetchOnMount: false
...(initialGroups.length > 0
? {
initialData: {
pages: [
{
groups: initialGroups,
pagination: groupsPagination
}
],
pageParams: [1]
},
refetchOnMount: false as const
}
: {})
});
const groups = data?.pages.flatMap((page) => page.groups) ?? [];
@@ -23,9 +23,8 @@ export function LauncherRefreshButton({
className="shrink-0"
>
<RefreshCw
className={`mr-0 sm:mr-2 h-4 w-4 ${isRefreshing ? "animate-spin" : ""}`}
className={`size-4 ${isRefreshing ? "animate-spin" : ""}`}
/>
<span className="hidden sm:inline">{t("refresh")}</span>
</Button>
);
}
@@ -1,17 +1,55 @@
"use client";
import CopyToClipboard from "@app/components/CopyToClipboard";
import {
InfoSection,
InfoSectionContent,
InfoSections,
InfoSectionTitle
} from "@app/components/InfoSection";
import {
SettingsSection,
SettingsSectionBody,
SettingsSectionDescription,
SettingsSectionHeader,
SettingsSectionTitle
} from "@app/components/Settings";
import {
SidePanel,
SidePanelBody,
SidePanelContent,
SidePanelDescription,
SidePanelFooter,
SidePanelHeader,
SidePanelTitle
} from "@app/components/SidePanel";
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
import { Button } from "@app/components/ui/button";
import {
derivePublicAuthState,
formatPortRestrictionDisplay,
formatPublicResourceType
} from "@app/lib/launcherResourceDetails";
import { getLauncherResourceAdminHref } from "@app/lib/launcherResourceAdminHref";
import {
formatSiteResourceDestinationDisplay,
isSafeUrlForLink
} from "@app/lib/launcherResourceAccess";
import { launcherQueries } from "@app/lib/queries";
import type { LauncherResource } from "@server/routers/launcher/types";
import type { GetResourceAuthInfoResponse } from "@server/routers/resource/getResourceAuthInfo";
import type { GetResourceResponse } from "@server/routers/resource/getResource";
import type { GetSiteResourceResponse } from "@server/routers/siteResource/getSiteResource";
import { useQuery } from "@tanstack/react-query";
import {
AlertCircle,
CheckCircle2,
Clock,
ExternalLink,
Loader2,
ShieldCheck,
ShieldOff,
XCircle
} from "lucide-react";
import { useTranslations } from "next-intl";
import Link from "next/link";
@@ -23,6 +61,460 @@ type LauncherResourcePanelProps = {
isAdmin: boolean;
};
type LauncherResourceDetailResult =
| {
resourceType: "public";
data: GetResourceResponse;
authInfo: GetResourceAuthInfoResponse;
}
| { resourceType: "site"; data: GetSiteResourceResponse };
function AccessMethodContent({
accessDisplay,
accessCopyValue,
accessUrl
}: {
accessDisplay: string;
accessCopyValue: string;
accessUrl?: string | null;
}) {
if (!accessDisplay) {
return <span>-</span>;
}
const href = accessUrl ?? undefined;
const canLink = Boolean(href && isSafeUrlForLink(href));
if (canLink && href) {
return (
<CopyToClipboard
text={href}
displayText={accessDisplay}
isLink={true}
className="text-base"
/>
);
}
return (
<CopyToClipboard
text={accessCopyValue || accessDisplay}
displayText={accessDisplay}
isLink={false}
className="text-base"
/>
);
}
function HealthStatusDisplay({
health
}: {
health: string | null | undefined;
}) {
const t = useTranslations();
const status = health ?? "unknown";
if (status === "healthy") {
return (
<div className="flex items-center space-x-2">
<CheckCircle2 className="size-4 shrink-0 text-green-500" />
<span>{t("resourcesTableHealthy")}</span>
</div>
);
}
if (status === "degraded") {
return (
<div className="flex items-center space-x-2">
<CheckCircle2 className="size-4 shrink-0 text-yellow-500" />
<span>{t("resourcesTableDegraded")}</span>
</div>
);
}
if (status === "unhealthy") {
return (
<div className="flex items-center space-x-2">
<XCircle className="size-4 shrink-0 text-destructive" />
<span>{t("resourcesTableUnhealthy")}</span>
</div>
);
}
return (
<div className="flex items-center space-x-2">
<Clock className="size-4 shrink-0 text-muted-foreground" />
<span>{t("resourcesTableUnknown")}</span>
</div>
);
}
const PUBLIC_AUTH_BROWSER_MODES = ["http", "ssh", "rdp", "vnc"];
function AuthMethodStatusDisplay({ enabled }: { enabled: boolean }) {
const t = useTranslations();
return (
<div className="flex items-center gap-2">
{enabled ? (
<CheckCircle2 className="size-4 text-green-600" />
) : (
<XCircle className="size-4 text-red-600" />
)}
<span>{enabled ? t("enabled") : t("disabled")}</span>
</div>
);
}
function PublicResourceAuthMethods({
authInfo
}: {
authInfo: GetResourceAuthInfoResponse;
}) {
const t = useTranslations();
const authMethods = [
{
key: "sso",
title: t("policyAuthSsoTitle"),
enabled: authInfo.sso
},
{
key: "password",
title: t("policyAuthPasscodeTitle"),
enabled: authInfo.password
},
{
key: "pincode",
title: t("policyAuthPincodeTitle"),
enabled: authInfo.pincode
},
{
key: "whitelist",
title: t("policyAuthEmailTitle"),
enabled: authInfo.whitelist
},
{
key: "headerAuth",
title: t("policyAuthHeaderAuthTitle"),
enabled: authInfo.headerAuth
}
];
return (
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("authentication")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("resourceLauncherAuthMethodsDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<InfoSections cols={authMethods.length} layout="panel">
{authMethods.map((method) => (
<InfoSection key={method.key}>
<InfoSectionTitle>{method.title}</InfoSectionTitle>
<InfoSectionContent>
<AuthMethodStatusDisplay
enabled={method.enabled}
/>
</InfoSectionContent>
</InfoSection>
))}
</InfoSections>
</SettingsSectionBody>
</SettingsSection>
);
}
function PublicResourceDetails({
launcherResource,
resource,
authInfo
}: {
launcherResource: LauncherResource;
resource: GetResourceResponse;
authInfo: GetResourceAuthInfoResponse;
}) {
const t = useTranslations();
const supportsAuth = PUBLIC_AUTH_BROWSER_MODES.includes(
resource.mode || ""
);
const authState = derivePublicAuthState(resource.mode, authInfo);
const infoSectionCount = supportsAuth ? 4 : 3;
return (
<div className="space-y-4">
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("resourceLauncherResourceDetails")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("resourceLauncherResourceDetailsDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<InfoSections cols={infoSectionCount} layout="panel">
<InfoSection>
<InfoSectionTitle>{t("type")}</InfoSectionTitle>
<InfoSectionContent>
{formatPublicResourceType(resource)}
</InfoSectionContent>
</InfoSection>
<InfoSection>
<InfoSectionTitle>{t("access")}</InfoSectionTitle>
<InfoSectionContent>
<AccessMethodContent
accessDisplay={
launcherResource.accessDisplay
}
accessCopyValue={
launcherResource.accessCopyValue
}
accessUrl={launcherResource.accessUrl}
/>
</InfoSectionContent>
</InfoSection>
{supportsAuth ? (
<InfoSection>
<InfoSectionTitle>
{t("authentication")}
</InfoSectionTitle>
<InfoSectionContent>
{authState === "protected" ? (
<div className="flex items-center space-x-2">
<ShieldCheck className="size-4 shrink-0 text-green-500" />
<span>{t("protected")}</span>
</div>
) : (
<div className="flex items-center space-x-2">
<ShieldOff className="size-4 shrink-0 text-yellow-500" />
<span>{t("notProtected")}</span>
</div>
)}
</InfoSectionContent>
</InfoSection>
) : null}
<InfoSection>
<InfoSectionTitle>{t("health")}</InfoSectionTitle>
<InfoSectionContent>
<HealthStatusDisplay health={resource.health} />
</InfoSectionContent>
</InfoSection>
</InfoSections>
</SettingsSectionBody>
</SettingsSection>
{supportsAuth ? (
<PublicResourceAuthMethods authInfo={authInfo} />
) : null}
</div>
);
}
function PrivateResourceDetails({
launcherResource,
resource
}: {
launcherResource: LauncherResource;
resource: GetSiteResourceResponse;
}) {
const t = useTranslations();
const modeLabel: Record<GetSiteResourceResponse["mode"], string> = {
host: t("editInternalResourceDialogModeHost"),
cidr: t("editInternalResourceDialogModeCidr"),
http: t("editInternalResourceDialogModeHttp"),
ssh: t("editInternalResourceDialogModeSsh")
};
const destination = formatSiteResourceDestinationDisplay({
mode: resource.mode,
destination: resource.destination,
destinationPort: resource.destinationPort,
scheme: resource.scheme
});
const portRestrictions = formatPortRestrictionDisplay(resource);
const showAlias = resource.mode !== "cidr" && resource.mode !== "http";
const showDestination = !(
resource.mode === "ssh" && resource.authDaemonMode === "native"
);
const infoSectionCount =
2 + (showDestination ? 1 : 0) + (showAlias ? 1 : 0) + 1;
return (
<div className="space-y-4">
<Alert variant="default">
<AlertCircle className="size-4" />
<AlertTitle>
{t("resourceLauncherPrivateClientRequiredTitle")}
</AlertTitle>
<AlertDescription>
<span>
{t("resourceLauncherPrivateClientRequired")}{" "}
<a
href="https://pangolin.net/downloads"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-primary hover:underline"
>
{t("resourceLauncherDownloadClient")}
<ExternalLink className="size-3.5 shrink-0" />
</a>
</span>
</AlertDescription>
</Alert>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("resourceLauncherResourceDetails")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("resourceLauncherResourceDetailsDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<InfoSections cols={infoSectionCount} layout="panel">
<InfoSection>
<InfoSectionTitle>{t("type")}</InfoSectionTitle>
<InfoSectionContent>
{modeLabel[resource.mode]}
</InfoSectionContent>
</InfoSection>
<InfoSection>
<InfoSectionTitle>{t("access")}</InfoSectionTitle>
<InfoSectionContent>
<AccessMethodContent
accessDisplay={
launcherResource.accessDisplay
}
accessCopyValue={
launcherResource.accessCopyValue
}
accessUrl={launcherResource.accessUrl}
/>
</InfoSectionContent>
</InfoSection>
{showDestination ? (
<InfoSection>
<InfoSectionTitle>
{t("editInternalResourceDialogDestination")}
</InfoSectionTitle>
<InfoSectionContent>
{destination || "-"}
</InfoSectionContent>
</InfoSection>
) : null}
{showAlias ? (
<InfoSection>
<InfoSectionTitle>
{t("editInternalResourceDialogAlias")}
</InfoSectionTitle>
<InfoSectionContent>
{resource.alias?.trim()
? resource.alias
: "-"}
</InfoSectionContent>
</InfoSection>
) : null}
<InfoSection>
<InfoSectionTitle>
{t("portRestrictions")}
</InfoSectionTitle>
<InfoSectionContent>
{!portRestrictions.hasNonDefaultPorts ? (
<span>
{t(
"resourceLauncherNoPortRestrictions"
)}
</span>
) : (
<div className="space-y-1">
{portRestrictions.tcp.state !==
"all" ? (
<div>
{t("resourceLauncherTcp")}:{" "}
{portRestrictions.tcp.state ===
"blocked"
? t("blocked")
: portRestrictions.tcp
.ports}
</div>
) : null}
{portRestrictions.udp.state !==
"all" ? (
<div>
{t("resourceLauncherUdp")}:{" "}
{portRestrictions.udp.state ===
"blocked"
? t("blocked")
: portRestrictions.udp
.ports}
</div>
) : null}
</div>
)}
</InfoSectionContent>
</InfoSection>
</InfoSections>
</SettingsSectionBody>
</SettingsSection>
</div>
);
}
function LauncherResourcePanelBody({
orgId,
resource,
open
}: {
orgId: string;
resource: LauncherResource;
open: boolean;
}) {
const t = useTranslations();
const { data, isPending, isError } = useQuery({
...launcherQueries.resourceDetail(orgId, resource),
enabled: open
});
if (isPending) {
return (
<div className="flex items-center justify-center py-12 text-muted-foreground">
<Loader2 className="size-6 animate-spin" />
</div>
);
}
if (isError || !data) {
return (
<p className="text-sm text-muted-foreground">
{t("resourceLauncherFailedToLoadDetails")}
</p>
);
}
const detail = data as LauncherResourceDetailResult;
if (detail.resourceType === "public") {
return (
<PublicResourceDetails
launcherResource={resource}
resource={detail.data}
authInfo={detail.authInfo}
/>
);
}
return (
<PrivateResourceDetails
launcherResource={resource}
resource={detail.data}
/>
);
}
export function LauncherResourcePanel({
open,
onOpenChange,
@@ -37,11 +529,16 @@ export function LauncherResourcePanel({
<SidePanelContent>
<SidePanelHeader>
<SidePanelTitle>{resource?.name ?? ""}</SidePanelTitle>
<SidePanelDescription>
{t("resourceLauncherResourceDetailsDescription")}
</SidePanelDescription>
</SidePanelHeader>
<SidePanelBody />
<SidePanelBody>
{resource ? (
<LauncherResourcePanelBody
orgId={orgId}
resource={resource}
open={open}
/>
) : null}
</SidePanelBody>
<SidePanelFooter>
<Button
variant="outline"
@@ -0,0 +1,86 @@
"use client";
import { cn } from "@app/lib/cn";
import { Search } from "lucide-react";
import { useTranslations } from "next-intl";
type LauncherSearchFirstGateProps = {
layout: "grid" | "list";
};
function GhostResourceGrid() {
return (
<div
className="grid w-full grid-cols-1 gap-2.5 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 [&>*]:min-w-0"
aria-hidden
>
{Array.from({ length: 4 }).map((_, index) => (
<div
key={index}
className="flex min-w-0 flex-col gap-2.5 rounded-xl border border-border/60 bg-muted/20 p-4"
>
<div className="flex items-center gap-5">
<div className="size-10 shrink-0 rounded-lg bg-muted/60" />
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
<div className="h-3.5 w-3/5 rounded bg-muted/60" />
<div className="h-3 w-2/5 rounded bg-muted/40" />
</div>
</div>
</div>
))}
</div>
);
}
function GhostResourceList() {
return (
<div className="flex w-full flex-col" aria-hidden>
{Array.from({ length: 3 }).map((_, index) => (
<div
key={index}
className={cn(
"flex items-center gap-4 px-4 py-3",
index < 2 && "border-b border-border/60"
)}
>
<div className="size-8 shrink-0 rounded-lg bg-muted/60" />
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
<div className="h-3.5 w-2/5 rounded bg-muted/60" />
<div className="h-3 w-1/4 rounded bg-muted/40" />
</div>
</div>
))}
</div>
);
}
export function LauncherSearchFirstGate({
layout
}: LauncherSearchFirstGateProps) {
const t = useTranslations();
return (
<div className="relative w-full overflow-hidden rounded-xl border border-dashed border-border">
<div className="pointer-events-none absolute inset-0 opacity-50">
{layout === "grid" ? (
<GhostResourceGrid />
) : (
<GhostResourceList />
)}
</div>
<div className="relative flex min-h-56 flex-col items-center justify-center gap-4 px-6 py-12 text-center">
<div className="flex size-12 items-center justify-center rounded-full bg-muted">
<Search className="size-5 text-muted-foreground" />
</div>
<div className="max-w-md space-y-1.5">
<h3 className="text-base font-semibold text-foreground">
{t("resourceLauncherSearchFirstTitle")}
</h3>
<p className="text-sm text-muted-foreground">
{t("resourceLauncherSearchFirstDescription")}
</p>
</div>
</div>
</div>
);
}
@@ -15,22 +15,27 @@ import {
SelectValue
} from "@app/components/ui/select";
import { Switch } from "@app/components/ui/switch";
import type { LauncherViewConfig } from "@server/routers/launcher/types";
import type {
LauncherScaleCapabilities,
LauncherViewConfig
} from "@server/routers/launcher/types";
import { useTranslations } from "next-intl";
import { Settings } from "lucide-react";
type LauncherSettingsMenuProps = {
config: LauncherViewConfig;
isDefaultView: boolean;
capabilities: LauncherScaleCapabilities;
isCompactMode: boolean;
selectedGroupBy: LauncherViewConfig["groupBy"];
onConfigChange: (patch: Partial<LauncherViewConfig>) => void;
onDeleteView: () => void;
};
export function LauncherSettingsMenu({
config,
isDefaultView,
onConfigChange,
onDeleteView
capabilities,
isCompactMode,
selectedGroupBy,
onConfigChange
}: LauncherSettingsMenuProps) {
const t = useTranslations();
@@ -51,7 +56,7 @@ export function LauncherSettingsMenu({
{t("resourceLauncherGroupBy")}
</p>
<Select
value={config.groupBy}
value={selectedGroupBy}
onValueChange={(value) =>
onConfigChange({
groupBy:
@@ -63,14 +68,46 @@ export function LauncherSettingsMenu({
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="site">
<SelectItem value="none">
{t("resourceLauncherGroupByNone")}
</SelectItem>
<SelectItem
value="site"
disabled={
!capabilities.allowSiteGrouping ||
(isCompactMode &&
config.siteIds.length === 0)
}
>
{t("resourceLauncherGroupBySite")}
</SelectItem>
<SelectItem value="label">
<SelectItem
value="label"
disabled={
!capabilities.allowLabelGrouping ||
(isCompactMode &&
config.labelIds.length === 0)
}
>
{t("resourceLauncherGroupByLabel")}
</SelectItem>
</SelectContent>
</Select>
{isCompactMode ? (
<p className="text-xs text-muted-foreground">
{t("resourceLauncherCompactGroupingHint")}
</p>
) : null}
{!isCompactMode && !capabilities.allowSiteGrouping ? (
<p className="text-xs text-muted-foreground">
{t("resourceLauncherSiteGroupingDisabled")}
</p>
) : null}
{!isCompactMode && !capabilities.allowLabelGrouping ? (
<p className="text-xs text-muted-foreground">
{t("resourceLauncherLabelGroupingDisabled")}
</p>
) : null}
</div>
<div className="space-y-2">
@@ -116,16 +153,6 @@ export function LauncherSettingsMenu({
/>
</div>
</div>
{!isDefaultView ? (
<Button
variant="destructive"
className="w-full rounded-xl"
onClick={onDeleteView}
>
{t("resourceLauncherDeleteView")}
</Button>
) : null}
</div>
</PopoverContent>
</Popover>
@@ -68,11 +68,14 @@ type LauncherSaveViewMenuProps = {
isAdmin: boolean;
isOrgWideView: boolean;
hasUnsavedChanges: boolean;
canResetSystemDefault: boolean;
onSaveToCurrent: () => void;
onSaveAsNew: () => void;
onSaveForEveryone: () => void;
onMakePersonal: () => void;
onResetView: () => void;
onResetSystemDefault: () => void;
onDeleteView: () => void;
};
export function LauncherSaveViewMenu({
@@ -80,13 +83,17 @@ export function LauncherSaveViewMenu({
isAdmin,
isOrgWideView,
hasUnsavedChanges,
canResetSystemDefault,
onSaveToCurrent,
onSaveAsNew,
onSaveForEveryone,
onMakePersonal,
onResetView
onResetView,
onResetSystemDefault,
onDeleteView
}: LauncherSaveViewMenuProps) {
const t = useTranslations();
const canDeleteView = !isDefaultView && (isAdmin || !isOrgWideView);
return (
<DropdownMenu>
@@ -108,7 +115,26 @@ export function LauncherSaveViewMenu({
<DropdownMenuSeparator />
</>
) : null}
{!isDefaultView && (isAdmin || !isOrgWideView) ? (
{isDefaultView && canResetSystemDefault ? (
<>
<DropdownMenuItem onSelect={onResetSystemDefault}>
{t("resourceLauncherResetSystemDefault")}
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
) : null}
{isDefaultView && hasUnsavedChanges ? (
<>
<DropdownMenuItem onSelect={onSaveToCurrent}>
{t("resourceLauncherSaveDefaultPersonal")}
</DropdownMenuItem>
{isAdmin ? (
<DropdownMenuItem onSelect={onSaveForEveryone}>
{t("resourceLauncherSaveForEveryone")}
</DropdownMenuItem>
) : null}
</>
) : !isDefaultView && (isAdmin || !isOrgWideView) ? (
<DropdownMenuItem onSelect={onSaveToCurrent}>
{t("resourceLauncherSaveToCurrentView")}
</DropdownMenuItem>
@@ -126,6 +152,17 @@ export function LauncherSaveViewMenu({
{t("resourceLauncherMakePersonal")}
</DropdownMenuItem>
) : null}
{canDeleteView ? (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={onDeleteView}
className="text-destructive focus:text-destructive"
>
{t("resourceLauncherDeleteView")}
</DropdownMenuItem>
</>
) : null}
</DropdownMenuContent>
</DropdownMenu>
);
@@ -29,12 +29,23 @@ import {
} from "@app/lib/launcherUrlState";
import { useToast } from "@app/hooks/useToast";
import { useEnvContext } from "@app/hooks/useEnvContext";
import type {
LauncherGroup,
LauncherViewConfig,
LauncherViewRecord
import {
getEffectiveLauncherConfig,
shouldShowFlatResourceList,
shouldShowLauncherGroupList,
shouldShowSearchFirstGate
} from "@app/lib/launcherScale";
import { launcherQueries } from "@app/lib/queries";
import {
getEffectiveDefaultLauncherConfig,
type LauncherDefaultViewOverrides,
type LauncherGroup,
type LauncherResource,
type LauncherScaleInfo,
type LauncherViewConfig,
type LauncherViewRecord
} from "@server/routers/launcher/types";
import { useMutation } from "@tanstack/react-query";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Search } from "lucide-react";
import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
@@ -52,20 +63,26 @@ import type { SelectedLabel } from "@app/components/labels-selector";
import { useMediaQuery } from "@app/hooks/useMediaQuery";
import { cn } from "@app/lib/cn";
import { LauncherFilterPopover } from "./LauncherFilterPopover";
import { LauncherFlatResourceList } from "./LauncherFlatResourceList";
import { LauncherGroupList } from "./LauncherGroupList";
import { LauncherSearchFirstGate } from "./LauncherSearchFirstGate";
import { LauncherRefreshButton } from "./LauncherRefreshButton";
import { LauncherResourcePanel } from "./LauncherResourcePanel";
import { LauncherSettingsMenu } from "./LauncherSettingsMenu";
import { LauncherSortButton } from "./LauncherSortButton";
import { LauncherSaveViewMenu, LauncherViewTabs } from "./LauncherViewTabs";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
type ResourceLauncherProps = {
orgId: string;
isAdmin: boolean;
views: LauncherViewRecord[];
defaultViewOverrides: LauncherDefaultViewOverrides;
activeViewId: LauncherActiveViewId;
config: LauncherViewConfig;
savedConfig: LauncherViewConfig;
scale: LauncherScaleInfo;
groups: LauncherGroup[];
groupsPagination: {
total: number;
@@ -78,9 +95,11 @@ export default function ResourceLauncher({
orgId,
isAdmin,
views,
defaultViewOverrides,
activeViewId,
config,
savedConfig,
scale: initialScale,
groups,
groupsPagination
}: ResourceLauncherProps) {
@@ -88,6 +107,7 @@ export default function ResourceLauncher({
const { toast } = useToast();
const { env } = useEnvContext();
const api = createApiClient({ env });
const queryClient = useQueryClient();
const router = useRouter();
const { navigate, isNavigating, searchParams } = useNavigationContext();
const [isRefreshing, startRefreshTransition] = useTransition();
@@ -95,11 +115,47 @@ export default function ResourceLauncher({
const [searchInputResetKey, setSearchInputResetKey] = useState(0);
const [saveDialogOpen, setSaveDialogOpen] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [newViewName, setNewViewName] = useState("");
const [saveOrgWide, setSaveOrgWide] = useState(false);
const [selectedResource, setSelectedResource] =
useState<LauncherResource | null>(null);
const [panelOpen, setPanelOpen] = useState(false);
const isDesktop = useMediaQuery("(min-width: 768px)");
const scaleFilters = useMemo(
() => ({
query: config.query,
groupBy: config.groupBy,
siteIds: config.siteIds,
labelIds: config.labelIds,
sort_by: config.sortBy,
order: config.order
}),
[
config.groupBy,
config.labelIds,
config.order,
config.query,
config.siteIds,
config.sortBy
]
);
const { data: scale = initialScale } = useQuery({
...launcherQueries.scale(orgId, scaleFilters),
initialData: initialScale
});
const showGroupList = shouldShowLauncherGroupList(scale, config);
const showSearchFirstGate = shouldShowSearchFirstGate(scale, config);
const showFlatResourceList = shouldShowFlatResourceList(scale, config);
const effectiveConfig = useMemo(
() => getEffectiveLauncherConfig(scale, config),
[config, scale]
);
const configRef = useRef(config);
configRef.current = config;
const searchInputRef = useRef(config.query);
@@ -129,13 +185,24 @@ export default function ResourceLauncher({
return;
}
const baseConfig = getLauncherUrlBaseConfig(lastView, views);
const baseConfig = getLauncherUrlBaseConfig(
lastView,
views,
defaultViewOverrides
);
const params = serializeLauncherUrlState({
viewId: lastView,
config: baseConfig
});
navigate({ searchParams: params, replace: true });
}, [activeViewId, navigate, orgId, searchParams, views]);
}, [
activeViewId,
defaultViewOverrides,
navigate,
orgId,
searchParams,
views
]);
const navigateToConfig = useCallback(
(viewId: LauncherActiveViewId, nextConfig: LauncherViewConfig) => {
@@ -158,10 +225,14 @@ export default function ResourceLauncher({
const selectView = useCallback(
(viewId: LauncherActiveViewId) => {
writeLauncherLastView(orgId, viewId);
const baseConfig = getLauncherUrlBaseConfig(viewId, views);
const baseConfig = getLauncherUrlBaseConfig(
viewId,
views,
defaultViewOverrides
);
navigateToConfig(viewId, baseConfig);
},
[navigateToConfig, orgId, views]
[defaultViewOverrides, navigateToConfig, orgId, views]
);
const activeSavedView = useMemo(
@@ -175,6 +246,10 @@ export default function ResourceLauncher({
const isDefaultView = activeViewId === "default";
const isOrgWideView = Boolean(activeSavedView?.isOrgWide);
const hasUnsavedChanges = !isLauncherConfigEqual(config, savedConfig);
const canResetSystemDefault =
isDefaultView &&
(Boolean(defaultViewOverrides.personal) ||
(isAdmin && Boolean(defaultViewOverrides.orgWide)));
const selectedSites: Selectedsite[] = useMemo(
() =>
@@ -270,6 +345,87 @@ export default function ResourceLauncher({
}
});
const saveDefaultViewMutation = useMutation({
mutationFn: async (payload: {
config: LauncherViewConfig;
orgWide: boolean;
}) => {
const res = await api.put(
`/org/${orgId}/launcher/default-view`,
payload
);
return res.data.data as LauncherViewRecord;
},
onSuccess: () => {
writeLauncherLastView(orgId, "default");
const params = serializeLauncherUrlState({
viewId: "default",
config
});
navigate({ searchParams: params, replace: true });
router.refresh();
toast({
title: t("resourceLauncherViewSaved"),
description: t("resourceLauncherViewSavedDescription")
});
},
onError: (error) => {
toast({
variant: "destructive",
title: t("resourceLauncherViewSaveFailed"),
description: formatAxiosError(
error,
t("resourceLauncherViewSaveFailedDescription")
)
});
}
});
const resetSystemDefaultMutation = useMutation({
mutationFn: async () => {
const resetAll = isAdmin && Boolean(defaultViewOverrides.orgWide);
await api.delete(`/org/${orgId}/launcher/default-view`, {
data: resetAll ? { all: true } : { orgWide: false }
});
},
onSuccess: () => {
writeLauncherLastView(orgId, "default");
const nextOverrides: LauncherDefaultViewOverrides = {
personal: null,
orgWide:
isAdmin && defaultViewOverrides.orgWide
? null
: defaultViewOverrides.orgWide
};
const targetConfig =
getEffectiveDefaultLauncherConfig(nextOverrides);
searchInputRef.current = targetConfig.query;
setSearchInputResetKey((key) => key + 1);
const params = serializeLauncherUrlState({
viewId: "default",
config: targetConfig
});
navigate({ searchParams: params, replace: true });
router.refresh();
toast({
title: t("resourceLauncherSystemDefaultRestored"),
description: t(
"resourceLauncherSystemDefaultRestoredDescription"
)
});
},
onError: (error) => {
toast({
variant: "destructive",
title: t("resourceLauncherViewSaveFailed"),
description: formatAxiosError(
error,
t("resourceLauncherViewSaveFailedDescription")
)
});
}
});
const deleteViewMutation = useMutation({
mutationFn: async (viewId: number) => {
await api.delete(`/org/${orgId}/launcher/views/${viewId}`);
@@ -278,7 +434,11 @@ export default function ResourceLauncher({
writeLauncherLastView(orgId, "default");
const params = serializeLauncherUrlState({
viewId: "default",
config: getLauncherUrlBaseConfig("default", views)
config: getLauncherUrlBaseConfig(
"default",
views,
defaultViewOverrides
)
});
navigate({ searchParams: params, replace: true });
router.refresh();
@@ -328,9 +488,17 @@ export default function ResourceLauncher({
navigateToConfig(activeViewIdRef.current, savedConfig);
}, [navigateToConfig, savedConfig]);
const handleResetSystemDefault = useCallback(() => {
resetSystemDefaultMutation.mutate();
}, [resetSystemDefaultMutation]);
const refreshData = () => {
startRefreshTransition(async () => {
try {
await api.post(`/org/${orgId}/launcher/invalidate-cache`);
await queryClient.invalidateQueries({
queryKey: ["ORG", orgId, "LAUNCHER"]
});
router.refresh();
} catch {
toast({
@@ -343,7 +511,14 @@ export default function ResourceLauncher({
};
const handleSaveToCurrent = () => {
if (isDefaultView || (isOrgWideView && !isAdmin)) {
if (isDefaultView) {
saveDefaultViewMutation.mutate({
config,
orgWide: false
});
return;
}
if (isOrgWideView && !isAdmin) {
return;
}
updateViewMutation.mutate({
@@ -360,6 +535,10 @@ export default function ResourceLauncher({
const handleSaveForEveryone = () => {
if (isDefaultView) {
saveDefaultViewMutation.mutate({
config,
orgWide: true
});
return;
}
updateViewMutation.mutate({
@@ -389,6 +568,18 @@ export default function ResourceLauncher({
});
};
const handleResourceSelect = useCallback((resource: LauncherResource) => {
setSelectedResource(resource);
setPanelOpen(true);
}, []);
const handlePanelOpenChange = useCallback((open: boolean) => {
setPanelOpen(open);
if (!open) {
setSelectedResource(null);
}
}, []);
const savedViewTabs = views.map((view) => ({
viewId: view.viewId,
name: view.name
@@ -412,19 +603,8 @@ export default function ResourceLauncher({
</div>
);
const renderToolbarActions = () => (
const renderToolbarFilterSort = () => (
<>
<LauncherSaveViewMenu
isDefaultView={isDefaultView}
isAdmin={isAdmin}
isOrgWideView={isOrgWideView}
hasUnsavedChanges={hasUnsavedChanges}
onSaveToCurrent={handleSaveToCurrent}
onSaveAsNew={handleSaveAsNew}
onSaveForEveryone={handleSaveForEveryone}
onMakePersonal={handleMakePersonal}
onResetView={handleResetView}
/>
<LauncherFilterPopover
orgId={orgId}
selectedSites={selectedSites}
@@ -448,15 +628,31 @@ export default function ResourceLauncher({
})
}
/>
</>
);
const renderToolbarActions = () => (
<>
<LauncherSaveViewMenu
isDefaultView={isDefaultView}
isAdmin={isAdmin}
isOrgWideView={isOrgWideView}
hasUnsavedChanges={hasUnsavedChanges}
canResetSystemDefault={canResetSystemDefault}
onSaveToCurrent={handleSaveToCurrent}
onSaveAsNew={handleSaveAsNew}
onSaveForEveryone={handleSaveForEveryone}
onMakePersonal={handleMakePersonal}
onResetView={handleResetView}
onResetSystemDefault={handleResetSystemDefault}
onDeleteView={() => setDeleteDialogOpen(true)}
/>
<LauncherSettingsMenu
config={config}
isDefaultView={isDefaultView}
capabilities={scale.capabilities}
isCompactMode={scale.mode === "compact"}
selectedGroupBy={effectiveConfig.groupBy}
onConfigChange={applyConfigPatch}
onDeleteView={() => {
if (!isDefaultView) {
deleteViewMutation.mutate(activeViewId);
}
}}
/>
<LauncherRefreshButton
onRefresh={refreshData}
@@ -483,6 +679,9 @@ export default function ResourceLauncher({
{isDesktop ? (
<div className="mb-6 flex w-full min-w-0 items-center gap-3">
{renderToolbarSearch("w-64")}
<div className="flex shrink-0 items-center gap-2">
{renderToolbarFilterSort()}
</div>
<div className="min-w-0 flex-1 overflow-x-auto">
{renderToolbarViews()}
</div>
@@ -495,22 +694,64 @@ export default function ResourceLauncher({
<div className="flex items-center gap-2 overflow-x-auto">
{renderToolbarActions()}
</div>
{renderToolbarSearch("w-full")}
<div className="flex items-center gap-2">
<div className="min-w-0 flex-1">
{renderToolbarSearch("w-full")}
</div>
{renderToolbarFilterSort()}
</div>
<div className="overflow-x-auto">
{renderToolbarViews()}
</div>
</div>
)}
<LauncherGroupList
{showSearchFirstGate ? (
<LauncherSearchFirstGate layout={config.layout} />
) : showGroupList ? (
<LauncherGroupList
orgId={orgId}
activeViewId={activeViewId}
config={effectiveConfig}
initialGroups={groups}
groupsPagination={groupsPagination}
onClearFilters={handleClearFilters}
onResourceSelect={handleResourceSelect}
/>
) : showFlatResourceList ? (
<LauncherFlatResourceList
orgId={orgId}
activeViewId={activeViewId}
config={effectiveConfig}
onClearFilters={handleClearFilters}
onResourceSelect={handleResourceSelect}
/>
) : null}
<LauncherResourcePanel
open={panelOpen}
onOpenChange={handlePanelOpenChange}
resource={selectedResource}
orgId={orgId}
activeViewId={activeViewId}
config={config}
initialGroups={groups}
groupsPagination={groupsPagination}
onClearFilters={handleClearFilters}
isAdmin={isAdmin}
/>
{activeSavedView ? (
<ConfirmDeleteDialog
open={deleteDialogOpen}
setOpen={setDeleteDialogOpen}
string={activeSavedView.name}
title={t("resourceLauncherDeleteViewTitle")}
buttonText={t("resourceLauncherDeleteViewConfirm")}
dialog={<p>{t("resourceLauncherDeleteViewQuestion")}</p>}
onConfirm={async () => {
await deleteViewMutation.mutateAsync(
activeSavedView.viewId
);
}}
/>
) : null}
<Credenza open={saveDialogOpen} onOpenChange={setSaveDialogOpen}>
<CredenzaContent>
<CredenzaHeader>
+4 -4
View File
@@ -10,7 +10,7 @@ function lastViewKey(orgId: string) {
function groupOpenKey(
orgId: string,
viewId: LauncherActiveViewId,
groupBy: "site" | "label"
groupBy: "site" | "label" | "none"
) {
return `${GROUP_OPEN_PREFIX}${orgId}:${viewId}:${groupBy}`;
}
@@ -64,7 +64,7 @@ export function writeLauncherLastView(
export function readLauncherGroupOpenState(
orgId: string,
viewId: LauncherActiveViewId,
groupBy: "site" | "label"
groupBy: "site" | "label" | "none"
): Record<string, boolean> {
return readJson<Record<string, boolean>>(
groupOpenKey(orgId, viewId, groupBy),
@@ -75,7 +75,7 @@ export function readLauncherGroupOpenState(
export function readLauncherGroupOpen(
orgId: string,
viewId: LauncherActiveViewId,
groupBy: "site" | "label",
groupBy: "site" | "label" | "none",
groupKey: string,
defaultOpen: boolean
): boolean {
@@ -86,7 +86,7 @@ export function readLauncherGroupOpen(
export function writeLauncherGroupOpen(
orgId: string,
viewId: LauncherActiveViewId,
groupBy: "site" | "label",
groupBy: "site" | "label" | "none",
groupKey: string,
isOpen: boolean
) {
+93
View File
@@ -0,0 +1,93 @@
import type { GetResourceResponse } from "@server/routers/resource/getResource";
import type { GetResourceAuthInfoResponse } from "@server/routers/resource/getResourceAuthInfo";
import type { GetSiteResourceResponse } from "@server/routers/siteResource/getSiteResource";
export type PublicAuthState = "protected" | "not_protected" | "none";
const BROWSER_MODES = ["http", "ssh", "rdp", "vnc"];
export function derivePublicAuthState(
mode: string | null,
authInfo: Pick<
GetResourceAuthInfoResponse,
"password" | "pincode" | "sso" | "whitelist" | "headerAuth"
>
): PublicAuthState {
if (!BROWSER_MODES.includes(mode || "")) {
return "none";
}
if (
authInfo.password ||
authInfo.pincode ||
authInfo.sso ||
authInfo.whitelist ||
authInfo.headerAuth
) {
return "protected";
}
return "not_protected";
}
export function formatPublicResourceType(
resource: Pick<GetResourceResponse, "mode" | "ssl">
): string {
if (resource.mode === "http") {
return resource.ssl ? "HTTPS" : "HTTP";
}
const mode = (resource.mode || "").toLowerCase();
if (mode === "tcp") {
return "TCP";
}
if (mode === "udp") {
return "UDP";
}
return (resource.mode || "—").toUpperCase();
}
export type PortProtocolState = "all" | "blocked" | "custom";
function getPortProtocolState(
value: string | null | undefined
): PortProtocolState {
if (value === "*") {
return "all";
}
if (!value || value.trim() === "") {
return "blocked";
}
return "custom";
}
export type PortRestrictionDisplay = {
hasNonDefaultPorts: boolean;
tcp: { state: PortProtocolState; ports: string | null };
udp: { state: PortProtocolState; ports: string | null };
};
export function formatPortRestrictionDisplay(
resource: Pick<
GetSiteResourceResponse,
"tcpPortRangeString" | "udpPortRangeString"
>
): PortRestrictionDisplay {
const tcpState = getPortProtocolState(resource.tcpPortRangeString);
const udpState = getPortProtocolState(resource.udpPortRangeString);
return {
hasNonDefaultPorts: tcpState !== "all" || udpState !== "all",
tcp: {
state: tcpState,
ports: tcpState === "custom" ? resource.tcpPortRangeString : null
},
udp: {
state: udpState,
ports: udpState === "custom" ? resource.udpPortRangeString : null
}
};
}
+114
View File
@@ -0,0 +1,114 @@
import type {
LauncherScaleInfo,
LauncherViewConfig
} from "@server/routers/launcher/types";
export function hasActiveLauncherFilters(config: LauncherViewConfig): boolean {
return (
config.query.trim().length > 0 ||
config.siteIds.length > 0 ||
config.labelIds.length > 0
);
}
export function getEffectiveGroupBy(
scale: LauncherScaleInfo,
config: LauncherViewConfig
): LauncherViewConfig["groupBy"] {
if (scale.mode !== "compact") {
return config.groupBy;
}
if (
config.groupBy === "site" &&
config.siteIds.length > 0 &&
scale.capabilities.allowSiteGrouping
) {
return "site";
}
if (
config.groupBy === "label" &&
config.labelIds.length > 0 &&
scale.capabilities.allowLabelGrouping
) {
return "label";
}
return "none";
}
export function getEffectiveLauncherConfig(
scale: LauncherScaleInfo,
config: LauncherViewConfig
): LauncherViewConfig {
const groupBy = getEffectiveGroupBy(scale, config);
if (groupBy === config.groupBy) {
return config;
}
return { ...config, groupBy };
}
export function shouldFetchLauncherGroups(
scale: LauncherScaleInfo,
config: LauncherViewConfig
): boolean {
const groupBy = getEffectiveGroupBy(scale, config);
if (groupBy === "none") {
return false;
}
if (scale.mode === "full") {
return true;
}
return (
(scale.capabilities.allowSiteGrouping &&
groupBy === "site" &&
config.siteIds.length > 0) ||
(scale.capabilities.allowLabelGrouping &&
groupBy === "label" &&
config.labelIds.length > 0)
);
}
export function shouldShowLauncherGroupList(
scale: LauncherScaleInfo,
config: LauncherViewConfig
): boolean {
return shouldFetchLauncherGroups(scale, config);
}
export function shouldShowSearchFirstGate(
scale: LauncherScaleInfo,
config: LauncherViewConfig
): boolean {
return (
scale.mode === "compact" &&
scale.capabilities.requireSearchOrFilter &&
!hasActiveLauncherFilters(config)
);
}
export function shouldShowFlatResourceList(
scale: LauncherScaleInfo,
config: LauncherViewConfig
): boolean {
if (shouldShowSearchFirstGate(scale, config)) {
return false;
}
const groupBy = getEffectiveGroupBy(scale, config);
if (groupBy === "none") {
return true;
}
if (scale.mode === "full") {
return false;
}
return !shouldShowLauncherGroupList(scale, config);
}
+56 -9
View File
@@ -1,21 +1,27 @@
import { internal } from "@app/lib/api";
import type { LauncherActiveViewId } from "@app/lib/launcherLocalStorage";
import { shouldFetchLauncherGroups } from "@app/lib/launcherScale";
import { resolveLauncherStateFromUrl } from "@app/lib/launcherUrlState";
import { buildLauncherSearchParams } from "@app/lib/launcherSearchParams";
import type {
LauncherGroup,
LauncherScaleInfo,
LauncherDefaultViewOverrides,
LauncherViewConfig,
LauncherViewRecord,
ListLauncherGroupsResponse,
ListLauncherScaleResponse,
ListLauncherViewsResponse
} from "@server/routers/launcher/types";
import { AxiosResponse } from "axios";
export type LauncherPageData = {
views: LauncherViewRecord[];
defaultViewOverrides: LauncherDefaultViewOverrides;
activeViewId: LauncherActiveViewId;
config: LauncherViewConfig;
savedConfig: LauncherViewConfig;
scale: LauncherScaleInfo;
groups: LauncherGroup[];
groupsPagination: {
total: number;
@@ -32,19 +38,56 @@ export async function fetchLauncherPageData(
>
): Promise<LauncherPageData> {
let views: LauncherViewRecord[] = [];
let defaultViewOverrides: LauncherDefaultViewOverrides = {
personal: null,
orgWide: null
};
try {
const viewsRes = await internal.get<
AxiosResponse<ListLauncherViewsResponse>
>(`/org/${orgId}/launcher/views`, cookieHeader);
views = viewsRes.data.data.views;
defaultViewOverrides = viewsRes.data.data.defaultViewOverrides;
} catch (e) {}
const { activeViewId, config, savedConfig } = resolveLauncherStateFromUrl(
searchParams,
views,
null
null,
defaultViewOverrides
);
const scaleFilters = {
query: config.query,
groupBy: config.groupBy,
siteIds: config.siteIds,
labelIds: config.labelIds,
sort_by: config.sortBy,
order: config.order
};
let scale: LauncherScaleInfo = {
mode: "full",
resourceCount: 0,
siteGroupCount: 0,
labelGroupCount: 0,
capabilities: {
allowSiteGrouping: true,
allowLabelGrouping: true,
requireSearchOrFilter: false
}
};
try {
const sp = buildLauncherSearchParams(scaleFilters, 1);
sp.delete("page");
sp.delete("pageSize");
const scaleRes = await internal.get<
AxiosResponse<ListLauncherScaleResponse>
>(`/org/${orgId}/launcher/scale?${sp.toString()}`, cookieHeader);
scale = scaleRes.data.data.scale;
} catch (e) {}
const groupFilters = {
query: config.query,
groupBy: config.groupBy,
@@ -62,20 +105,24 @@ export async function fetchLauncherPageData(
pageSize: 20
};
try {
const sp = buildLauncherSearchParams(groupFilters, 1);
const groupsRes = await internal.get<
AxiosResponse<ListLauncherGroupsResponse>
>(`/org/${orgId}/launcher/groups?${sp.toString()}`, cookieHeader);
groups = groupsRes.data.data.groups;
groupsPagination = groupsRes.data.data.pagination;
} catch (e) {}
if (shouldFetchLauncherGroups(scale, config)) {
try {
const sp = buildLauncherSearchParams(groupFilters, 1);
const groupsRes = await internal.get<
AxiosResponse<ListLauncherGroupsResponse>
>(`/org/${orgId}/launcher/groups?${sp.toString()}`, cookieHeader);
groups = groupsRes.data.data.groups;
groupsPagination = groupsRes.data.data.pagination;
} catch (e) {}
}
return {
views,
defaultViewOverrides,
activeViewId,
config,
savedConfig,
scale,
groups,
groupsPagination
};
+21 -7
View File
@@ -1,7 +1,9 @@
import type { LauncherActiveViewId } from "@app/lib/launcherLocalStorage";
import {
defaultLauncherViewConfig,
getEffectiveDefaultLauncherConfig,
parseIdListParam,
type LauncherDefaultViewOverrides,
type LauncherViewConfig,
type LauncherViewRecord
} from "@server/routers/launcher/types";
@@ -64,10 +66,13 @@ export function isLauncherConfigEqual(
export function getLauncherUrlBaseConfig(
viewId: LauncherActiveViewId,
views: LauncherViewRecord[]
views: LauncherViewRecord[],
defaultViewOverrides?: LauncherDefaultViewOverrides
): LauncherViewConfig {
if (viewId === "default") {
return defaultLauncherViewConfig;
return defaultViewOverrides
? getEffectiveDefaultLauncherConfig(defaultViewOverrides)
: defaultLauncherViewConfig;
}
const savedView = views.find((view) => view.viewId === viewId);
@@ -113,7 +118,7 @@ function parseConfigOverrides(
}
const groupBy = searchParams.get("groupBy");
if (groupBy === "site" || groupBy === "label") {
if (groupBy === "site" || groupBy === "label" || groupBy === "none") {
overrides.groupBy = groupBy;
}
@@ -172,7 +177,8 @@ function isValidActiveViewId(
export function resolveLauncherStateFromUrl(
searchParams: URLSearchParams,
views: LauncherViewRecord[],
fallbackViewId: LauncherActiveViewId | null
fallbackViewId: LauncherActiveViewId | null,
defaultViewOverrides?: LauncherDefaultViewOverrides
): ResolvedLauncherState {
const parsed = parseLauncherUrlState(searchParams);
@@ -188,18 +194,26 @@ export function resolveLauncherStateFromUrl(
: "default";
}
const savedConfig = getLauncherUrlBaseConfig(activeViewId, views);
const savedConfig = getLauncherUrlBaseConfig(
activeViewId,
views,
defaultViewOverrides
);
let config: LauncherViewConfig;
if (hasLauncherConfigParams(searchParams)) {
config = resolveLauncherConfig(
defaultLauncherViewConfig,
getEffectiveDefaultLauncherConfig(
defaultViewOverrides ?? { personal: null, orgWide: null }
),
parsed.configOverrides
);
} else if (activeViewId !== "default") {
config = savedConfig;
} else {
config = defaultLauncherViewConfig;
config = getEffectiveDefaultLauncherConfig(
defaultViewOverrides ?? { personal: null, orgWide: null }
);
}
return {
+65 -3
View File
@@ -50,11 +50,16 @@ import type {
ListLauncherGroupsResponse,
ListLauncherLabelsResponse,
ListLauncherResourcesResponse,
ListLauncherScaleResponse,
ListLauncherSitesResponse,
ListLauncherViewsResponse,
LauncherListQuery,
LauncherResource,
LauncherViewConfig
} from "@server/routers/launcher/types";
import type { GetResourceResponse } from "@server/routers/resource/getResource";
import type { GetResourceAuthInfoResponse } from "@server/routers/resource/getResourceAuthInfo";
import type { GetSiteResourceResponse } from "@server/routers/siteResource/getSiteResource";
import type { LauncherQueryFilters } from "@app/lib/launcherSearchParams";
import { buildLauncherSearchParams } from "@app/lib/launcherSearchParams";
@@ -1189,13 +1194,13 @@ export const launcherQueries = {
const res = await meta!.api.get<
AxiosResponse<ListLauncherViewsResponse>
>(`/org/${orgId}/launcher/views`, { signal });
return res.data.data.views;
return res.data.data;
}
}),
sites: ({
orgId,
query,
perPage = 500
perPage = 20
}: {
orgId: string;
query?: string;
@@ -1227,7 +1232,7 @@ export const launcherQueries = {
labels: ({
orgId,
query,
perPage = 500
perPage = 20
}: {
orgId: string;
query?: string;
@@ -1298,5 +1303,62 @@ export const launcherQueries = {
const nextPage = page + 1;
return page * pageSize < total ? nextPage : undefined;
}
}),
scale: (orgId: string, filters: LauncherQueryFilters) =>
queryOptions({
queryKey: ["ORG", orgId, "LAUNCHER", "SCALE", filters] as const,
queryFn: async ({ signal, meta }) => {
const sp = buildLauncherSearchParams(filters, 1);
sp.delete("page");
sp.delete("pageSize");
sp.delete("groupKey");
const res = await meta!.api.get<
AxiosResponse<ListLauncherScaleResponse>
>(`/org/${orgId}/launcher/scale?${sp.toString()}`, { signal });
return res.data.data.scale;
}
}),
resourceDetail: (orgId: string, resource: LauncherResource | null) =>
queryOptions({
queryKey: [
"ORG",
orgId,
"LAUNCHER",
"RESOURCE_DETAIL",
resource?.launcherResourceKey ?? null
] as const,
enabled: resource != null,
queryFn: async ({ signal, meta }) => {
if (!resource) {
throw new Error("Resource is required");
}
if (resource.resourceType === "public") {
const res = await meta!.api.get<
AxiosResponse<GetResourceResponse>
>(`/org/${orgId}/resource/${resource.niceId}`, { signal });
const resourceData = res.data.data;
const authRes = await meta!.api.get<
AxiosResponse<GetResourceAuthInfoResponse>
>(`/resource/${resourceData.resourceGuid}/auth`, {
signal
});
return {
resourceType: "public" as const,
data: resourceData,
authInfo: authRes.data.data
};
}
const siteResourceId =
resource.siteResourceId ?? resource.resourceId;
const res = await meta!.api.get<
AxiosResponse<GetSiteResourceResponse>
>(`/org/${orgId}/site-resource/${siteResourceId}`, { signal });
return {
resourceType: "site" as const,
data: res.data.data
};
}
})
};