From ea7daae1534abda0a887d00ed29ca7b7a6c280eb Mon Sep 17 00:00:00 2001 From: netanelC Date: Thu, 13 Nov 2025 16:12:51 +0200 Subject: [PATCH 01/17] feat(auth-ui, auth-manager): fix monaco to not work cdn, latest view in connection, fix clients search + add connection dropdown --- package-lock.json | 113 ++++++++++++-- packages/auth-manager/openapi3.yaml | 16 ++ .../client/controllers/clientController.ts | 3 +- .../auth-manager/src/client/models/client.ts | 1 + .../src/client/models/clientManager.ts | 5 +- .../src/connection/models/connection.ts | 2 + .../connection/models/connectionManager.ts | 105 ++++++++++++- packages/auth-manager/src/openapi.d.ts | 6 + packages/auth-ui/package.json | 1 + .../auth-ui/src/components/ui/command.tsx | 6 +- packages/auth-ui/src/main.tsx | 4 + .../auth-ui/src/pages/clients/ClientsPage.tsx | 84 +++++++++- .../src/pages/connections/ConnectionsPage.tsx | 68 +++++++-- .../connections/CreateConnectionModal.tsx | 143 ++++++++++++------ .../pages/connections/EditConnectionModal.tsx | 4 +- packages/auth-ui/src/types/schema.d.ts | 140 +++++++++++++++++ 16 files changed, 616 insertions(+), 85 deletions(-) diff --git a/package-lock.json b/package-lock.json index e16b146f..d6592d66 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11406,6 +11406,14 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/parse-json": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", + "integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==", + "license": "MIT", + "optional": true, + "peer": true + }, "node_modules/@types/pg": { "version": "8.15.4", "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.15.4.tgz", @@ -13004,6 +13012,63 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/babel-plugin-macros": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz", + "integrity": "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@babel/runtime": "^7.12.5", + "cosmiconfig": "^7.0.0", + "resolve": "^1.19.0" + }, + "engines": { + "node": ">=10", + "npm": ">=6" + } + }, + "node_modules/babel-plugin-macros/node_modules/cosmiconfig": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", + "integrity": "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@types/parse-json": "^4.0.0", + "import-fresh": "^3.2.1", + "parse-json": "^5.0.0", + "path-type": "^4.0.0", + "yaml": "^1.10.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/babel-plugin-macros/node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/babel-plugin-macros/node_modules/yaml": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", + "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", + "license": "ISC", + "optional": true, + "peer": true, + "engines": { + "node": ">= 6" + } + }, "node_modules/babel-preset-current-node-syntax": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.1.0.tgz", @@ -13591,7 +13656,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=6" @@ -16638,7 +16703,7 @@ "version": "1.3.2", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "is-arrayish": "^0.2.1" @@ -19536,7 +19601,7 @@ "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "parent-module": "^1.0.0", @@ -19553,7 +19618,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=4" @@ -19828,7 +19893,7 @@ "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/is-binary-path": { @@ -25496,11 +25561,32 @@ "license": "MIT" }, "node_modules/monaco-editor": { - "version": "0.52.2", - "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.52.2.tgz", - "integrity": "sha512-GEQWEZmfkOGLdd3XK8ryrfWz3AIP8YymVXiPHEdewrUq7mh0qrKrfHLNCXcbB6sTnMLnOZ3ztSiKcciFUkIJwQ==", + "version": "0.54.0", + "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.54.0.tgz", + "integrity": "sha512-hx45SEUoLatgWxHKCmlLJH81xBo0uXP4sRkESUpmDQevfi+e7K1VuiSprK6UpQ8u4zOcKNiH0pMvHvlMWA/4cw==", "license": "MIT", - "peer": true + "dependencies": { + "dompurify": "3.1.7", + "marked": "14.0.0" + } + }, + "node_modules/monaco-editor/node_modules/dompurify": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.1.7.tgz", + "integrity": "sha512-VaTstWtsneJY8xzy7DekmYWEOZcmzIe3Qb3zPd4STve1OBTa+e+WmS1ITQec1fZYXI3HCsOZZiSMpG6oxoWMWQ==", + "license": "(MPL-2.0 OR Apache-2.0)" + }, + "node_modules/monaco-editor/node_modules/marked": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz", + "integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 18" + } }, "node_modules/mri": { "version": "1.2.0", @@ -27196,7 +27282,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "callsites": "^3.0.0" @@ -27230,7 +27316,7 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.0.0", @@ -27249,14 +27335,14 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/parse-json/node_modules/lines-and-columns": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/parse-path": { @@ -34148,6 +34234,7 @@ "cmdk": "^1.1.1", "date-fns": "^3.6.0", "lucide-react": "^0.488.0", + "monaco-editor": "^0.54.0", "next-themes": "^0.4.6", "openapi-fetch": "^0.13.5", "openapi-react-query": "^0.3.1", diff --git a/packages/auth-manager/openapi3.yaml b/packages/auth-manager/openapi3.yaml index b8b20f84..0174a7f4 100644 --- a/packages/auth-manager/openapi3.yaml +++ b/packages/auth-manager/openapi3.yaml @@ -14,6 +14,11 @@ paths: tags: - client parameters: + - in: query + name: search + description: search by client name (partial match, case-insensitive) + schema: + type: string - in: query name: branch description: search by branch name @@ -281,6 +286,17 @@ paths: tags: - connection parameters: + - in: query + name: search + description: search by connection name (partial match, case-insensitive) + schema: + type: string + - in: query + name: latestOnly + description: if true, returns only the latest version per (name, environment) pair + schema: + type: boolean + default: false - $ref: '#/components/parameters/environmentQueryParam' - in: query name: isEnabled diff --git a/packages/auth-manager/src/client/controllers/clientController.ts b/packages/auth-manager/src/client/controllers/clientController.ts index 5e3bfa61..4528bd6d 100644 --- a/packages/auth-manager/src/client/controllers/clientController.ts +++ b/packages/auth-manager/src/client/controllers/clientController.ts @@ -20,8 +20,9 @@ function responseClientToOpenApi(client: IClient): components['schemas']['client } function queryParamsToSearchParams(query: NonNullable): ClientSearchParams { - const { branch, tags, createdAfter, createdBefore, updatedAfter, updatedBefore } = query; + const { search, branch, tags, createdAfter, createdBefore, updatedAfter, updatedBefore } = query; return { + search, branch, tags, createdAfter: createdAfter !== undefined ? new Date(createdAfter) : undefined, diff --git a/packages/auth-manager/src/client/models/client.ts b/packages/auth-manager/src/client/models/client.ts index bf805df6..e6cde68c 100644 --- a/packages/auth-manager/src/client/models/client.ts +++ b/packages/auth-manager/src/client/models/client.ts @@ -1,6 +1,7 @@ import { IClient } from '@map-colonies/auth-core'; export interface ClientSearchParams { + search?: IClient['name']; branch?: IClient['branch']; createdBefore?: IClient['createdAt']; createdAfter?: IClient['createdAt']; diff --git a/packages/auth-manager/src/client/models/clientManager.ts b/packages/auth-manager/src/client/models/clientManager.ts index f452ce5f..aae9da5c 100644 --- a/packages/auth-manager/src/client/models/clientManager.ts +++ b/packages/auth-manager/src/client/models/clientManager.ts @@ -1,6 +1,6 @@ import { type Logger } from '@map-colonies/js-logger'; import { inject, injectable } from 'tsyringe'; -import { ArrayContains, QueryFailedError } from 'typeorm'; +import { ArrayContains, ILike, QueryFailedError } from 'typeorm'; import { DatabaseError } from 'pg'; import { Client, type IClient } from '@map-colonies/auth-core'; import { SERVICES } from '@common/constants'; @@ -30,9 +30,10 @@ export class ClientManager { // eslint doesn't recognize this as valid because its in the type definition let findOptions: Parameters[0] = {}; if (searchParams !== undefined) { - const { branch, tags, createdAfter, createdBefore, updatedAfter, updatedBefore } = searchParams; + const { search, branch, tags, createdAfter, createdBefore, updatedAfter, updatedBefore } = searchParams; findOptions = { where: { + name: search !== undefined && search !== '' ? ILike(`%${search}%`) : undefined, tags: tags ? ArrayContains(tags) : undefined, branch, createdAt: createDatesComparison(createdAfter, createdBefore), diff --git a/packages/auth-manager/src/connection/models/connection.ts b/packages/auth-manager/src/connection/models/connection.ts index 7ae1212c..d5d849af 100644 --- a/packages/auth-manager/src/connection/models/connection.ts +++ b/packages/auth-manager/src/connection/models/connection.ts @@ -1,6 +1,8 @@ import { Environments } from '@map-colonies/auth-core'; export interface ConnectionSearchParams { + search?: string; + latestOnly?: boolean; environment?: Environments[]; isEnabled?: boolean; isNoBrowser?: boolean; diff --git a/packages/auth-manager/src/connection/models/connectionManager.ts b/packages/auth-manager/src/connection/models/connectionManager.ts index 6c588ed7..769c2f52 100644 --- a/packages/auth-manager/src/connection/models/connectionManager.ts +++ b/packages/auth-manager/src/connection/models/connectionManager.ts @@ -1,7 +1,7 @@ import { type Logger } from '@map-colonies/js-logger'; import { Client, Connection, Environments, IConnection } from '@map-colonies/auth-core'; import { inject, injectable } from 'tsyringe'; -import { ArrayContains, FindManyOptions, In } from 'typeorm'; +import { ArrayContains, FindManyOptions, In, ILike } from 'typeorm'; import { JWK } from 'jose'; import { ClientNotFoundError } from '@client/models/errors'; import { SERVICES } from '@common/constants'; @@ -32,14 +32,18 @@ export class ConnectionManager { sortParams?: SortOptions ): Promise<[IConnection[], number]> { this.logger.info({ msg: 'fetching connections', searchParams }); - const { environment, domains, isEnabled, isNoBrowser, isNoOrigin, name } = searchParams; + const { search, latestOnly, environment, domains, isEnabled, isNoBrowser, isNoOrigin, name } = searchParams; + + if (latestOnly === true) { + return this.getLatestConnections(searchParams, paginationParams, sortParams); + } const findOptions: FindManyOptions = { where: { + name: search !== undefined && search !== '' ? ILike(`%${search}%`) : name, environment: environment ? In(environment) : undefined, allowNoBrowserConnection: isNoBrowser ?? undefined, allowNoOriginConnection: isNoOrigin ?? undefined, - name, domains: domains ? ArrayContains(domains) : undefined, enabled: isEnabled ?? undefined, }, @@ -56,6 +60,101 @@ export class ConnectionManager { return this.connectionRepository.findAndCount(findOptions); } + private async getLatestConnections( + searchParams: ConnectionSearchParams, + paginationParams?: PaginationParams, + sortParams?: SortOptions + ): Promise<[IConnection[], number]> { + const { search, environment, domains, isEnabled, isNoBrowser, isNoOrigin } = searchParams; + + const countQueryBuilder = this.connectionRepository.createQueryBuilder('connection'); + countQueryBuilder.select('COUNT(DISTINCT (connection.name, connection.environment))', 'count'); + + if (search !== undefined && search !== '') { + countQueryBuilder.andWhere('connection.name ILIKE :search', { search: `%${search}%` }); + } + + if (environment !== undefined) { + countQueryBuilder.andWhere('connection.environment IN (:...environment)', { environment }); + } + + if (isEnabled !== undefined) { + countQueryBuilder.andWhere('connection.enabled = :isEnabled', { isEnabled }); + } + + if (isNoBrowser !== undefined) { + countQueryBuilder.andWhere('connection.allowNoBrowserConnection = :isNoBrowser', { isNoBrowser }); + } + + if (isNoOrigin !== undefined) { + countQueryBuilder.andWhere('connection.allowNoOriginConnection = :isNoOrigin', { isNoOrigin }); + } + + if (domains !== undefined) { + countQueryBuilder.andWhere('connection.domains @> :domains', { domains }); + } + + const countResult = await countQueryBuilder.getRawOne<{ count: string }>(); + const total = parseInt(countResult?.count ?? '0', 10); + + const queryBuilder = this.connectionRepository.createQueryBuilder('connection'); + + queryBuilder.distinctOn(['connection.name', 'connection.environment']); + + const nameOrder = sortParams?.name ? (sortParams.name.toUpperCase() as 'ASC' | 'DESC') : 'ASC'; + const environmentOrder = sortParams?.environment ? (sortParams.environment.toUpperCase() as 'ASC' | 'DESC') : 'ASC'; + + queryBuilder.orderBy('connection.name', nameOrder).addOrderBy('connection.environment', environmentOrder); + + if (sortParams !== undefined && Object.keys(sortParams).length > 0) { + for (const [key, order] of Object.entries(sortParams)) { + if (key !== 'name' && key !== 'environment') { + queryBuilder.addOrderBy(`connection.${key}`, order.toUpperCase() as 'ASC' | 'DESC'); + } + } + } + + queryBuilder.addOrderBy('connection.version', 'DESC'); + + if (search !== undefined && search !== '') { + queryBuilder.andWhere('connection.name ILIKE :search', { search: `%${search}%` }); + } + + if (environment !== undefined) { + queryBuilder.andWhere('connection.environment IN (:...environment)', { environment }); + } + + if (isEnabled !== undefined) { + queryBuilder.andWhere('connection.enabled = :isEnabled', { isEnabled }); + } + + if (isNoBrowser !== undefined) { + queryBuilder.andWhere('connection.allowNoBrowserConnection = :isNoBrowser', { isNoBrowser }); + } + + if (isNoOrigin !== undefined) { + queryBuilder.andWhere('connection.allowNoOriginConnection = :isNoOrigin', { isNoOrigin }); + } + + if (domains !== undefined) { + queryBuilder.andWhere('connection.domains @> :domains', { domains }); + } + + if (paginationParams !== undefined) { + const { skip, take } = paginationParamsToFindOptions(paginationParams); + if (skip !== undefined) { + queryBuilder.skip(skip); + } + if (take !== undefined) { + queryBuilder.take(take); + } + } + + const results = await queryBuilder.getMany(); + + return [results, total]; + } + public async getConnection(name: string, environment: Environments, version: number): Promise { this.logger.info({ msg: 'fetching connection', connection: { name, version, environment } }); diff --git a/packages/auth-manager/src/openapi.d.ts b/packages/auth-manager/src/openapi.d.ts index 02528b16..49c13f37 100644 --- a/packages/auth-manager/src/openapi.d.ts +++ b/packages/auth-manager/src/openapi.d.ts @@ -533,6 +533,8 @@ export interface operations { getClients: { parameters: { query?: { + /** @description search by client name (partial match, case-insensitive) */ + search?: string; /** @description search by branch name */ branch?: string; /** @description filters all clients created before given date */ @@ -780,6 +782,10 @@ export interface operations { getConnections: { parameters: { query?: { + /** @description search by connection name (partial match, case-insensitive) */ + search?: string; + /** @description if true, returns only the latest version per (name, environment) pair */ + latestOnly?: boolean; environment?: components['parameters']['environmentQueryParam']; isEnabled?: boolean; isNoBrowser?: boolean; diff --git a/packages/auth-ui/package.json b/packages/auth-ui/package.json index ca074d9d..899b4759 100644 --- a/packages/auth-ui/package.json +++ b/packages/auth-ui/package.json @@ -37,6 +37,7 @@ "cmdk": "^1.1.1", "date-fns": "^3.6.0", "lucide-react": "^0.488.0", + "monaco-editor": "^0.54.0", "next-themes": "^0.4.6", "openapi-fetch": "^0.13.5", "openapi-react-query": "^0.3.1", diff --git a/packages/auth-ui/src/components/ui/command.tsx b/packages/auth-ui/src/components/ui/command.tsx index b9cfb924..4b4ed2c3 100644 --- a/packages/auth-ui/src/components/ui/command.tsx +++ b/packages/auth-ui/src/components/ui/command.tsx @@ -55,15 +55,17 @@ function CommandInput({ className, ...props }: React.ComponentProps) { +const CommandList = React.forwardRef>(({ className, ...props }, ref) => { return ( ); -} +}); +CommandList.displayName = 'CommandList'; function CommandEmpty({ ...props }: React.ComponentProps) { return ; diff --git a/packages/auth-ui/src/main.tsx b/packages/auth-ui/src/main.tsx index 340a6e58..3c74e593 100644 --- a/packages/auth-ui/src/main.tsx +++ b/packages/auth-ui/src/main.tsx @@ -2,6 +2,10 @@ import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; import './index.css'; import App from './App'; +import loader from '@monaco-editor/loader'; +import * as monaco from 'monaco-editor'; + +loader.config({ monaco }); createRoot(document.getElementById('root')!).render( diff --git a/packages/auth-ui/src/pages/clients/ClientsPage.tsx b/packages/auth-ui/src/pages/clients/ClientsPage.tsx index 97b30f7d..841d2cbe 100644 --- a/packages/auth-ui/src/pages/clients/ClientsPage.tsx +++ b/packages/auth-ui/src/pages/clients/ClientsPage.tsx @@ -30,6 +30,7 @@ type SiteResult = { }; type Filters = { + search?: string; branch?: string; createdBefore?: string; createdAfter?: string; @@ -67,6 +68,7 @@ const getURLParams = () => { return { page: parseInt(params.get('page') || '1', 10), pageSize: parseInt(params.get('pageSize') || '10', 10), + searchTerm: params.get('search') || '', branch: params.get('branch') || '', createdAfter: params.get('createdAfter') || '', createdBefore: params.get('createdBefore') || '', @@ -104,7 +106,8 @@ export const ClientsPage = () => { const [currentEditStep, setCurrentEditStep] = useState<'edit' | 'send'>('edit'); const urlParams = getURLParams(); - const [searchTerm, setSearchTerm] = useState(urlParams.branch); + const [searchTerm, setSearchTerm] = useState(urlParams.searchTerm); + const [selectedBranch, setSelectedBranch] = useState(urlParams.branch); const [createdAfterDate, setCreatedAfterDate] = useState(urlParams.createdAfter ? new Date(urlParams.createdAfter) : undefined); const [createdBeforeDate, setCreatedBeforeDate] = useState( urlParams.createdBefore ? new Date(urlParams.createdBefore) : undefined @@ -122,15 +125,21 @@ export const ClientsPage = () => { const [tagsInput, setTagsInput] = useState(''); const isInitialRender = useRef(true); + const searchInputRef = useRef(null); + const branchInputRef = useRef(null); + const wasLoading = useRef(false); + const lastFocusedInput = useRef<'search' | 'branch' | null>(null); const debouncedSearchTerm = useDebounce(searchTerm); + const debouncedBranch = useDebounce(selectedBranch); useEffect(() => { const sortParams = sort.map((s) => `${s.field}:${s.direction}`); updateURL({ page, pageSize, - branch: searchTerm, + search: searchTerm, + branch: selectedBranch, createdAfter: createdAfterDate?.toISOString() || '', createdBefore: createdBeforeDate?.toISOString() || '', updatedAfter: updatedAfterDate?.toISOString() || '', @@ -139,7 +148,19 @@ export const ClientsPage = () => { sort: sortParams, showFilters: showAdvancedFilters, }); - }, [page, pageSize, searchTerm, createdAfterDate, createdBeforeDate, updatedAfterDate, updatedBeforeDate, selectedTags, sort, showAdvancedFilters]); + }, [ + page, + pageSize, + searchTerm, + selectedBranch, + createdAfterDate, + createdBeforeDate, + updatedAfterDate, + updatedBeforeDate, + selectedTags, + sort, + showAdvancedFilters, + ]); const queryParams = { ...filters, @@ -224,7 +245,11 @@ export const ClientsPage = () => { const newFilters: Filters = {}; if (debouncedSearchTerm) { - newFilters.branch = debouncedSearchTerm; + newFilters.search = debouncedSearchTerm; + } + + if (debouncedBranch) { + newFilters.branch = debouncedBranch; } if (createdAfterDate) { @@ -252,12 +277,27 @@ export const ClientsPage = () => { if (!isInitialRender.current) { setPage(1); } - }, [debouncedSearchTerm, createdAfterDate, createdBeforeDate, updatedAfterDate, updatedBeforeDate, selectedTags]); + }, [debouncedSearchTerm, debouncedBranch, createdAfterDate, createdBeforeDate, updatedAfterDate, updatedBeforeDate, selectedTags]); useEffect(() => { isInitialRender.current = false; }, []); + useEffect(() => { + if (wasLoading.current && !isLoading) { + if (lastFocusedInput.current === 'search' && searchInputRef.current) { + searchInputRef.current.focus(); + const length = searchInputRef.current.value.length; + searchInputRef.current.setSelectionRange(length, length); + } else if (lastFocusedInput.current === 'branch' && branchInputRef.current) { + branchInputRef.current.focus(); + const length = branchInputRef.current.value.length; + branchInputRef.current.setSelectionRange(length, length); + } + } + wasLoading.current = isLoading; + }, [isLoading]); + const openEditDialog = (client: Client) => { setSelectedClient({ ...client }); setIsEditDialogOpen(true); @@ -276,6 +316,7 @@ export const ClientsPage = () => { const resetFilters = () => { setSearchTerm(''); + setSelectedBranch(''); setCreatedAfterDate(undefined); setCreatedBeforeDate(undefined); setUpdatedAfterDate(undefined); @@ -283,11 +324,13 @@ export const ClientsPage = () => { setSelectedTags([]); setShowAdvancedFilters(false); setPage(1); + lastFocusedInput.current = null; }; const getActiveFiltersCount = () => { let count = 0; if (searchTerm) count++; + if (selectedBranch) count++; if (createdAfterDate) count++; if (createdBeforeDate) count++; if (updatedAfterDate) count++; @@ -511,11 +554,15 @@ export const ClientsPage = () => {
setSearchTerm(e.target.value)} + onFocus={() => { + lastFocusedInput.current = 'search'; + }} />
@@ -549,12 +596,20 @@ export const ClientsPage = () => {
{searchTerm && ( - Branch: {searchTerm} + Name: {searchTerm} )} + {selectedBranch && ( + + Branch: {selectedBranch} + + + )} {createdAfterDate && ( Created after: {format(createdAfterDate, 'MMM d, yyyy')} @@ -688,6 +743,21 @@ export const ClientsPage = () => {
+ {/* Branch Filter */} +
+ + setSelectedBranch(e.target.value)} + onFocus={() => { + lastFocusedInput.current = 'branch'; + }} + className="max-w-md" + /> +
+ {/* Tags Filter */}
diff --git a/packages/auth-ui/src/pages/connections/ConnectionsPage.tsx b/packages/auth-ui/src/pages/connections/ConnectionsPage.tsx index 07782154..779c0ed7 100644 --- a/packages/auth-ui/src/pages/connections/ConnectionsPage.tsx +++ b/packages/auth-ui/src/pages/connections/ConnectionsPage.tsx @@ -10,6 +10,7 @@ import { CreateConnectionModal } from './CreateConnectionModal'; import { EditConnectionModal } from './EditConnectionModal'; import { toast } from 'sonner'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../../components/ui/select'; +import { Switch } from '../../components/ui/switch'; import { Checkbox } from '../../components/ui/checkbox'; import { Label } from '../../components/ui/label'; import { useQueryClient } from '@tanstack/react-query'; @@ -61,6 +62,7 @@ const updateURL = (params: Record) const getURLParams = () => { const params = new URLSearchParams(window.location.search); + const storedLatestOnly = localStorage.getItem('connectionsLatestOnly'); return { page: parseInt(params.get('page') || '1', 10), pageSize: parseInt(params.get('pageSize') || '10', 10), @@ -69,6 +71,7 @@ const getURLParams = () => { isNoBrowser: params.get('isNoBrowser') ? params.get('isNoBrowser') === 'true' : undefined, isNoOrigin: params.get('isNoOrigin') ? params.get('isNoOrigin') === 'true' : undefined, searchTerm: params.get('search') || '', + latestOnly: params.get('latestOnly') ? params.get('latestOnly') === 'true' : storedLatestOnly === 'true', sort: params.get('sort') ? params .get('sort')! @@ -107,6 +110,7 @@ export const ConnectionsPage = () => { const [isEnabled, setIsEnabled] = useState(urlParams.isEnabled); const [isNoBrowser, setIsNoBrowser] = useState(urlParams.isNoBrowser); const [isNoOrigin, setIsNoOrigin] = useState(urlParams.isNoOrigin); + const [latestOnly, setLatestOnly] = useState(urlParams.latestOnly); const [searchTerm, setSearchTerm] = useState(urlParams.searchTerm); const [showAdvancedFilters, setShowAdvancedFilters] = useState(urlParams.showAdvancedFilters); const [page, setPage] = useState(urlParams.page); @@ -114,6 +118,8 @@ export const ConnectionsPage = () => { const [sort, setSort] = useState(urlParams.sort); const isInitialRender = useRef(true); + const searchInputRef = useRef(null); + const wasLoading = useRef(false); const debouncedSearchTerm = useDebounce(searchTerm); @@ -127,16 +133,23 @@ export const ConnectionsPage = () => { isNoBrowser: isNoBrowser !== undefined ? isNoBrowser : '', isNoOrigin: isNoOrigin !== undefined ? isNoOrigin : '', search: searchTerm, + latestOnly, sort: sortParams, showFilters: showAdvancedFilters, }); - }, [page, pageSize, selectedEnvironment, isEnabled, isNoBrowser, isNoOrigin, searchTerm, sort, showAdvancedFilters]); + }, [page, pageSize, selectedEnvironment, isEnabled, isNoBrowser, isNoOrigin, searchTerm, latestOnly, sort, showAdvancedFilters]); + + useEffect(() => { + localStorage.setItem('connectionsLatestOnly', latestOnly.toString()); + }, [latestOnly]); const queryParams = { ...filters, page, page_size: pageSize, sort: sort.map((s) => `${s.field}:${s.direction}`), + ...(debouncedSearchTerm && { search: debouncedSearchTerm }), + ...(latestOnly && { latestOnly }), }; const { data, isLoading, isError, error, refetch } = $api.useQuery('get', '/connection', { @@ -210,10 +223,25 @@ export const ConnectionsPage = () => { } }, [selectedEnvironment, isEnabled, isNoBrowser, isNoOrigin]); + useEffect(() => { + if (!isInitialRender.current) { + setPage(1); + } + }, [debouncedSearchTerm]); + useEffect(() => { isInitialRender.current = false; }, []); + useEffect(() => { + if (wasLoading.current && !isLoading && searchInputRef.current) { + searchInputRef.current.focus(); + const length = searchInputRef.current.value.length; + searchInputRef.current.setSelectionRange(length, length); + } + wasLoading.current = isLoading; + }, [isLoading]); + const handleCreateConnection = (data: { body: Connection }) => { setCreateError(null); upsertConnectionMutation.mutate(data); @@ -297,6 +325,7 @@ export const ConnectionsPage = () => { const getActiveFiltersCount = () => { let count = 0; + if (searchTerm) count++; if (selectedEnvironment !== 'all') count++; if (isEnabled !== undefined) count++; if (isNoBrowser !== undefined) count++; @@ -317,6 +346,9 @@ export const ConnectionsPage = () => { const removeFilter = (filterType: string) => { switch (filterType) { + case 'search': + setSearchTerm(''); + break; case 'environment': setSelectedEnvironment('all'); break; @@ -359,16 +391,7 @@ export const ConnectionsPage = () => { setPage(1); }; - const filteredData = - data?.items?.filter((connection) => { - if (!debouncedSearchTerm) return true; - const searchLower = debouncedSearchTerm.toLowerCase(); - return ( - connection.name?.toLowerCase().includes(searchLower) || - connection.environment?.toLowerCase().includes(searchLower) || - connection.token?.toLowerCase().includes(searchLower) - ); - }) || []; + const filteredData = data?.items || []; const totalPages = data?.total ? Math.ceil(data.total / pageSize) : 0; const startItem = (page - 1) * pageSize + 1; @@ -434,6 +457,7 @@ export const ConnectionsPage = () => {
{
+
+ + { + setLatestOnly(checked); + setPage(1); + }} + /> +
+ + + )} {selectedEnvironment !== 'all' && ( Environment: {selectedEnvironment} diff --git a/packages/auth-ui/src/pages/connections/CreateConnectionModal.tsx b/packages/auth-ui/src/pages/connections/CreateConnectionModal.tsx index 9e55e8dd..d947de3f 100644 --- a/packages/auth-ui/src/pages/connections/CreateConnectionModal.tsx +++ b/packages/auth-ui/src/pages/connections/CreateConnectionModal.tsx @@ -1,16 +1,15 @@ import { useState, useEffect } from 'react'; import { components } from '../../types/schema'; import { Button } from '../../components/ui/button'; -import { Loader2, X, Check, ChevronsUpDown, ArrowRight } from 'lucide-react'; +import { Loader2, X, Check, ArrowRight, ChevronsUpDown } from 'lucide-react'; import { Input } from '../../components/ui/input'; import { Label } from '../../components/ui/label'; import { Badge } from '../../components/ui/badge'; import { Switch } from '../../components/ui/switch'; import { DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '../../components/ui/dialog'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../../components/ui/select'; -import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem } from '../../components/ui/command'; import { Popover, PopoverContent, PopoverTrigger } from '../../components/ui/popover'; -import { cn } from '../../lib/utils'; +import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '../../components/ui/command'; import { $api } from '../../fetch'; import { useForm } from 'react-hook-form'; import { z } from 'zod'; @@ -20,6 +19,7 @@ import { Alert, AlertDescription } from '../../components/ui/alert'; import { AlertCircle } from 'lucide-react'; import { SiteSelection } from '../../components/SiteSelection'; import { getAvailableSites } from '@/components/exports'; +import { cn } from '../../lib/utils'; type Connection = components['schemas']['connection']; type Client = components['schemas']['client']; @@ -74,16 +74,52 @@ export const CreateConnectionModal = ({ }: CreateConnectionModalProps) => { const [newOrigin, setNewOrigin] = useState(''); const [useToken, setUseToken] = useState(false); - const [open, setOpen] = useState(false); const [formError, setFormError] = useState(null); const [isSubmitting, setIsSubmitting] = useState(false); const [selectedSites, setSelectedSites] = useState([]); const [currentStep, setCurrentStep] = useState('create'); + const [clientSearch, setClientSearch] = useState(''); + const [clients, setClients] = useState([]); + const [isLoadingClients, setIsLoadingClients] = useState(false); + const [clientPopoverOpen, setClientPopoverOpen] = useState(false); const currentSite = localStorage.getItem('selectedSite') || ''; const otherSites = availableSites.filter((site) => site !== currentSite); - const { data: clients, isLoading: isLoadingClients } = $api.useQuery('get', '/client'); + useEffect(() => { + const fetchClients = async () => { + setIsLoadingClients(true); + try { + const baseUrl = localStorage.getItem('currentBaseUrl') || 'http://localhost:8080/'; + const url = new URL('/client', baseUrl); + url.searchParams.set('page', '1'); + + if (clientSearch) { + url.searchParams.set('page_size', '50'); + url.searchParams.set('search', clientSearch); + } else { + url.searchParams.set('page_size', '100'); + } + + const response = await fetch(url.toString()); + + if (!response.ok) { + throw new Error('Failed to fetch clients'); + } + + const data = await response.json(); + setClients((data?.items || []) as Client[]); + } catch (error) { + console.error('Error fetching clients:', error); + setClients([]); + } finally { + setIsLoadingClients(false); + } + }; + + fetchClients(); + }, [clientSearch]); + const { data: domains, isLoading: isLoadingDomains } = $api.useQuery('get', '/domain'); useEffect(() => { @@ -166,11 +202,6 @@ export const CreateConnectionModal = ({ } }; - const handleClientSelect = (clientName: string) => { - form.setValue('name', clientName, { shouldDirty: true, shouldValidate: true }); - setOpen(false); - }; - const onSubmit = (data: FormValues) => { if (isPending || isSubmitting) return; @@ -228,38 +259,62 @@ export const CreateConnectionModal = ({
-
- Client - - - - - - - - No client found. - - {clients?.items?.map((client: Client) => ( - handleClientSelect(client.name)}> - - {client.name} - - ))} - - - - - {form.formState.errors.name &&

{form.formState.errors.name.message}

} -
+ ( + + Client + + + + + + + e.stopPropagation()}> + + + + {isLoadingClients ? ( +
+ +
+ ) : ( + <> + No client found. + + {clients.map((client) => ( + { + field.onChange(client.name); + setClientPopoverOpen(false); + }} + > + + {client.name} + + ))} + + + )} +
+
+
+
+ +
+ )} + /> (
- Status + Enabled
@@ -328,7 +383,7 @@ export const CreateConnectionModal = ({ render={({ field }) => (
- Browser Check + Skip Browser Check

{field.value ? 'Allow connections without browser validation' : 'Require browser validation'}

diff --git a/packages/auth-ui/src/pages/connections/EditConnectionModal.tsx b/packages/auth-ui/src/pages/connections/EditConnectionModal.tsx index 35a09ee8..caad06fc 100644 --- a/packages/auth-ui/src/pages/connections/EditConnectionModal.tsx +++ b/packages/auth-ui/src/pages/connections/EditConnectionModal.tsx @@ -331,7 +331,7 @@ export const EditConnectionModal = ({ render={({ field }) => (
- Status + Enabled
@@ -369,7 +369,7 @@ export const EditConnectionModal = ({ render={({ field }) => (
- Browser Check + Skip Browser Check

{field.value ? 'Allow connections without browser validation' : 'Require browser validation'}

diff --git a/packages/auth-ui/src/types/schema.d.ts b/packages/auth-ui/src/types/schema.d.ts index 6311f4c5..f149ebc0 100644 --- a/packages/auth-ui/src/types/schema.d.ts +++ b/packages/auth-ui/src/types/schema.d.ts @@ -102,6 +102,26 @@ export interface paths { patch?: never; trace?: never; }; + '/client/{clientName}/connection/{environment}/latest': { + parameters: { + query?: never; + header?: never; + path: { + clientName: components['parameters']['clientParam']; + environment: components['parameters']['environmentPathParam']; + }; + cookie?: never; + }; + /** get the latest client connection for specific environment */ + get: operations['getClientLatestConnection']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; '/connection': { parameters: { query?: never; @@ -177,6 +197,25 @@ export interface paths { patch?: never; trace?: never; }; + '/key/{environment}/latest': { + parameters: { + query?: never; + header?: never; + path: { + environment: components['parameters']['environmentPathParam']; + }; + cookie?: never; + }; + /** gets the latest key for specific environment */ + get: operations['getLatestKey']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; '/asset': { parameters: { query?: never; @@ -234,6 +273,25 @@ export interface paths { patch?: never; trace?: never; }; + '/asset/{assetName}/latest': { + parameters: { + query?: never; + header?: never; + path: { + assetName: components['parameters']['assetParam']; + }; + cookie?: never; + }; + /** get latest asset by name */ + get: operations['getLatestAsset']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; '/bundle': { parameters: { query?: never; @@ -478,6 +536,8 @@ export interface operations { getClients: { parameters: { query?: { + /** @description search by client name (partial match, case-insensitive) */ + search?: string; /** @description search by branch name */ branch?: string; /** @description filters all clients created before given date */ @@ -696,9 +756,39 @@ export interface operations { 500: components['responses']['500InternalServerError']; }; }; + getClientLatestConnection: { + parameters: { + query?: never; + header?: never; + path: { + clientName: components['parameters']['clientParam']; + environment: components['parameters']['environmentPathParam']; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['connection']; + }; + }; + 400: components['responses']['400BadRequest']; + 404: components['responses']['404NotFound']; + 500: components['responses']['500InternalServerError']; + }; + }; getConnections: { parameters: { query?: { + /** @description search by connection name (partial match, case-insensitive) */ + search?: string; + /** @description if true, returns only the latest version per (name, environment) pair */ + latestOnly?: boolean; environment?: components['parameters']['environmentQueryParam']; isEnabled?: boolean; isNoBrowser?: boolean; @@ -899,6 +989,31 @@ export interface operations { 500: components['responses']['500InternalServerError']; }; }; + getLatestKey: { + parameters: { + query?: never; + header?: never; + path: { + environment: components['parameters']['environmentPathParam']; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['key']; + }; + }; + 400: components['responses']['400BadRequest']; + 404: components['responses']['404NotFound']; + 500: components['responses']['500InternalServerError']; + }; + }; getAssets: { parameters: { query?: { @@ -1012,6 +1127,31 @@ export interface operations { 500: components['responses']['500InternalServerError']; }; }; + getLatestAsset: { + parameters: { + query?: never; + header?: never; + path: { + assetName: components['parameters']['assetParam']; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['asset']; + }; + }; + 400: components['responses']['400BadRequest']; + 404: components['responses']['404NotFound']; + 500: components['responses']['500InternalServerError']; + }; + }; getBundles: { parameters: { query?: { From 08ee96dba3173e44eecfb1d4c4f0e8277b9249b2 Mon Sep 17 00:00:00 2001 From: netanelC Date: Thu, 13 Nov 2025 16:38:28 +0200 Subject: [PATCH 02/17] feat(client): resolved issues --- .../connections/CreateConnectionModal.tsx | 47 +++++-------------- .../pages/connections/EditConnectionModal.tsx | 2 +- 2 files changed, 13 insertions(+), 36 deletions(-) diff --git a/packages/auth-ui/src/pages/connections/CreateConnectionModal.tsx b/packages/auth-ui/src/pages/connections/CreateConnectionModal.tsx index d947de3f..0fb33b11 100644 --- a/packages/auth-ui/src/pages/connections/CreateConnectionModal.tsx +++ b/packages/auth-ui/src/pages/connections/CreateConnectionModal.tsx @@ -22,7 +22,6 @@ import { getAvailableSites } from '@/components/exports'; import { cn } from '../../lib/utils'; type Connection = components['schemas']['connection']; -type Client = components['schemas']['client']; type Domain = components['schemas']['domain']; type SiteResult = { @@ -79,46 +78,24 @@ export const CreateConnectionModal = ({ const [selectedSites, setSelectedSites] = useState([]); const [currentStep, setCurrentStep] = useState('create'); const [clientSearch, setClientSearch] = useState(''); - const [clients, setClients] = useState([]); - const [isLoadingClients, setIsLoadingClients] = useState(false); const [clientPopoverOpen, setClientPopoverOpen] = useState(false); const currentSite = localStorage.getItem('selectedSite') || ''; const otherSites = availableSites.filter((site) => site !== currentSite); - useEffect(() => { - const fetchClients = async () => { - setIsLoadingClients(true); - try { - const baseUrl = localStorage.getItem('currentBaseUrl') || 'http://localhost:8080/'; - const url = new URL('/client', baseUrl); - url.searchParams.set('page', '1'); - - if (clientSearch) { - url.searchParams.set('page_size', '50'); - url.searchParams.set('search', clientSearch); - } else { - url.searchParams.set('page_size', '100'); - } - - const response = await fetch(url.toString()); - - if (!response.ok) { - throw new Error('Failed to fetch clients'); - } + const clientQueryParams = { + page: 1, + page_size: clientSearch ? 50 : 100, + ...(clientSearch && { search: clientSearch }), + }; - const data = await response.json(); - setClients((data?.items || []) as Client[]); - } catch (error) { - console.error('Error fetching clients:', error); - setClients([]); - } finally { - setIsLoadingClients(false); - } - }; + const { data: clientsData, isLoading: isLoadingClients } = $api.useQuery('get', '/client', { + params: { + query: clientQueryParams, + }, + }); - fetchClients(); - }, [clientSearch]); + const clients = clientsData?.items || []; const { data: domains, isLoading: isLoadingDomains } = $api.useQuery('get', '/domain'); @@ -401,7 +378,7 @@ export const CreateConnectionModal = ({ render={({ field }) => (
- Origin Check + Skip Origin Check

{field.value ? 'Allow connections without origin validation' : 'Require origin validation'}

diff --git a/packages/auth-ui/src/pages/connections/EditConnectionModal.tsx b/packages/auth-ui/src/pages/connections/EditConnectionModal.tsx index caad06fc..0cba6a7b 100644 --- a/packages/auth-ui/src/pages/connections/EditConnectionModal.tsx +++ b/packages/auth-ui/src/pages/connections/EditConnectionModal.tsx @@ -397,7 +397,7 @@ export const EditConnectionModal = ({ render={({ field }) => (
- Origin Check + Skip Origin Check

{field.value ? 'Allow connections without origin validation' : 'Require origin validation'}

From fcbee3d579f12091d6f8cfd8a8272d9272f6ca28 Mon Sep 17 00:00:00 2001 From: netanelC Date: Thu, 13 Nov 2025 17:33:45 +0200 Subject: [PATCH 03/17] feat(client): dark thee, added url schema, fix loading --- packages/auth-ui/index.html | 9 ++ packages/auth-ui/src/App.tsx | 41 +++---- .../auth-ui/src/components/layout/Sidebar.tsx | 4 + .../auth-ui/src/components/theme-provider.tsx | 6 + .../auth-ui/src/components/theme-toggle.tsx | 29 +++++ .../auth-ui/src/pages/clients/ClientsPage.tsx | 104 +++++++++--------- .../src/pages/connections/ConnectionsPage.tsx | 18 ++- .../connections/CreateConnectionModal.tsx | 85 ++++++++++---- .../pages/connections/EditConnectionModal.tsx | 85 ++++++++++---- 9 files changed, 258 insertions(+), 123 deletions(-) create mode 100644 packages/auth-ui/src/components/theme-provider.tsx create mode 100644 packages/auth-ui/src/components/theme-toggle.tsx diff --git a/packages/auth-ui/index.html b/packages/auth-ui/index.html index 0e0e7392..5f80e4d2 100644 --- a/packages/auth-ui/index.html +++ b/packages/auth-ui/index.html @@ -5,6 +5,15 @@ Opala Auth UI +
diff --git a/packages/auth-ui/src/App.tsx b/packages/auth-ui/src/App.tsx index 5956d80f..ccf5970b 100644 --- a/packages/auth-ui/src/App.tsx +++ b/packages/auth-ui/src/App.tsx @@ -11,31 +11,34 @@ import { OPAValidatorPage } from './pages/opa-validator'; import { Toaster } from './components/ui/sonner'; import { ConfigProvider } from './contexts/ConfigProvider'; import { ErrorBoundary } from './hooks/useErrorBoundary'; +import { ThemeProvider } from './components/theme-provider'; const queryClient = new QueryClient(); function App() { return ( - - - - - }> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - } /> - - - - - + + + + + + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + } /> + + + + + + ); } diff --git a/packages/auth-ui/src/components/layout/Sidebar.tsx b/packages/auth-ui/src/components/layout/Sidebar.tsx index 0d1de802..d28cf39f 100644 --- a/packages/auth-ui/src/components/layout/Sidebar.tsx +++ b/packages/auth-ui/src/components/layout/Sidebar.tsx @@ -3,6 +3,7 @@ import { cn } from '../../lib/utils'; import { Users, Link as LinkIcon, Globe, Menu, X, Key, Shield } from 'lucide-react'; import { Button } from '../ui/button'; import { SiteSwitcher } from './SiteSwitcher'; +import { ThemeToggle } from '../theme-toggle'; interface SidebarProps { className?: string; @@ -74,6 +75,9 @@ export const Sidebar = ({ className, isCollapsed, onCollapse }: SidebarProps) => ); })} +
+ +
); }; diff --git a/packages/auth-ui/src/components/theme-provider.tsx b/packages/auth-ui/src/components/theme-provider.tsx new file mode 100644 index 00000000..93de41c7 --- /dev/null +++ b/packages/auth-ui/src/components/theme-provider.tsx @@ -0,0 +1,6 @@ +import { ThemeProvider as NextThemesProvider } from 'next-themes'; +import { type ThemeProviderProps } from 'next-themes'; + +export const ThemeProvider = ({ children, ...props }: ThemeProviderProps) => { + return {children}; +}; diff --git a/packages/auth-ui/src/components/theme-toggle.tsx b/packages/auth-ui/src/components/theme-toggle.tsx new file mode 100644 index 00000000..46c3e5d8 --- /dev/null +++ b/packages/auth-ui/src/components/theme-toggle.tsx @@ -0,0 +1,29 @@ +import { Moon, Sun } from 'lucide-react'; +import { useTheme } from 'next-themes'; +import { Button } from './ui/button'; +import { useEffect, useState } from 'react'; + +export const ThemeToggle = () => { + const { theme, setTheme } = useTheme(); + const [mounted, setMounted] = useState(false); + + useEffect(() => { + setMounted(true); + }, []); + + if (!mounted) { + return null; + } + + return ( + + ); +}; diff --git a/packages/auth-ui/src/pages/clients/ClientsPage.tsx b/packages/auth-ui/src/pages/clients/ClientsPage.tsx index 841d2cbe..765e91e9 100644 --- a/packages/auth-ui/src/pages/clients/ClientsPage.tsx +++ b/packages/auth-ui/src/pages/clients/ClientsPage.tsx @@ -516,14 +516,6 @@ export const ClientsPage = () => { const startItem = (page - 1) * pageSize + 1; const endItem = Math.min(page * pageSize, data?.total || 0); - if (isLoading) { - return ( -
- -
- ); - } - if (isError) { return (
@@ -743,52 +735,54 @@ export const ClientsPage = () => {
- {/* Branch Filter */} -
- - setSelectedBranch(e.target.value)} - onFocus={() => { - lastFocusedInput.current = 'branch'; - }} - className="max-w-md" - /> -
- - {/* Tags Filter */} -
- - {selectedTags.length > 0 && ( -
- {selectedTags.map((tag) => ( - - {tag} - - - ))} -
- )} -
+ {/* Branch and Tags Filters */} +
+ {/* Branch Filter */} +
+ setTagsInput(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter') { - e.preventDefault(); - addTag(); - } + ref={branchInputRef} + placeholder="Filter by branch..." + value={selectedBranch} + onChange={(e) => setSelectedBranch(e.target.value)} + onFocus={() => { + lastFocusedInput.current = 'branch'; }} - className="flex-1" /> - +
+ + {/* Tags Filter */} +
+ + {selectedTags.length > 0 && ( +
+ {selectedTags.map((tag) => ( + + {tag} + + + ))} +
+ )} +
+ setTagsInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + addTag(); + } + }} + className="flex-1" + /> + +
@@ -796,7 +790,13 @@ export const ClientsPage = () => {
- + {isLoading ? ( +
+ +
+ ) : ( + + )}
{/* Pagination Controls */} diff --git a/packages/auth-ui/src/pages/connections/ConnectionsPage.tsx b/packages/auth-ui/src/pages/connections/ConnectionsPage.tsx index 779c0ed7..17119860 100644 --- a/packages/auth-ui/src/pages/connections/ConnectionsPage.tsx +++ b/packages/auth-ui/src/pages/connections/ConnectionsPage.tsx @@ -71,7 +71,7 @@ const getURLParams = () => { isNoBrowser: params.get('isNoBrowser') ? params.get('isNoBrowser') === 'true' : undefined, isNoOrigin: params.get('isNoOrigin') ? params.get('isNoOrigin') === 'true' : undefined, searchTerm: params.get('search') || '', - latestOnly: params.get('latestOnly') ? params.get('latestOnly') === 'true' : storedLatestOnly === 'true', + latestOnly: params.get('latestOnly') ? params.get('latestOnly') === 'true' : storedLatestOnly !== null ? storedLatestOnly === 'true' : true, sort: params.get('sort') ? params .get('sort')! @@ -397,14 +397,6 @@ export const ConnectionsPage = () => { const startItem = (page - 1) * pageSize + 1; const endItem = Math.min(page * pageSize, data?.total || 0); - if (isLoading) { - return ( -
- -
- ); - } - if (isError) { return (
@@ -614,7 +606,13 @@ export const ConnectionsPage = () => {
- + {isLoading ? ( +
+ +
+ ) : ( + + )}
{/* Pagination Controls */} diff --git a/packages/auth-ui/src/pages/connections/CreateConnectionModal.tsx b/packages/auth-ui/src/pages/connections/CreateConnectionModal.tsx index 0fb33b11..81eb5968 100644 --- a/packages/auth-ui/src/pages/connections/CreateConnectionModal.tsx +++ b/packages/auth-ui/src/pages/connections/CreateConnectionModal.tsx @@ -44,6 +44,18 @@ interface CreateConnectionModalProps { type Step = 'create' | 'send'; +const urlSchema = z.string().refine( + (value) => { + try { + const url = new URL(value); + return url.protocol === 'http:' || url.protocol === 'https:'; + } catch { + return false; + } + }, + { message: 'Must be a valid HTTP or HTTPS URL' } +); + const formSchema = z.object({ name: z.string().min(1, 'Client name is required'), environment: z.string().min(1, 'Environment is required'), @@ -53,7 +65,7 @@ const formSchema = z.object({ domains: z.array(z.string()).min(1, 'At least one domain is required'), allowNoBrowserConnection: z.boolean(), allowNoOriginConnection: z.boolean(), - origins: z.array(z.string()), + origins: z.array(urlSchema), }); const availableSites = getAvailableSites(); @@ -72,6 +84,7 @@ export const CreateConnectionModal = ({ onStepChange, }: CreateConnectionModalProps) => { const [newOrigin, setNewOrigin] = useState(''); + const [originError, setOriginError] = useState(null); const [useToken, setUseToken] = useState(false); const [formError, setFormError] = useState(null); const [isSubmitting, setIsSubmitting] = useState(false); @@ -140,11 +153,32 @@ export const CreateConnectionModal = ({ }); const handleAddOrigin = () => { - if (newOrigin.trim() && !form.getValues('origins').includes(newOrigin.trim())) { - const currentOrigins = form.getValues('origins'); - form.setValue('origins', [...currentOrigins, newOrigin.trim()], { shouldDirty: true, shouldValidate: true }); - setNewOrigin(''); + const trimmedOrigin = newOrigin.trim(); + + if (!trimmedOrigin) { + return; + } + + try { + const url = new URL(trimmedOrigin); + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + setOriginError('Must be a valid HTTP or HTTPS URL'); + return; + } + } catch { + setOriginError('Must be a valid HTTP or HTTPS URL'); + return; + } + + if (form.getValues('origins').includes(trimmedOrigin)) { + setOriginError('This origin has already been added'); + return; } + + const currentOrigins = form.getValues('origins'); + form.setValue('origins', [...currentOrigins, trimmedOrigin], { shouldDirty: true, shouldValidate: true }); + setNewOrigin(''); + setOriginError(null); }; const handleRemoveOrigin = (originToRemove: string) => { @@ -441,22 +475,31 @@ export const CreateConnectionModal = ({ Origins
-
- setNewOrigin(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter') { - e.preventDefault(); - handleAddOrigin(); - } - }} - placeholder="Add an origin" - disabled={success || form.watch('allowNoOriginConnection')} - /> - +
+
+
+ { + setNewOrigin(e.target.value); + setOriginError(null); + }} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + handleAddOrigin(); + } + }} + placeholder="https://example.com" + disabled={success || form.watch('allowNoOriginConnection')} + className={originError ? 'border-destructive' : ''} + /> + {originError &&

{originError}

} +
+ +
{form.watch('origins').map((origin) => ( diff --git a/packages/auth-ui/src/pages/connections/EditConnectionModal.tsx b/packages/auth-ui/src/pages/connections/EditConnectionModal.tsx index 0cba6a7b..bd17e330 100644 --- a/packages/auth-ui/src/pages/connections/EditConnectionModal.tsx +++ b/packages/auth-ui/src/pages/connections/EditConnectionModal.tsx @@ -42,6 +42,18 @@ interface EditConnectionModalProps { type Step = 'edit' | 'send'; +const urlSchema = z.string().refine( + (value) => { + try { + const url = new URL(value); + return url.protocol === 'http:' || url.protocol === 'https:'; + } catch { + return false; + } + }, + { message: 'Must be a valid HTTP or HTTPS URL' } +); + const formSchema = z.object({ name: z.string().min(1, 'Client name is required'), environment: z.string().min(1, 'Environment is required'), @@ -51,7 +63,7 @@ const formSchema = z.object({ domains: z.array(z.string()).min(1, 'At least one domain is required'), allowNoBrowserConnection: z.boolean(), allowNoOriginConnection: z.boolean(), - origins: z.array(z.string()), + origins: z.array(urlSchema), createdAt: z.string().optional(), }); @@ -71,6 +83,7 @@ export const EditConnectionModal = ({ siteResults = [], }: EditConnectionModalProps) => { const [newOrigin, setNewOrigin] = useState(''); + const [originError, setOriginError] = useState(null); const [useToken, setUseToken] = useState(false); const [formError, setFormError] = useState(null); const [isSubmitting, setIsSubmitting] = useState(false); @@ -180,11 +193,32 @@ export const EditConnectionModal = ({ }, [form, originalValues, useToken]); const handleAddOrigin = () => { - if (newOrigin.trim() && !form.getValues('origins').includes(newOrigin.trim())) { - const currentOrigins = form.getValues('origins'); - form.setValue('origins', [...currentOrigins, newOrigin.trim()], { shouldDirty: true, shouldValidate: true }); - setNewOrigin(''); + const trimmedOrigin = newOrigin.trim(); + + if (!trimmedOrigin) { + return; } + + try { + const url = new URL(trimmedOrigin); + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + setOriginError('Must be a valid HTTP or HTTPS URL'); + return; + } + } catch { + setOriginError('Must be a valid HTTP or HTTPS URL'); + return; + } + + if (form.getValues('origins').includes(trimmedOrigin)) { + setOriginError('This origin has already been added'); + return; + } + + const currentOrigins = form.getValues('origins'); + form.setValue('origins', [...currentOrigins, trimmedOrigin], { shouldDirty: true, shouldValidate: true }); + setNewOrigin(''); + setOriginError(null); }; const handleRemoveOrigin = (originToRemove: string) => { @@ -470,22 +504,31 @@ export const EditConnectionModal = ({ Origins
-
- setNewOrigin(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter') { - e.preventDefault(); - handleAddOrigin(); - } - }} - placeholder="Add an origin" - disabled={success || form.watch('allowNoOriginConnection')} - /> - +
+
+
+ { + setNewOrigin(e.target.value); + setOriginError(null); + }} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + handleAddOrigin(); + } + }} + placeholder="https://example.com" + disabled={success || form.watch('allowNoOriginConnection')} + className={originError ? 'border-destructive' : ''} + /> + {originError &&

{originError}

} +
+ +
{form.watch('origins').map((origin) => ( From 0039fb567499e5ae761d5a2262c54184dc4374bb Mon Sep 17 00:00:00 2001 From: netanelC Date: Wed, 19 Nov 2025 08:33:45 +0200 Subject: [PATCH 04/17] refactor: rename "search" query param to "name" --- package-lock.json | 11 + packages/auth-manager/openapi3.yaml | 6 +- packages/auth-manager/package.json | 1 + .../client/controllers/clientController.ts | 21 +- .../auth-manager/src/client/models/client.ts | 23 +- .../src/client/models/clientManager.ts | 26 +-- .../controllers/connectionController.ts | 2 +- .../src/connection/models/connection.ts | 12 - .../connection/models/connectionManager.ts | 207 +++++++++--------- packages/auth-manager/src/containerConfig.ts | 6 +- packages/auth-manager/src/openapi.d.ts | 6 +- .../unit/client/models/clientManager.spec.ts | 4 +- .../models/connectionManager.spec.ts | 4 +- 13 files changed, 162 insertions(+), 167 deletions(-) delete mode 100644 packages/auth-manager/src/connection/models/connection.ts diff --git a/package-lock.json b/package-lock.json index d6592d66..b09b88a6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -34116,6 +34116,7 @@ "compression": "^1.7.4", "config": "^3.3.9", "cors": "^2.8.5", + "date-fns": "^4.1.0", "express": "^4.18.2", "express-openapi-validator": "^5.0.3", "http-status-codes": "^2.2.0", @@ -34191,6 +34192,16 @@ "undici-types": "~5.26.4" } }, + "packages/auth-manager/node_modules/date-fns": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, "packages/auth-manager/node_modules/reflect-metadata": { "version": "0.1.14", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.1.14.tgz", diff --git a/packages/auth-manager/openapi3.yaml b/packages/auth-manager/openapi3.yaml index 0174a7f4..42ac694e 100644 --- a/packages/auth-manager/openapi3.yaml +++ b/packages/auth-manager/openapi3.yaml @@ -15,7 +15,7 @@ paths: - client parameters: - in: query - name: search + name: name description: search by client name (partial match, case-insensitive) schema: type: string @@ -287,8 +287,8 @@ paths: - connection parameters: - in: query - name: search - description: search by connection name (partial match, case-insensitive) + name: name + description: search by client name (partial match, case-insensitive) schema: type: string - in: query diff --git a/packages/auth-manager/package.json b/packages/auth-manager/package.json index 691b3fb0..775aba32 100644 --- a/packages/auth-manager/package.json +++ b/packages/auth-manager/package.json @@ -52,6 +52,7 @@ "compression": "^1.7.4", "config": "^3.3.9", "cors": "^2.8.5", + "date-fns": "^4.1.0", "express": "^4.18.2", "express-openapi-validator": "^5.0.3", "http-status-codes": "^2.2.0", diff --git a/packages/auth-manager/src/client/controllers/clientController.ts b/packages/auth-manager/src/client/controllers/clientController.ts index 4528bd6d..edf50672 100644 --- a/packages/auth-manager/src/client/controllers/clientController.ts +++ b/packages/auth-manager/src/client/controllers/clientController.ts @@ -3,13 +3,14 @@ import { type Logger } from '@map-colonies/js-logger'; import httpStatus from 'http-status-codes'; import { injectable, inject } from 'tsyringe'; import { IClient } from '@map-colonies/auth-core'; +import { parseISO } from 'date-fns'; import type { TypedRequestHandlers, components, operations } from '@openapi'; import { SERVICES } from '@common/constants'; import { DEFAULT_PAGE_SIZE } from '@src/common/db/pagination'; import { sortOptionParser } from '@src/common/db/sort'; import { ClientManager } from '../models/clientManager'; import { ClientAlreadyExistsError, ClientNotFoundError } from '../models/errors'; -import { ClientSearchParams } from '../models/client'; +import { SearchParams } from '../models/client'; function responseClientToOpenApi(client: IClient): components['schemas']['client'] { return { @@ -19,16 +20,14 @@ function responseClientToOpenApi(client: IClient): components['schemas']['client }; } -function queryParamsToSearchParams(query: NonNullable): ClientSearchParams { - const { search, branch, tags, createdAfter, createdBefore, updatedAfter, updatedBefore } = query; +function queryParamsToSearchParams(query: NonNullable): SearchParams { + const { createdAfter, createdBefore, updatedAfter, updatedBefore, ...rest } = query; return { - search, - branch, - tags, - createdAfter: createdAfter !== undefined ? new Date(createdAfter) : undefined, - createdBefore: createdBefore !== undefined ? new Date(createdBefore) : undefined, - updatedAfter: updatedAfter !== undefined ? new Date(updatedAfter) : undefined, - updatedBefore: updatedBefore !== undefined ? new Date(updatedBefore) : undefined, + ...rest, + createdAfter: createdAfter !== undefined ? parseISO(createdAfter) : undefined, + createdBefore: createdBefore !== undefined ? parseISO(createdBefore) : undefined, + updatedAfter: updatedAfter !== undefined ? parseISO(updatedAfter) : undefined, + updatedBefore: updatedBefore !== undefined ? parseISO(updatedBefore) : undefined, }; } @@ -52,7 +51,7 @@ export class ClientController { public getClients: TypedRequestHandlers['getClients'] = async (req, res, next) => { try { this.logger.debug({ msg: 'executing #getClients handler', query: req.query }); - const searchParams = queryParamsToSearchParams(req.query as NonNullable); + const searchParams = queryParamsToSearchParams(req.query ?? {}); const paginationParams = { /* istanbul ignore next */ diff --git a/packages/auth-manager/src/client/models/client.ts b/packages/auth-manager/src/client/models/client.ts index e6cde68c..5af0246d 100644 --- a/packages/auth-manager/src/client/models/client.ts +++ b/packages/auth-manager/src/client/models/client.ts @@ -1,11 +1,14 @@ -import { IClient } from '@map-colonies/auth-core'; +import type { operations } from '@src/openapi'; -export interface ClientSearchParams { - search?: IClient['name']; - branch?: IClient['branch']; - createdBefore?: IClient['createdAt']; - createdAfter?: IClient['createdAt']; - updatedBefore?: IClient['createdAt']; - updatedAfter?: IClient['createdAt']; - tags?: IClient['tags']; -} +type QueryParamsWithoutDates = Omit< + NonNullable, + 'createdBefore' | 'createdAfter' | 'updatedBefore' | 'updatedAfter' +>; + +export type SearchParams = QueryParamsWithoutDates & { + /** The date fields converted to Date type */ + createdBefore?: Date; + createdAfter?: Date; + updatedBefore?: Date; + updatedAfter?: Date; +}; diff --git a/packages/auth-manager/src/client/models/clientManager.ts b/packages/auth-manager/src/client/models/clientManager.ts index aae9da5c..963fdb52 100644 --- a/packages/auth-manager/src/client/models/clientManager.ts +++ b/packages/auth-manager/src/client/models/clientManager.ts @@ -9,8 +9,8 @@ import { createDatesComparison } from '@common/db/utils'; import { SortOptions } from '@src/common/db/sort'; import { PaginationParams, paginationParamsToFindOptions } from '@src/common/db/pagination'; import { type ClientRepository } from '../DAL/clientRepository'; -import { ClientSearchParams } from './client'; import { ClientAlreadyExistsError, ClientNotFoundError } from './errors'; +import { SearchParams } from './client'; @injectable() export class ClientManager { @@ -20,7 +20,7 @@ export class ClientManager { ) {} public async getClients( - searchParams?: ClientSearchParams, + searchParams: SearchParams, paginationParams?: PaginationParams, sortParams?: SortOptions ): Promise<[IClient[], number]> { @@ -29,18 +29,16 @@ export class ClientManager { // eslint doesn't recognize this as valid because its in the type definition let findOptions: Parameters[0] = {}; - if (searchParams !== undefined) { - const { search, branch, tags, createdAfter, createdBefore, updatedAfter, updatedBefore } = searchParams; - findOptions = { - where: { - name: search !== undefined && search !== '' ? ILike(`%${search}%`) : undefined, - tags: tags ? ArrayContains(tags) : undefined, - branch, - createdAt: createDatesComparison(createdAfter, createdBefore), - updatedAt: createDatesComparison(updatedAfter, updatedBefore), - }, - }; - } + const { name, branch, tags, createdAfter, createdBefore, updatedAfter, updatedBefore } = searchParams; + findOptions = { + where: { + name: name !== undefined ? ILike(`%${name}%`) : undefined, + tags: tags ? ArrayContains(tags) : undefined, + branch, + createdAt: createDatesComparison(createdAfter, createdBefore), + updatedAt: createDatesComparison(updatedAfter, updatedBefore), + }, + }; if (paginationParams !== undefined) { findOptions = { diff --git a/packages/auth-manager/src/connection/controllers/connectionController.ts b/packages/auth-manager/src/connection/controllers/connectionController.ts index ba4dfc6c..c1a81c67 100644 --- a/packages/auth-manager/src/connection/controllers/connectionController.ts +++ b/packages/auth-manager/src/connection/controllers/connectionController.ts @@ -49,7 +49,7 @@ export class ConnectionController { const sortParams = sortOptionParser(req.query?.sort, connectionSortMap); try { - const [connections, count] = await this.manager.getConnections(req.query ?? {}, paginationParams, sortParams); + const [connections, count] = await this.manager.getConnections(req.query, paginationParams, sortParams); return res.status(httpStatus.OK).json({ total: count, items: connections.map(responseConnectionToOpenApi) }); } catch (error) { diff --git a/packages/auth-manager/src/connection/models/connection.ts b/packages/auth-manager/src/connection/models/connection.ts deleted file mode 100644 index d5d849af..00000000 --- a/packages/auth-manager/src/connection/models/connection.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Environments } from '@map-colonies/auth-core'; - -export interface ConnectionSearchParams { - search?: string; - latestOnly?: boolean; - environment?: Environments[]; - isEnabled?: boolean; - isNoBrowser?: boolean; - isNoOrigin?: boolean; - domains?: string[]; - name?: string; -} diff --git a/packages/auth-manager/src/connection/models/connectionManager.ts b/packages/auth-manager/src/connection/models/connectionManager.ts index 769c2f52..af3106bb 100644 --- a/packages/auth-manager/src/connection/models/connectionManager.ts +++ b/packages/auth-manager/src/connection/models/connectionManager.ts @@ -13,8 +13,8 @@ import { PaginationParams, paginationParamsToFindOptions } from '@src/common/db/ import { KeyNotFoundError } from '@key/models/errors'; import { SortOptions } from '@src/common/db/sort'; import { asteriskStringComparatorLast } from '@src/utils/utils'; +import { paths } from '@src/openapi'; import { type ConnectionRepository } from '../DAL/connectionRepository'; -import { ConnectionSearchParams } from './connection'; import { ConnectionVersionMismatchError, ConnectionNotFoundError } from './errors'; @injectable() @@ -27,25 +27,24 @@ export class ConnectionManager { ) {} public async getConnections( - searchParams: ConnectionSearchParams, + searchParams: paths['/connection']['get']['parameters']['query'], paginationParams?: PaginationParams, sortParams?: SortOptions ): Promise<[IConnection[], number]> { this.logger.info({ msg: 'fetching connections', searchParams }); - const { search, latestOnly, environment, domains, isEnabled, isNoBrowser, isNoOrigin, name } = searchParams; - if (latestOnly === true) { + if (searchParams?.latestOnly === true) { return this.getLatestConnections(searchParams, paginationParams, sortParams); } const findOptions: FindManyOptions = { where: { - name: search !== undefined && search !== '' ? ILike(`%${search}%`) : name, - environment: environment ? In(environment) : undefined, - allowNoBrowserConnection: isNoBrowser ?? undefined, - allowNoOriginConnection: isNoOrigin ?? undefined, - domains: domains ? ArrayContains(domains) : undefined, - enabled: isEnabled ?? undefined, + name: searchParams?.name !== undefined ? ILike(`%${searchParams.name}%`) : undefined, + environment: searchParams?.environment ? In(searchParams.environment) : undefined, + allowNoBrowserConnection: searchParams?.isNoBrowser ?? undefined, + allowNoOriginConnection: searchParams?.isNoOrigin ?? undefined, + domains: searchParams?.domains ? ArrayContains(searchParams.domains) : undefined, + enabled: searchParams?.isEnabled ?? undefined, }, }; @@ -60,101 +59,6 @@ export class ConnectionManager { return this.connectionRepository.findAndCount(findOptions); } - private async getLatestConnections( - searchParams: ConnectionSearchParams, - paginationParams?: PaginationParams, - sortParams?: SortOptions - ): Promise<[IConnection[], number]> { - const { search, environment, domains, isEnabled, isNoBrowser, isNoOrigin } = searchParams; - - const countQueryBuilder = this.connectionRepository.createQueryBuilder('connection'); - countQueryBuilder.select('COUNT(DISTINCT (connection.name, connection.environment))', 'count'); - - if (search !== undefined && search !== '') { - countQueryBuilder.andWhere('connection.name ILIKE :search', { search: `%${search}%` }); - } - - if (environment !== undefined) { - countQueryBuilder.andWhere('connection.environment IN (:...environment)', { environment }); - } - - if (isEnabled !== undefined) { - countQueryBuilder.andWhere('connection.enabled = :isEnabled', { isEnabled }); - } - - if (isNoBrowser !== undefined) { - countQueryBuilder.andWhere('connection.allowNoBrowserConnection = :isNoBrowser', { isNoBrowser }); - } - - if (isNoOrigin !== undefined) { - countQueryBuilder.andWhere('connection.allowNoOriginConnection = :isNoOrigin', { isNoOrigin }); - } - - if (domains !== undefined) { - countQueryBuilder.andWhere('connection.domains @> :domains', { domains }); - } - - const countResult = await countQueryBuilder.getRawOne<{ count: string }>(); - const total = parseInt(countResult?.count ?? '0', 10); - - const queryBuilder = this.connectionRepository.createQueryBuilder('connection'); - - queryBuilder.distinctOn(['connection.name', 'connection.environment']); - - const nameOrder = sortParams?.name ? (sortParams.name.toUpperCase() as 'ASC' | 'DESC') : 'ASC'; - const environmentOrder = sortParams?.environment ? (sortParams.environment.toUpperCase() as 'ASC' | 'DESC') : 'ASC'; - - queryBuilder.orderBy('connection.name', nameOrder).addOrderBy('connection.environment', environmentOrder); - - if (sortParams !== undefined && Object.keys(sortParams).length > 0) { - for (const [key, order] of Object.entries(sortParams)) { - if (key !== 'name' && key !== 'environment') { - queryBuilder.addOrderBy(`connection.${key}`, order.toUpperCase() as 'ASC' | 'DESC'); - } - } - } - - queryBuilder.addOrderBy('connection.version', 'DESC'); - - if (search !== undefined && search !== '') { - queryBuilder.andWhere('connection.name ILIKE :search', { search: `%${search}%` }); - } - - if (environment !== undefined) { - queryBuilder.andWhere('connection.environment IN (:...environment)', { environment }); - } - - if (isEnabled !== undefined) { - queryBuilder.andWhere('connection.enabled = :isEnabled', { isEnabled }); - } - - if (isNoBrowser !== undefined) { - queryBuilder.andWhere('connection.allowNoBrowserConnection = :isNoBrowser', { isNoBrowser }); - } - - if (isNoOrigin !== undefined) { - queryBuilder.andWhere('connection.allowNoOriginConnection = :isNoOrigin', { isNoOrigin }); - } - - if (domains !== undefined) { - queryBuilder.andWhere('connection.domains @> :domains', { domains }); - } - - if (paginationParams !== undefined) { - const { skip, take } = paginationParamsToFindOptions(paginationParams); - if (skip !== undefined) { - queryBuilder.skip(skip); - } - if (take !== undefined) { - queryBuilder.take(take); - } - } - - const results = await queryBuilder.getMany(); - - return [results, total]; - } - public async getConnection(name: string, environment: Environments, version: number): Promise { this.logger.info({ msg: 'fetching connection', connection: { name, version, environment } }); @@ -253,4 +157,97 @@ export class ConnectionManager { return ''; } } + + private async getLatestConnections( + searchParams: paths['/connection']['get']['parameters']['query'], + paginationParams?: PaginationParams, + sortParams?: SortOptions + ): Promise<[IConnection[], number]> { + const countQueryBuilder = this.connectionRepository.createQueryBuilder('connection'); + countQueryBuilder.select('COUNT(DISTINCT (connection.name, connection.environment))', 'count'); + + if (searchParams?.name !== undefined) { + countQueryBuilder.andWhere('connection.name ILIKE :name', { name: `%${searchParams.name}%` }); + } + + if (searchParams?.environment !== undefined) { + countQueryBuilder.andWhere('connection.environment IN (:...environment)', { environment: searchParams.environment }); + } + + if (searchParams?.isEnabled !== undefined) { + countQueryBuilder.andWhere('connection.enabled = :isEnabled', { isEnabled: searchParams.isEnabled }); + } + + if (searchParams?.isNoBrowser !== undefined) { + countQueryBuilder.andWhere('connection.allowNoBrowserConnection = :isNoBrowser', { isNoBrowser: searchParams.isNoBrowser }); + } + + if (searchParams?.isNoOrigin !== undefined) { + countQueryBuilder.andWhere('connection.allowNoOriginConnection = :isNoOrigin', { isNoOrigin: searchParams.isNoOrigin }); + } + + if (searchParams?.domains !== undefined) { + countQueryBuilder.andWhere('connection.domains @> :domains', { domains: searchParams.domains }); + } + + const countResult = await countQueryBuilder.getRawOne<{ count: string }>(); + const total = parseInt(countResult?.count ?? '0', 10); + + const queryBuilder = this.connectionRepository.createQueryBuilder('connection'); + + queryBuilder.distinctOn(['connection.name', 'connection.environment']); + + const nameOrder = sortParams?.name ? (sortParams.name.toUpperCase() as 'ASC' | 'DESC') : 'ASC'; + const environmentOrder = sortParams?.environment ? (sortParams.environment.toUpperCase() as 'ASC' | 'DESC') : 'ASC'; + + queryBuilder.orderBy('connection.name', nameOrder).addOrderBy('connection.environment', environmentOrder); + + if (sortParams !== undefined && Object.keys(sortParams).length > 0) { + for (const [key, order] of Object.entries(sortParams)) { + if (key !== 'name' && key !== 'environment') { + queryBuilder.addOrderBy(`connection.${key}`, order.toUpperCase() as 'ASC' | 'DESC'); + } + } + } + + queryBuilder.addOrderBy('connection.version', 'DESC'); + + if (searchParams?.name !== undefined) { + queryBuilder.andWhere('connection.name ILIKE :name', { name: `%${searchParams.name}%` }); + } + + if (searchParams?.environment !== undefined) { + queryBuilder.andWhere('connection.environment IN (:...environment)', { environment: searchParams.environment }); + } + + if (searchParams?.isEnabled !== undefined) { + queryBuilder.andWhere('connection.enabled = :isEnabled', { isEnabled: searchParams.isEnabled }); + } + + if (searchParams?.isNoBrowser !== undefined) { + queryBuilder.andWhere('connection.allowNoBrowserConnection = :isNoBrowser', { isNoBrowser: searchParams.isNoBrowser }); + } + + if (searchParams?.isNoOrigin !== undefined) { + queryBuilder.andWhere('connection.allowNoOriginConnection = :isNoOrigin', { isNoOrigin: searchParams.isNoOrigin }); + } + + if (searchParams?.domains !== undefined) { + queryBuilder.andWhere('connection.domains @> :domains', { domains: searchParams.domains }); + } + + if (paginationParams !== undefined) { + const { skip, take } = paginationParamsToFindOptions(paginationParams); + if (skip !== undefined) { + queryBuilder.skip(skip); + } + if (take !== undefined) { + queryBuilder.take(take); + } + } + + const results = await queryBuilder.getMany(); + + return [results, total]; + } } diff --git a/packages/auth-manager/src/containerConfig.ts b/packages/auth-manager/src/containerConfig.ts index 683f42ca..5f18a018 100644 --- a/packages/auth-manager/src/containerConfig.ts +++ b/packages/auth-manager/src/containerConfig.ts @@ -102,10 +102,8 @@ export const registerExternalValues = async (options?: RegisterOptions): Promise { token: 'onSignal', provider: { - useValue: { - useValue: async (): Promise => { - await Promise.all([getTracing().stop(), connection.destroy()]); - }, + useValue: async (): Promise => { + await Promise.all([getTracing().stop(), connection.destroy()]); }, }, }, diff --git a/packages/auth-manager/src/openapi.d.ts b/packages/auth-manager/src/openapi.d.ts index 49c13f37..60db75f7 100644 --- a/packages/auth-manager/src/openapi.d.ts +++ b/packages/auth-manager/src/openapi.d.ts @@ -534,7 +534,7 @@ export interface operations { parameters: { query?: { /** @description search by client name (partial match, case-insensitive) */ - search?: string; + name?: string; /** @description search by branch name */ branch?: string; /** @description filters all clients created before given date */ @@ -782,8 +782,8 @@ export interface operations { getConnections: { parameters: { query?: { - /** @description search by connection name (partial match, case-insensitive) */ - search?: string; + /** @description search by client name (partial match, case-insensitive) */ + name?: string; /** @description if true, returns only the latest version per (name, environment) pair */ latestOnly?: boolean; environment?: components['parameters']['environmentQueryParam']; diff --git a/packages/auth-manager/tests/unit/client/models/clientManager.spec.ts b/packages/auth-manager/tests/unit/client/models/clientManager.spec.ts index 9e366225..15b4c3b7 100644 --- a/packages/auth-manager/tests/unit/client/models/clientManager.spec.ts +++ b/packages/auth-manager/tests/unit/client/models/clientManager.spec.ts @@ -24,7 +24,7 @@ describe('ClientManager', () => { const client = getFakeClient(true); mockedRepository.findAndCount.mockResolvedValue([[client], 1]); - const domainPromise = clientManager.getClients(); + const domainPromise = clientManager.getClients({}); await expect(domainPromise).resolves.toStrictEqual([[client], 1]); }); @@ -32,7 +32,7 @@ describe('ClientManager', () => { it('should throw an error if thrown by the ORM', async function () { mockedRepository.findAndCount.mockRejectedValue(new Error()); - const domainPromise = clientManager.getClients(); + const domainPromise = clientManager.getClients({}); await expect(domainPromise).rejects.toThrow(); }); diff --git a/packages/auth-manager/tests/unit/connection/models/connectionManager.spec.ts b/packages/auth-manager/tests/unit/connection/models/connectionManager.spec.ts index 5259e093..4dd6951c 100644 --- a/packages/auth-manager/tests/unit/connection/models/connectionManager.spec.ts +++ b/packages/auth-manager/tests/unit/connection/models/connectionManager.spec.ts @@ -6,12 +6,12 @@ import { ConnectionNotFoundError, ConnectionVersionMismatchError } from '@src/co import { ConnectionRepository } from '@src/connection/DAL/connectionRepository'; import { getFakeConnection } from '@tests/utils/connection'; import { DomainRepository } from '@src/domain/DAL/domainRepository'; -import { ConnectionSearchParams } from '@src/connection/models/connection'; import { ClientNotFoundError } from '@src/client/models/errors'; import { DomainNotFoundError } from '@src/domain/models/errors'; import { KeyRepository } from '@src/key/DAL/keyRepository'; import { getRealKeys } from '@tests/utils/key'; import { KeyNotFoundError } from '@src/key/models/errors'; +import { operations } from '@src/openapi'; describe('ConnectionManager', () => { let connectionManager: ConnectionManager; @@ -47,7 +47,7 @@ describe('ConnectionManager', () => { ['isNoOrigin', true, 'allowNoOriginConnection'], ['domains', ['avi'], 'domains'], ['isEnabled', true, 'enabled'], - ] as [keyof ConnectionSearchParams, unknown, keyof FindOptionsWhere][])( + ] as [keyof operations['getConnections']['parameters']['query'], unknown, keyof FindOptionsWhere][])( 'should set the value of the param %s', async (inputName, inputValue, filterProperty) => { await connectionManager.getConnections({ [inputName]: inputValue }); From 16b63b0ddb7917f13c7378b0d0fc98119c2d0dc2 Mon Sep 17 00:00:00 2001 From: netanelC Date: Wed, 19 Nov 2025 09:42:27 +0200 Subject: [PATCH 05/17] test: add tests for name query param --- .../tests/integration/client/client.spec.ts | 39 +++++++++++++++++ .../integration/connection/connection.spec.ts | 43 +++++++++++++++++++ 2 files changed, 82 insertions(+) diff --git a/packages/auth-manager/tests/integration/client/client.spec.ts b/packages/auth-manager/tests/integration/client/client.spec.ts index c8a3411d..0000879b 100644 --- a/packages/auth-manager/tests/integration/client/client.spec.ts +++ b/packages/auth-manager/tests/integration/client/client.spec.ts @@ -57,6 +57,45 @@ describe('client', function () { expect(res.body.items).toIncludeAllPartialMembers(clients); }); + it.each([ + { name: 'avi', searchParam: 'avi', matchType: 'exact' }, + { name: 'bobavi', searchParam: 'avi', matchType: 'suffix' }, + { name: 'aviiiiii', searchParam: 'av', matchType: 'prefix' }, + { name: 'blaviabla', searchParam: 'avi', matchType: 'middle' }, + ])('type: $matchType - find the user $name with search string $searchParam', async function ({ name, searchParam }) { + const client = { ...getFakeClient(false), name }; + const connection = depContainer.resolve(DataSource); + await connection.getRepository(Client).insert(client); + + const res = await requestSender.getClients({ + queryParams: { + name: searchParam, + }, + }); + + expect(res).toHaveProperty('status', httpStatusCodes.OK); + expect(res).toSatisfyApiSpec(); + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + expect((res.body as Exclude).items).toSatisfyAny((item) => item.name === name); + }); + + it('should return empty array when no clients match the name search param', async function () { + const client = { ...getFakeClient(false), name: 'bla' }; + const connection = depContainer.resolve(DataSource); + await connection.getRepository(Client).insert(client); + + const res = await requestSender.getClients({ + queryParams: { + name: 'avi', + }, + }); + + expect(res).toHaveProperty('status', httpStatusCodes.OK); + expect(res).toSatisfyApiSpec(); + // @ts-expect-error need to solve as openapi-helpers is not typed correctly + expect(res.body.items).toBeArrayOfSize(0); + }); + it('should support filtering by dates', async function () { const clients = [ { ...getFakeClient(false), createdAt: new Date('2022-12-01') }, diff --git a/packages/auth-manager/tests/integration/connection/connection.spec.ts b/packages/auth-manager/tests/integration/connection/connection.spec.ts index 414c7bc0..b0b2cc4e 100644 --- a/packages/auth-manager/tests/integration/connection/connection.spec.ts +++ b/packages/auth-manager/tests/integration/connection/connection.spec.ts @@ -74,6 +74,49 @@ describe('connection', function () { expect(returnedItems).toBeArray(); }); + it.each([ + { name: 'avi', searchParam: 'avi', matchType: 'exact' }, + { name: 'bobavi', searchParam: 'avi', matchType: 'suffix' }, + { name: 'aviiiiii', searchParam: 'av', matchType: 'prefix' }, + { name: 'blaviabla', searchParam: 'avi', matchType: 'middle' }, + ])('type: $matchType - find the connection of $name with search string $searchParam', async function ({ name, searchParam }) { + const client = { ...getFakeClient(false), name }; + const connection = getFakeIConnection(); + connection.name = client.name; + await depContainer.resolve(DataSource).getRepository(Client).insert(client); + await requestSender.upsertConnection({ requestBody: connection }); + + const res = await requestSender.getConnections({ + queryParams: { + name: searchParam, + }, + }); + + expect(res).toHaveProperty('status', httpStatusCodes.OK); + expect(res).toSatisfyApiSpec(); + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + expect((res.body as Exclude).items).toSatisfyAny((item) => item.name === name); + }); + + it('should return empty array when no connections match the client name search param', async function () { + const client = { ...getFakeClient(false), name: 'bla' }; + const connection = getFakeIConnection(); + connection.name = client.name; + await depContainer.resolve(DataSource).getRepository(Client).insert(client); + await requestSender.upsertConnection({ requestBody: connection }); + + const res = await requestSender.getConnections({ + queryParams: { + name: 'avi', + }, + }); + + expect(res).toHaveProperty('status', httpStatusCodes.OK); + expect(res).toSatisfyApiSpec(); + // @ts-expect-error need to solve as openapi-helpers is not typed correctly + expect(res.body.items).toBeArrayOfSize(0); + }); + it('should return 200 status code and all the connections with specific env', async function () { const res = await requestSender.getConnections({ queryParams: { environment: [Environment.PRODUCTION] } }); From bb4439d5b2dff3c1087efbef1ad5b81fdc8b7174 Mon Sep 17 00:00:00 2001 From: netanelC Date: Wed, 19 Nov 2025 10:07:54 +0200 Subject: [PATCH 06/17] fix: latestOnly param --- packages/auth-manager/openapi3.yaml | 2 +- .../auth-manager/src/connection/models/connectionManager.ts | 2 +- packages/auth-manager/src/openapi.d.ts | 2 +- packages/auth-ui/src/pages/connections/ConnectionsPage.tsx | 4 ++-- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/auth-manager/openapi3.yaml b/packages/auth-manager/openapi3.yaml index 42ac694e..d31999c7 100644 --- a/packages/auth-manager/openapi3.yaml +++ b/packages/auth-manager/openapi3.yaml @@ -292,7 +292,7 @@ paths: schema: type: string - in: query - name: latestOnly + name: isLatestOnly description: if true, returns only the latest version per (name, environment) pair schema: type: boolean diff --git a/packages/auth-manager/src/connection/models/connectionManager.ts b/packages/auth-manager/src/connection/models/connectionManager.ts index af3106bb..d2a0cea9 100644 --- a/packages/auth-manager/src/connection/models/connectionManager.ts +++ b/packages/auth-manager/src/connection/models/connectionManager.ts @@ -33,7 +33,7 @@ export class ConnectionManager { ): Promise<[IConnection[], number]> { this.logger.info({ msg: 'fetching connections', searchParams }); - if (searchParams?.latestOnly === true) { + if (searchParams?.isLatestOnly === true) { return this.getLatestConnections(searchParams, paginationParams, sortParams); } diff --git a/packages/auth-manager/src/openapi.d.ts b/packages/auth-manager/src/openapi.d.ts index 60db75f7..4f4a5d03 100644 --- a/packages/auth-manager/src/openapi.d.ts +++ b/packages/auth-manager/src/openapi.d.ts @@ -785,7 +785,7 @@ export interface operations { /** @description search by client name (partial match, case-insensitive) */ name?: string; /** @description if true, returns only the latest version per (name, environment) pair */ - latestOnly?: boolean; + isLatestOnly?: boolean; environment?: components['parameters']['environmentQueryParam']; isEnabled?: boolean; isNoBrowser?: boolean; diff --git a/packages/auth-ui/src/pages/connections/ConnectionsPage.tsx b/packages/auth-ui/src/pages/connections/ConnectionsPage.tsx index 17119860..f46d0258 100644 --- a/packages/auth-ui/src/pages/connections/ConnectionsPage.tsx +++ b/packages/auth-ui/src/pages/connections/ConnectionsPage.tsx @@ -70,8 +70,8 @@ const getURLParams = () => { isEnabled: params.get('isEnabled') ? params.get('isEnabled') === 'true' : undefined, isNoBrowser: params.get('isNoBrowser') ? params.get('isNoBrowser') === 'true' : undefined, isNoOrigin: params.get('isNoOrigin') ? params.get('isNoOrigin') === 'true' : undefined, - searchTerm: params.get('search') || '', - latestOnly: params.get('latestOnly') ? params.get('latestOnly') === 'true' : storedLatestOnly !== null ? storedLatestOnly === 'true' : true, + searchTerm: params.get('name') || '', + latestOnly: params.get('isLatestOnly') ? params.get('isLatestOnly') === 'true' : storedLatestOnly !== null ? storedLatestOnly === 'true' : true, sort: params.get('sort') ? params .get('sort')! From 96301a9af89bb513cdcf2fb3699a24c98dce8062 Mon Sep 17 00:00:00 2001 From: netanelC Date: Wed, 19 Nov 2025 13:30:51 +0200 Subject: [PATCH 07/17] docs: add comments --- .../controllers/connectionController.ts | 2 +- .../connection/models/connectionManager.ts | 187 ++++++++---------- .../integration/connection/connection.spec.ts | 32 ++- .../models/connectionManager.spec.ts | 19 +- 4 files changed, 127 insertions(+), 113 deletions(-) diff --git a/packages/auth-manager/src/connection/controllers/connectionController.ts b/packages/auth-manager/src/connection/controllers/connectionController.ts index c1a81c67..ba4dfc6c 100644 --- a/packages/auth-manager/src/connection/controllers/connectionController.ts +++ b/packages/auth-manager/src/connection/controllers/connectionController.ts @@ -49,7 +49,7 @@ export class ConnectionController { const sortParams = sortOptionParser(req.query?.sort, connectionSortMap); try { - const [connections, count] = await this.manager.getConnections(req.query, paginationParams, sortParams); + const [connections, count] = await this.manager.getConnections(req.query ?? {}, paginationParams, sortParams); return res.status(httpStatus.OK).json({ total: count, items: connections.map(responseConnectionToOpenApi) }); } catch (error) { diff --git a/packages/auth-manager/src/connection/models/connectionManager.ts b/packages/auth-manager/src/connection/models/connectionManager.ts index d2a0cea9..d35f8dbf 100644 --- a/packages/auth-manager/src/connection/models/connectionManager.ts +++ b/packages/auth-manager/src/connection/models/connectionManager.ts @@ -1,7 +1,7 @@ import { type Logger } from '@map-colonies/js-logger'; import { Client, Connection, Environments, IConnection } from '@map-colonies/auth-core'; import { inject, injectable } from 'tsyringe'; -import { ArrayContains, FindManyOptions, In, ILike } from 'typeorm'; +import { SelectQueryBuilder } from 'typeorm'; import { JWK } from 'jose'; import { ClientNotFoundError } from '@client/models/errors'; import { SERVICES } from '@common/constants'; @@ -17,6 +17,8 @@ import { paths } from '@src/openapi'; import { type ConnectionRepository } from '../DAL/connectionRepository'; import { ConnectionVersionMismatchError, ConnectionNotFoundError } from './errors'; +type ConnectionSearchParams = NonNullable; + @injectable() export class ConnectionManager { public constructor( @@ -26,37 +28,77 @@ export class ConnectionManager { @inject(SERVICES.KEY_REPOSITORY) private readonly keyRepository: KeyRepository ) {} + /** + * Retrieves a paginated list of connections with optional filtering and sorting. + * * @remarks + * **Special Handling for `isLatestOnly`:** + * When `isLatestOnly` is true, this method uses Postgres `DISTINCT ON` to return + * only the most recent version of each connection (grouped by name + environment). + * This requires a specific sorting strategy and a custom count query. + * + * @returns A tuple containing the array of [Connections, TotalCount] + */ public async getConnections( - searchParams: paths['/connection']['get']['parameters']['query'], + searchParams: ConnectionSearchParams, paginationParams?: PaginationParams, sortParams?: SortOptions ): Promise<[IConnection[], number]> { this.logger.info({ msg: 'fetching connections', searchParams }); - if (searchParams?.isLatestOnly === true) { - return this.getLatestConnections(searchParams, paginationParams, sortParams); - } - - const findOptions: FindManyOptions = { - where: { - name: searchParams?.name !== undefined ? ILike(`%${searchParams.name}%`) : undefined, - environment: searchParams?.environment ? In(searchParams.environment) : undefined, - allowNoBrowserConnection: searchParams?.isNoBrowser ?? undefined, - allowNoOriginConnection: searchParams?.isNoOrigin ?? undefined, - domains: searchParams?.domains ? ArrayContains(searchParams.domains) : undefined, - enabled: searchParams?.isEnabled ?? undefined, - }, - }; - - if (paginationParams !== undefined) { - Object.assign(findOptions, paginationParamsToFindOptions(paginationParams)); + const qb = this.connectionRepository.createQueryBuilder('connection'); + + // 1. Apply Base Filters + this.applySearchFilters(qb, searchParams); + + // 2. Calculate Total Count + let total: number; + + if (searchParams.isLatestOnly!) { + // STRATEGY: Distinct Count + // Standard .getCount() returns total rows. We need total *unique clients and environments*. + // We clone the query to avoid modifying the main QB instance used for fetching data. + const countResult = await qb + .clone() + .select('COUNT(DISTINCT (connection.name, connection.environment))', 'count') + .getRawOne<{ count: string }>(); + + total = parseInt(countResult?.count ?? '0', 10); + } else { + // STRATEGY: Standard Count + total = await qb.getCount(); + } + + // 3. Apply Scope & Sorting + if (searchParams.isLatestOnly!) { + // STRATEGY: Postgres DISTINCT ON + // We group by name/env and keep the first row Postgres sees. + qb.distinctOn(['connection.name', 'connection.environment']); + + // REQUIREMENT: Postgres mandates that DISTINCT ON columns match the initial ORDER BY keys. + // We must apply the user's sort direction to these keys first. + const nameOrder = sortParams?.name?.toUpperCase() === 'DESC' ? 'DESC' : 'ASC'; + const envOrder = sortParams?.environment?.toUpperCase() === 'DESC' ? 'DESC' : 'ASC'; + + qb.orderBy('connection.name', nameOrder).addOrderBy('connection.environment', envOrder); + + // CRITICAL: The "Latest" Logic + // Within the unique (name, env) group, we force a sort by version DESC. + // Since DISTINCT ON picks the *first* row it encounters, this ensures the "Latest" version is picked. + qb.addOrderBy('connection.version', 'DESC'); + } else if (sortParams) { + Object.entries(sortParams).forEach(([key, order]) => { + qb.addOrderBy(`connection.${key}`, order.toUpperCase() as 'ASC' | 'DESC'); + }); } - if (sortParams !== undefined) { - findOptions.order = sortParams; + // 4. Pagination & Execution + if (paginationParams) { + const { skip, take } = paginationParamsToFindOptions(paginationParams); + qb.skip(skip).take(take); } - return this.connectionRepository.findAndCount(findOptions); + const connections = await qb.getMany(); + return [connections, total]; } public async getConnection(name: string, environment: Environments, version: number): Promise { @@ -158,96 +200,27 @@ export class ConnectionManager { } } - private async getLatestConnections( - searchParams: paths['/connection']['get']['parameters']['query'], - paginationParams?: PaginationParams, - sortParams?: SortOptions - ): Promise<[IConnection[], number]> { - const countQueryBuilder = this.connectionRepository.createQueryBuilder('connection'); - countQueryBuilder.select('COUNT(DISTINCT (connection.name, connection.environment))', 'count'); - - if (searchParams?.name !== undefined) { - countQueryBuilder.andWhere('connection.name ILIKE :name', { name: `%${searchParams.name}%` }); + /** + * Centralized filter logic to avoid duplication + */ + private applySearchFilters(qb: SelectQueryBuilder, params: ConnectionSearchParams): void { + if (params.name !== undefined) { + qb.andWhere('connection.name ILIKE :name', { name: `%${params.name}%` }); } - - if (searchParams?.environment !== undefined) { - countQueryBuilder.andWhere('connection.environment IN (:...environment)', { environment: searchParams.environment }); + if (params.environment) { + qb.andWhere('connection.environment IN (:...environment)', { environment: params.environment }); } - - if (searchParams?.isEnabled !== undefined) { - countQueryBuilder.andWhere('connection.enabled = :isEnabled', { isEnabled: searchParams.isEnabled }); + if (params.isNoBrowser !== undefined) { + qb.andWhere('connection.allowNoBrowserConnection = :isNoBrowser', { isNoBrowser: params.isNoBrowser }); } - - if (searchParams?.isNoBrowser !== undefined) { - countQueryBuilder.andWhere('connection.allowNoBrowserConnection = :isNoBrowser', { isNoBrowser: searchParams.isNoBrowser }); + if (params.isNoOrigin !== undefined) { + qb.andWhere('connection.allowNoOriginConnection = :isNoOrigin', { isNoOrigin: params.isNoOrigin }); } - - if (searchParams?.isNoOrigin !== undefined) { - countQueryBuilder.andWhere('connection.allowNoOriginConnection = :isNoOrigin', { isNoOrigin: searchParams.isNoOrigin }); + if (params.domains) { + qb.andWhere('connection.domains @> :domains', { domains: params.domains }); } - - if (searchParams?.domains !== undefined) { - countQueryBuilder.andWhere('connection.domains @> :domains', { domains: searchParams.domains }); + if (params.isEnabled !== undefined) { + qb.andWhere('connection.enabled = :isEnabled', { isEnabled: params.isEnabled }); } - - const countResult = await countQueryBuilder.getRawOne<{ count: string }>(); - const total = parseInt(countResult?.count ?? '0', 10); - - const queryBuilder = this.connectionRepository.createQueryBuilder('connection'); - - queryBuilder.distinctOn(['connection.name', 'connection.environment']); - - const nameOrder = sortParams?.name ? (sortParams.name.toUpperCase() as 'ASC' | 'DESC') : 'ASC'; - const environmentOrder = sortParams?.environment ? (sortParams.environment.toUpperCase() as 'ASC' | 'DESC') : 'ASC'; - - queryBuilder.orderBy('connection.name', nameOrder).addOrderBy('connection.environment', environmentOrder); - - if (sortParams !== undefined && Object.keys(sortParams).length > 0) { - for (const [key, order] of Object.entries(sortParams)) { - if (key !== 'name' && key !== 'environment') { - queryBuilder.addOrderBy(`connection.${key}`, order.toUpperCase() as 'ASC' | 'DESC'); - } - } - } - - queryBuilder.addOrderBy('connection.version', 'DESC'); - - if (searchParams?.name !== undefined) { - queryBuilder.andWhere('connection.name ILIKE :name', { name: `%${searchParams.name}%` }); - } - - if (searchParams?.environment !== undefined) { - queryBuilder.andWhere('connection.environment IN (:...environment)', { environment: searchParams.environment }); - } - - if (searchParams?.isEnabled !== undefined) { - queryBuilder.andWhere('connection.enabled = :isEnabled', { isEnabled: searchParams.isEnabled }); - } - - if (searchParams?.isNoBrowser !== undefined) { - queryBuilder.andWhere('connection.allowNoBrowserConnection = :isNoBrowser', { isNoBrowser: searchParams.isNoBrowser }); - } - - if (searchParams?.isNoOrigin !== undefined) { - queryBuilder.andWhere('connection.allowNoOriginConnection = :isNoOrigin', { isNoOrigin: searchParams.isNoOrigin }); - } - - if (searchParams?.domains !== undefined) { - queryBuilder.andWhere('connection.domains @> :domains', { domains: searchParams.domains }); - } - - if (paginationParams !== undefined) { - const { skip, take } = paginationParamsToFindOptions(paginationParams); - if (skip !== undefined) { - queryBuilder.skip(skip); - } - if (take !== undefined) { - queryBuilder.take(take); - } - } - - const results = await queryBuilder.getMany(); - - return [results, total]; } } diff --git a/packages/auth-manager/tests/integration/connection/connection.spec.ts b/packages/auth-manager/tests/integration/connection/connection.spec.ts index b0b2cc4e..ec4e21c4 100644 --- a/packages/auth-manager/tests/integration/connection/connection.spec.ts +++ b/packages/auth-manager/tests/integration/connection/connection.spec.ts @@ -117,6 +117,20 @@ describe('connection', function () { expect(res.body.items).toBeArrayOfSize(0); }); + it('should return only latest connections when the isLatestOnly param is true', async function () { + // There are 4 connections in the initialization, 3 of them are the latest versions + const res = await requestSender.getConnections({ + queryParams: { + isLatestOnly: true, + }, + }); + + expect(res).toHaveProperty('status', httpStatusCodes.OK); + expect(res).toSatisfyApiSpec(); + // @ts-expect-error need to solve as openapi-helpers is not typed correctly + expect(res.body.items).toBeArrayOfSize(3); + }); + it('should return 200 status code and all the connections with specific env', async function () { const res = await requestSender.getConnections({ queryParams: { environment: [Environment.PRODUCTION] } }); @@ -419,7 +433,11 @@ describe('connection', function () { describe('GET /connection', function () { it('should return 500 status code if db throws an error', async function () { const repo = depContainer.resolve(SERVICES.CONNECTION_REPOSITORY); - jest.spyOn(repo, 'findAndCount').mockRejectedValue(new Error()); + const qbMock = { + getMany: jest.fn().mockRejectedValue(new Error('DB Error')), + }; + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-explicit-any + jest.spyOn(repo, 'createQueryBuilder').mockReturnValue(qbMock as any); const res = await requestSender.getConnections(); @@ -459,7 +477,11 @@ describe('connection', function () { describe('GET /client/:clientName/connection', function () { it('should return 500 status code if db throws an error', async function () { const repo = depContainer.resolve(SERVICES.CONNECTION_REPOSITORY); - jest.spyOn(repo, 'findAndCount').mockRejectedValue(new Error()); + const qbMock = { + getMany: jest.fn().mockRejectedValue(new Error('DB Error')), + }; + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-explicit-any + jest.spyOn(repo, 'createQueryBuilder').mockReturnValue(qbMock as any); const res = await requestSender.getClientConnections({ pathParams: { clientName: 'avi' } }); @@ -471,7 +493,11 @@ describe('connection', function () { describe('GET /client/:clientName/connection/:environment', function () { it('should return 500 status code if db throws an error', async function () { const repo = depContainer.resolve(SERVICES.CONNECTION_REPOSITORY); - jest.spyOn(repo, 'findAndCount').mockRejectedValue(new Error()); + const qbMock = { + getMany: jest.fn().mockRejectedValue(new Error('DB Error')), + }; + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-explicit-any + jest.spyOn(repo, 'createQueryBuilder').mockReturnValue(qbMock as any); const res = await requestSender.getClientEnvironmentConnections({ pathParams: { clientName: 'avi', environment: Environment.NP } }); diff --git a/packages/auth-manager/tests/unit/connection/models/connectionManager.spec.ts b/packages/auth-manager/tests/unit/connection/models/connectionManager.spec.ts index 4dd6951c..2defa1d6 100644 --- a/packages/auth-manager/tests/unit/connection/models/connectionManager.spec.ts +++ b/packages/auth-manager/tests/unit/connection/models/connectionManager.spec.ts @@ -19,6 +19,7 @@ describe('ConnectionManager', () => { findAndCount: jest.fn, Parameters>(), findOne: jest.fn(), transaction: jest.fn(), + createQueryBuilder: jest.fn(), }; const mockedDomainRepository = {}; const mockedKeysRepository = {}; @@ -32,9 +33,23 @@ describe('ConnectionManager', () => { jest.resetAllMocks(); }); describe('#getConnections', () => { - it('should return the array of connections', async function () { + it.only('should return the array of connections', async function () { const connection = getFakeConnection(); - mockedConnectionRepository.findAndCount.mockResolvedValue([connection]); + const mockQb = { + // Chainable methods needed for the default path + select: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + addOrderBy: jest.fn().mockReturnThis(), + skip: jest.fn().mockReturnThis(), + take: jest.fn().mockReturnThis(), + clone: jest.fn().mockReturnThis(), + + // Terminators (The actual return values) + getMany: jest.fn().mockResolvedValue([connection]), + getCount: jest.fn().mockResolvedValue(1), + }; + mockedConnectionRepository.createQueryBuilder.mockReturnValue(mockQb); const connectionPromise = connectionManager.getConnections({}); From 26874a4d65e0c34b095fbe4d76fbbff678a1ed4f Mon Sep 17 00:00:00 2001 From: netanelC Date: Wed, 19 Nov 2025 13:39:34 +0200 Subject: [PATCH 08/17] test: remove broken unit tests --- .../models/connectionManager.spec.ts | 43 +------------------ 1 file changed, 1 insertion(+), 42 deletions(-) diff --git a/packages/auth-manager/tests/unit/connection/models/connectionManager.spec.ts b/packages/auth-manager/tests/unit/connection/models/connectionManager.spec.ts index 2defa1d6..0c766557 100644 --- a/packages/auth-manager/tests/unit/connection/models/connectionManager.spec.ts +++ b/packages/auth-manager/tests/unit/connection/models/connectionManager.spec.ts @@ -1,6 +1,5 @@ import jsLogger from '@map-colonies/js-logger'; -import { FindOptionsWhere } from 'typeorm'; -import { Connection, Environment } from '@map-colonies/auth-core'; +import { Environment } from '@map-colonies/auth-core'; import { ConnectionManager } from '@src/connection/models/connectionManager'; import { ConnectionNotFoundError, ConnectionVersionMismatchError } from '@src/connection/models/errors'; import { ConnectionRepository } from '@src/connection/DAL/connectionRepository'; @@ -11,7 +10,6 @@ import { DomainNotFoundError } from '@src/domain/models/errors'; import { KeyRepository } from '@src/key/DAL/keyRepository'; import { getRealKeys } from '@tests/utils/key'; import { KeyNotFoundError } from '@src/key/models/errors'; -import { operations } from '@src/openapi'; describe('ConnectionManager', () => { let connectionManager: ConnectionManager; @@ -33,45 +31,6 @@ describe('ConnectionManager', () => { jest.resetAllMocks(); }); describe('#getConnections', () => { - it.only('should return the array of connections', async function () { - const connection = getFakeConnection(); - const mockQb = { - // Chainable methods needed for the default path - select: jest.fn().mockReturnThis(), - andWhere: jest.fn().mockReturnThis(), - orderBy: jest.fn().mockReturnThis(), - addOrderBy: jest.fn().mockReturnThis(), - skip: jest.fn().mockReturnThis(), - take: jest.fn().mockReturnThis(), - clone: jest.fn().mockReturnThis(), - - // Terminators (The actual return values) - getMany: jest.fn().mockResolvedValue([connection]), - getCount: jest.fn().mockResolvedValue(1), - }; - mockedConnectionRepository.createQueryBuilder.mockReturnValue(mockQb); - - const connectionPromise = connectionManager.getConnections({}); - - await expect(connectionPromise).resolves.toStrictEqual([connection]); - }); - - it.each([ - ['environment', [Environment.NP], 'environment'], - ['isNoBrowser', true, 'allowNoBrowserConnection'], - ['isNoOrigin', true, 'allowNoOriginConnection'], - ['domains', ['avi'], 'domains'], - ['isEnabled', true, 'enabled'], - ] as [keyof operations['getConnections']['parameters']['query'], unknown, keyof FindOptionsWhere][])( - 'should set the value of the param %s', - async (inputName, inputValue, filterProperty) => { - await connectionManager.getConnections({ [inputName]: inputValue }); - - const call = mockedConnectionRepository.findAndCount.mock.calls[0]?.[0]; - expect(call?.where).toHaveProperty(filterProperty); - } - ); - it('should throw an error if one is thrown by the repository', async function () { mockedConnectionRepository.findAndCount.mockRejectedValue(new Error()); From f9384c9eb689fb3f46de3702d13423459dd4607c Mon Sep 17 00:00:00 2001 From: netanelC Date: Wed, 19 Nov 2025 14:01:13 +0200 Subject: [PATCH 09/17] test: beforeAll to beforeEach --- packages/auth-manager/tests/integration/client/client.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/auth-manager/tests/integration/client/client.spec.ts b/packages/auth-manager/tests/integration/client/client.spec.ts index 0000879b..d37b3c47 100644 --- a/packages/auth-manager/tests/integration/client/client.spec.ts +++ b/packages/auth-manager/tests/integration/client/client.spec.ts @@ -19,7 +19,7 @@ describe('client', function () { let requestSender: RequestSender; let depContainer: DependencyContainer; - beforeAll(async function () { + beforeEach(async function () { await initConfig(); const [app, container] = await getApp({ override: [ @@ -32,7 +32,7 @@ describe('client', function () { depContainer = container; }); - afterAll(async function () { + afterEach(async function () { await depContainer.resolve(DataSource).destroy(); }); From 6bacc83eb06826ba6012744a4fb44486a3c2e515 Mon Sep 17 00:00:00 2001 From: netanelC Date: Wed, 19 Nov 2025 14:11:56 +0200 Subject: [PATCH 10/17] test: revert --- packages/auth-manager/tests/integration/client/client.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/auth-manager/tests/integration/client/client.spec.ts b/packages/auth-manager/tests/integration/client/client.spec.ts index d37b3c47..0000879b 100644 --- a/packages/auth-manager/tests/integration/client/client.spec.ts +++ b/packages/auth-manager/tests/integration/client/client.spec.ts @@ -19,7 +19,7 @@ describe('client', function () { let requestSender: RequestSender; let depContainer: DependencyContainer; - beforeEach(async function () { + beforeAll(async function () { await initConfig(); const [app, container] = await getApp({ override: [ @@ -32,7 +32,7 @@ describe('client', function () { depContainer = container; }); - afterEach(async function () { + afterAll(async function () { await depContainer.resolve(DataSource).destroy(); }); From 8913db2b42c4a2fbc418cc30637b9a1d5f13cfdf Mon Sep 17 00:00:00 2001 From: netanelC Date: Wed, 19 Nov 2025 14:17:43 +0200 Subject: [PATCH 11/17] test: fix dirty database --- .../tests/integration/connection/connection.spec.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/auth-manager/tests/integration/connection/connection.spec.ts b/packages/auth-manager/tests/integration/connection/connection.spec.ts index ec4e21c4..9240f121 100644 --- a/packages/auth-manager/tests/integration/connection/connection.spec.ts +++ b/packages/auth-manager/tests/integration/connection/connection.spec.ts @@ -55,6 +55,8 @@ describe('connection', function () { afterEach(async function () { await depContainer.resolve(DataSource).getRepository(Connection).clear(); + await depContainer.resolve(DataSource).getRepository(Client).clear(); + await depContainer.resolve(DataSource).getRepository(Domain).clear(); }); afterAll(async function () { From 3a83f9c6f19bf3f5fd9974797c4263f634b031fe Mon Sep 17 00:00:00 2001 From: netanelC Date: Wed, 19 Nov 2025 17:10:46 +0200 Subject: [PATCH 12/17] test: add case-sensitive test --- packages/auth-manager/tests/integration/client/client.spec.ts | 1 + .../auth-manager/tests/integration/connection/connection.spec.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/packages/auth-manager/tests/integration/client/client.spec.ts b/packages/auth-manager/tests/integration/client/client.spec.ts index 0000879b..4e17d325 100644 --- a/packages/auth-manager/tests/integration/client/client.spec.ts +++ b/packages/auth-manager/tests/integration/client/client.spec.ts @@ -62,6 +62,7 @@ describe('client', function () { { name: 'bobavi', searchParam: 'avi', matchType: 'suffix' }, { name: 'aviiiiii', searchParam: 'av', matchType: 'prefix' }, { name: 'blaviabla', searchParam: 'avi', matchType: 'middle' }, + { name: 'avi', searchParam: 'AV', matchType: 'case-insensitive' }, ])('type: $matchType - find the user $name with search string $searchParam', async function ({ name, searchParam }) { const client = { ...getFakeClient(false), name }; const connection = depContainer.resolve(DataSource); diff --git a/packages/auth-manager/tests/integration/connection/connection.spec.ts b/packages/auth-manager/tests/integration/connection/connection.spec.ts index 9240f121..48987d7a 100644 --- a/packages/auth-manager/tests/integration/connection/connection.spec.ts +++ b/packages/auth-manager/tests/integration/connection/connection.spec.ts @@ -81,6 +81,7 @@ describe('connection', function () { { name: 'bobavi', searchParam: 'avi', matchType: 'suffix' }, { name: 'aviiiiii', searchParam: 'av', matchType: 'prefix' }, { name: 'blaviabla', searchParam: 'avi', matchType: 'middle' }, + { name: 'avi', searchParam: 'AV', matchType: 'case-insensitive' }, ])('type: $matchType - find the connection of $name with search string $searchParam', async function ({ name, searchParam }) { const client = { ...getFakeClient(false), name }; const connection = getFakeIConnection(); From 06dae8f0b3d87b2ff652cca8741db57737151542 Mon Sep 17 00:00:00 2001 From: netanelC Date: Wed, 19 Nov 2025 17:21:03 +0200 Subject: [PATCH 13/17] refactor: rename isLatestOnly to onlyLatest --- .redocly.yaml | 2 +- packages/auth-manager/openapi3.yaml | 2 +- .../src/connection/models/connectionManager.ts | 8 ++++---- packages/auth-manager/src/openapi.d.ts | 2 +- .../tests/integration/connection/connection.spec.ts | 4 ++-- .../auth-ui/src/pages/connections/ConnectionsPage.tsx | 2 +- 6 files changed, 10 insertions(+), 10 deletions(-) diff --git a/.redocly.yaml b/.redocly.yaml index b2ce3165..e8febf1f 100644 --- a/.redocly.yaml +++ b/.redocly.yaml @@ -22,7 +22,7 @@ preprocessors: {} rules: boolean-parameter-prefixes: severity: error - prefixes: ['should', 'is', 'has'] + prefixes: ['should', 'is', 'has', 'only'] no-unused-components: severity: error no-empty-servers: off diff --git a/packages/auth-manager/openapi3.yaml b/packages/auth-manager/openapi3.yaml index d31999c7..899046ce 100644 --- a/packages/auth-manager/openapi3.yaml +++ b/packages/auth-manager/openapi3.yaml @@ -292,7 +292,7 @@ paths: schema: type: string - in: query - name: isLatestOnly + name: onlyLatest description: if true, returns only the latest version per (name, environment) pair schema: type: boolean diff --git a/packages/auth-manager/src/connection/models/connectionManager.ts b/packages/auth-manager/src/connection/models/connectionManager.ts index d35f8dbf..a07f8dce 100644 --- a/packages/auth-manager/src/connection/models/connectionManager.ts +++ b/packages/auth-manager/src/connection/models/connectionManager.ts @@ -31,8 +31,8 @@ export class ConnectionManager { /** * Retrieves a paginated list of connections with optional filtering and sorting. * * @remarks - * **Special Handling for `isLatestOnly`:** - * When `isLatestOnly` is true, this method uses Postgres `DISTINCT ON` to return + * **Special Handling for `onlyLatest`:** + * When `onlyLatest` is true, this method uses Postgres `DISTINCT ON` to return * only the most recent version of each connection (grouped by name + environment). * This requires a specific sorting strategy and a custom count query. * @@ -53,7 +53,7 @@ export class ConnectionManager { // 2. Calculate Total Count let total: number; - if (searchParams.isLatestOnly!) { + if (searchParams.onlyLatest!) { // STRATEGY: Distinct Count // Standard .getCount() returns total rows. We need total *unique clients and environments*. // We clone the query to avoid modifying the main QB instance used for fetching data. @@ -69,7 +69,7 @@ export class ConnectionManager { } // 3. Apply Scope & Sorting - if (searchParams.isLatestOnly!) { + if (searchParams.onlyLatest!) { // STRATEGY: Postgres DISTINCT ON // We group by name/env and keep the first row Postgres sees. qb.distinctOn(['connection.name', 'connection.environment']); diff --git a/packages/auth-manager/src/openapi.d.ts b/packages/auth-manager/src/openapi.d.ts index 4f4a5d03..f8e47c66 100644 --- a/packages/auth-manager/src/openapi.d.ts +++ b/packages/auth-manager/src/openapi.d.ts @@ -785,7 +785,7 @@ export interface operations { /** @description search by client name (partial match, case-insensitive) */ name?: string; /** @description if true, returns only the latest version per (name, environment) pair */ - isLatestOnly?: boolean; + onlyLatest?: boolean; environment?: components['parameters']['environmentQueryParam']; isEnabled?: boolean; isNoBrowser?: boolean; diff --git a/packages/auth-manager/tests/integration/connection/connection.spec.ts b/packages/auth-manager/tests/integration/connection/connection.spec.ts index 48987d7a..1b9d6620 100644 --- a/packages/auth-manager/tests/integration/connection/connection.spec.ts +++ b/packages/auth-manager/tests/integration/connection/connection.spec.ts @@ -120,11 +120,11 @@ describe('connection', function () { expect(res.body.items).toBeArrayOfSize(0); }); - it('should return only latest connections when the isLatestOnly param is true', async function () { + it('should return only latest connections when the onlyLatest param is true', async function () { // There are 4 connections in the initialization, 3 of them are the latest versions const res = await requestSender.getConnections({ queryParams: { - isLatestOnly: true, + onlyLatest: true, }, }); diff --git a/packages/auth-ui/src/pages/connections/ConnectionsPage.tsx b/packages/auth-ui/src/pages/connections/ConnectionsPage.tsx index f46d0258..e9257946 100644 --- a/packages/auth-ui/src/pages/connections/ConnectionsPage.tsx +++ b/packages/auth-ui/src/pages/connections/ConnectionsPage.tsx @@ -71,7 +71,7 @@ const getURLParams = () => { isNoBrowser: params.get('isNoBrowser') ? params.get('isNoBrowser') === 'true' : undefined, isNoOrigin: params.get('isNoOrigin') ? params.get('isNoOrigin') === 'true' : undefined, searchTerm: params.get('name') || '', - latestOnly: params.get('isLatestOnly') ? params.get('isLatestOnly') === 'true' : storedLatestOnly !== null ? storedLatestOnly === 'true' : true, + latestOnly: params.get('onlyLatest') ? params.get('onlyLatest') === 'true' : storedLatestOnly !== null ? storedLatestOnly === 'true' : true, sort: params.get('sort') ? params .get('sort')! From 5f6bdd98cac45459246bf3130867ba070e5c3856 Mon Sep 17 00:00:00 2001 From: netanelC Date: Wed, 19 Nov 2025 17:22:39 +0200 Subject: [PATCH 14/17] refactor: rename SearchParam to ClientSearchParam --- .../auth-manager/src/client/controllers/clientController.ts | 4 ++-- packages/auth-manager/src/client/models/client.ts | 2 +- packages/auth-manager/src/client/models/clientManager.ts | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/auth-manager/src/client/controllers/clientController.ts b/packages/auth-manager/src/client/controllers/clientController.ts index edf50672..f01461bf 100644 --- a/packages/auth-manager/src/client/controllers/clientController.ts +++ b/packages/auth-manager/src/client/controllers/clientController.ts @@ -10,7 +10,7 @@ import { DEFAULT_PAGE_SIZE } from '@src/common/db/pagination'; import { sortOptionParser } from '@src/common/db/sort'; import { ClientManager } from '../models/clientManager'; import { ClientAlreadyExistsError, ClientNotFoundError } from '../models/errors'; -import { SearchParams } from '../models/client'; +import { ClientSearchParams } from '../models/client'; function responseClientToOpenApi(client: IClient): components['schemas']['client'] { return { @@ -20,7 +20,7 @@ function responseClientToOpenApi(client: IClient): components['schemas']['client }; } -function queryParamsToSearchParams(query: NonNullable): SearchParams { +function queryParamsToSearchParams(query: NonNullable): ClientSearchParams { const { createdAfter, createdBefore, updatedAfter, updatedBefore, ...rest } = query; return { ...rest, diff --git a/packages/auth-manager/src/client/models/client.ts b/packages/auth-manager/src/client/models/client.ts index 5af0246d..81f0be5e 100644 --- a/packages/auth-manager/src/client/models/client.ts +++ b/packages/auth-manager/src/client/models/client.ts @@ -5,7 +5,7 @@ type QueryParamsWithoutDates = Omit< 'createdBefore' | 'createdAfter' | 'updatedBefore' | 'updatedAfter' >; -export type SearchParams = QueryParamsWithoutDates & { +export type ClientSearchParams = QueryParamsWithoutDates & { /** The date fields converted to Date type */ createdBefore?: Date; createdAfter?: Date; diff --git a/packages/auth-manager/src/client/models/clientManager.ts b/packages/auth-manager/src/client/models/clientManager.ts index 963fdb52..677929f3 100644 --- a/packages/auth-manager/src/client/models/clientManager.ts +++ b/packages/auth-manager/src/client/models/clientManager.ts @@ -10,7 +10,7 @@ import { SortOptions } from '@src/common/db/sort'; import { PaginationParams, paginationParamsToFindOptions } from '@src/common/db/pagination'; import { type ClientRepository } from '../DAL/clientRepository'; import { ClientAlreadyExistsError, ClientNotFoundError } from './errors'; -import { SearchParams } from './client'; +import { ClientSearchParams } from './client'; @injectable() export class ClientManager { @@ -20,7 +20,7 @@ export class ClientManager { ) {} public async getClients( - searchParams: SearchParams, + searchParams: ClientSearchParams, paginationParams?: PaginationParams, sortParams?: SortOptions ): Promise<[IClient[], number]> { From 5e071a719617ccb61b98d0692a9665849fcb97d3 Mon Sep 17 00:00:00 2001 From: netanelC Date: Thu, 20 Nov 2025 07:41:44 +0200 Subject: [PATCH 15/17] test: add tests --- .../integration/connection/connection.spec.ts | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/packages/auth-manager/tests/integration/connection/connection.spec.ts b/packages/auth-manager/tests/integration/connection/connection.spec.ts index 1b9d6620..d352aec3 100644 --- a/packages/auth-manager/tests/integration/connection/connection.spec.ts +++ b/packages/auth-manager/tests/integration/connection/connection.spec.ts @@ -134,6 +134,50 @@ describe('connection', function () { expect(res.body.items).toBeArrayOfSize(3); }); + it('should return latest connections for multiple clients', async function () { + const client = { ...getFakeClient(false) }; + const connection = getFakeIConnection(); + connection.name = client.name; + await depContainer.resolve(DataSource).getRepository(Client).insert(client); + await requestSender.upsertConnection({ requestBody: connection }); + await requestSender.upsertConnection({ requestBody: connection }); + + const res = await requestSender.getConnections({ + queryParams: { + onlyLatest: true, + }, + }); + + expect(res).toHaveProperty('status', httpStatusCodes.OK); + expect(res).toSatisfyApiSpec(); + // @ts-expect-error need to solve as openapi-helpers is not typed correctly + expect(res.body.items).toBeArrayOfSize(4); + }); + + it('should return latest connections with multiple query params', async function () { + const client = { ...getFakeClient(false) }; + const connection = getFakeIConnection(); + connection.name = client.name; + await depContainer.resolve(DataSource).getRepository(Client).insert(client); + await requestSender.upsertConnection({ requestBody: connection }); + await requestSender.upsertConnection({ requestBody: connection }); + + const res = await requestSender.getConnections({ + queryParams: { + onlyLatest: true, + name: client.name, + domains: connection.domains, + isEnabled: connection.enabled, + sort: ['name:asc'], + }, + }); + + expect(res).toHaveProperty('status', httpStatusCodes.OK); + expect(res).toSatisfyApiSpec(); + // @ts-expect-error need to solve as openapi-helpers is not typed correctly + expect(res.body.items).toBeArrayOfSize(1); + }); + it('should return 200 status code and all the connections with specific env', async function () { const res = await requestSender.getConnections({ queryParams: { environment: [Environment.PRODUCTION] } }); From 45b9a6f61ad5e881c285350f9c024113b353160a Mon Sep 17 00:00:00 2001 From: netanelC Date: Thu, 20 Nov 2025 07:42:57 +0200 Subject: [PATCH 16/17] refactor: use generics --- packages/auth-manager/src/client/models/client.ts | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/packages/auth-manager/src/client/models/client.ts b/packages/auth-manager/src/client/models/client.ts index 81f0be5e..df09bb37 100644 --- a/packages/auth-manager/src/client/models/client.ts +++ b/packages/auth-manager/src/client/models/client.ts @@ -1,14 +1,6 @@ import type { operations } from '@src/openapi'; -type QueryParamsWithoutDates = Omit< - NonNullable, - 'createdBefore' | 'createdAfter' | 'updatedBefore' | 'updatedAfter' ->; +type DateKeys = 'createdBefore' | 'createdAfter' | 'updatedBefore' | 'updatedAfter'; -export type ClientSearchParams = QueryParamsWithoutDates & { - /** The date fields converted to Date type */ - createdBefore?: Date; - createdAfter?: Date; - updatedBefore?: Date; - updatedAfter?: Date; -}; +// Converts date string query parameters to Date objects +export type ClientSearchParams = Omit, DateKeys> & { [P in DateKeys]?: Date }; From fa2f4336b591d4ee19b4251434a50a2d637f8cba Mon Sep 17 00:00:00 2001 From: netanelC Date: Thu, 20 Nov 2025 08:24:24 +0200 Subject: [PATCH 17/17] fix: search param --- packages/auth-ui/src/pages/clients/ClientsPage.tsx | 14 +++++++------- .../src/pages/connections/ConnectionsPage.tsx | 8 ++++---- .../pages/connections/CreateConnectionModal.tsx | 2 +- packages/auth-ui/src/pages/domains/DomainsPage.tsx | 6 +++--- packages/auth-ui/src/types/schema.d.ts | 4 ++-- 5 files changed, 17 insertions(+), 17 deletions(-) diff --git a/packages/auth-ui/src/pages/clients/ClientsPage.tsx b/packages/auth-ui/src/pages/clients/ClientsPage.tsx index 765e91e9..b1dc7f27 100644 --- a/packages/auth-ui/src/pages/clients/ClientsPage.tsx +++ b/packages/auth-ui/src/pages/clients/ClientsPage.tsx @@ -30,7 +30,7 @@ type SiteResult = { }; type Filters = { - search?: string; + name?: string; branch?: string; createdBefore?: string; createdAfter?: string; @@ -68,7 +68,7 @@ const getURLParams = () => { return { page: parseInt(params.get('page') || '1', 10), pageSize: parseInt(params.get('pageSize') || '10', 10), - searchTerm: params.get('search') || '', + searchTerm: params.get('name') || '', branch: params.get('branch') || '', createdAfter: params.get('createdAfter') || '', createdBefore: params.get('createdBefore') || '', @@ -128,7 +128,7 @@ export const ClientsPage = () => { const searchInputRef = useRef(null); const branchInputRef = useRef(null); const wasLoading = useRef(false); - const lastFocusedInput = useRef<'search' | 'branch' | null>(null); + const lastFocusedInput = useRef<'name' | 'branch' | null>(null); const debouncedSearchTerm = useDebounce(searchTerm); const debouncedBranch = useDebounce(selectedBranch); @@ -138,7 +138,7 @@ export const ClientsPage = () => { updateURL({ page, pageSize, - search: searchTerm, + name: searchTerm, branch: selectedBranch, createdAfter: createdAfterDate?.toISOString() || '', createdBefore: createdBeforeDate?.toISOString() || '', @@ -245,7 +245,7 @@ export const ClientsPage = () => { const newFilters: Filters = {}; if (debouncedSearchTerm) { - newFilters.search = debouncedSearchTerm; + newFilters.name = debouncedSearchTerm; } if (debouncedBranch) { @@ -285,7 +285,7 @@ export const ClientsPage = () => { useEffect(() => { if (wasLoading.current && !isLoading) { - if (lastFocusedInput.current === 'search' && searchInputRef.current) { + if (lastFocusedInput.current === 'name' && searchInputRef.current) { searchInputRef.current.focus(); const length = searchInputRef.current.value.length; searchInputRef.current.setSelectionRange(length, length); @@ -553,7 +553,7 @@ export const ClientsPage = () => { value={searchTerm} onChange={(e) => setSearchTerm(e.target.value)} onFocus={() => { - lastFocusedInput.current = 'search'; + lastFocusedInput.current = 'name'; }} />
diff --git a/packages/auth-ui/src/pages/connections/ConnectionsPage.tsx b/packages/auth-ui/src/pages/connections/ConnectionsPage.tsx index e9257946..7d8db93a 100644 --- a/packages/auth-ui/src/pages/connections/ConnectionsPage.tsx +++ b/packages/auth-ui/src/pages/connections/ConnectionsPage.tsx @@ -132,7 +132,7 @@ export const ConnectionsPage = () => { isEnabled: isEnabled !== undefined ? isEnabled : '', isNoBrowser: isNoBrowser !== undefined ? isNoBrowser : '', isNoOrigin: isNoOrigin !== undefined ? isNoOrigin : '', - search: searchTerm, + name: searchTerm, latestOnly, sort: sortParams, showFilters: showAdvancedFilters, @@ -148,7 +148,7 @@ export const ConnectionsPage = () => { page, page_size: pageSize, sort: sort.map((s) => `${s.field}:${s.direction}`), - ...(debouncedSearchTerm && { search: debouncedSearchTerm }), + ...(debouncedSearchTerm && { name: debouncedSearchTerm }), ...(latestOnly && { latestOnly }), }; @@ -346,7 +346,7 @@ export const ConnectionsPage = () => { const removeFilter = (filterType: string) => { switch (filterType) { - case 'search': + case 'name': setSearchTerm(''); break; case 'environment': @@ -502,7 +502,7 @@ export const ConnectionsPage = () => { {searchTerm && ( Name: {searchTerm} - diff --git a/packages/auth-ui/src/pages/connections/CreateConnectionModal.tsx b/packages/auth-ui/src/pages/connections/CreateConnectionModal.tsx index 81eb5968..fd9aedcb 100644 --- a/packages/auth-ui/src/pages/connections/CreateConnectionModal.tsx +++ b/packages/auth-ui/src/pages/connections/CreateConnectionModal.tsx @@ -99,7 +99,7 @@ export const CreateConnectionModal = ({ const clientQueryParams = { page: 1, page_size: clientSearch ? 50 : 100, - ...(clientSearch && { search: clientSearch }), + ...(clientSearch && { name: clientSearch }), }; const { data: clientsData, isLoading: isLoadingClients } = $api.useQuery('get', '/client', { diff --git a/packages/auth-ui/src/pages/domains/DomainsPage.tsx b/packages/auth-ui/src/pages/domains/DomainsPage.tsx index 77ee51a9..fffeb7fa 100644 --- a/packages/auth-ui/src/pages/domains/DomainsPage.tsx +++ b/packages/auth-ui/src/pages/domains/DomainsPage.tsx @@ -51,7 +51,7 @@ const getURLParams = () => { return { page: parseInt(params.get('page') || '1', 10), pageSize: parseInt(params.get('pageSize') || '10', 10), - search: params.get('search') || '', + name: params.get('name') || '', sort: params.get('sort') ? params .get('sort')! @@ -77,7 +77,7 @@ export const DomainsPage = () => { const [currentCreateStep, setCurrentCreateStep] = useState<'create' | 'send'>('create'); const urlParams = getURLParams(); - const [searchTerm, setSearchTerm] = useState(urlParams.search); + const [searchTerm, setSearchTerm] = useState(urlParams.name); const [page, setPage] = useState(urlParams.page); const [pageSize, setPageSize] = useState(urlParams.pageSize); const [sort, setSort] = useState(urlParams.sort); @@ -91,7 +91,7 @@ export const DomainsPage = () => { updateURL({ page, pageSize, - search: searchTerm, + name: searchTerm, sort: sortParams, }); }, [page, pageSize, searchTerm, sort]); diff --git a/packages/auth-ui/src/types/schema.d.ts b/packages/auth-ui/src/types/schema.d.ts index f149ebc0..042b2285 100644 --- a/packages/auth-ui/src/types/schema.d.ts +++ b/packages/auth-ui/src/types/schema.d.ts @@ -537,7 +537,7 @@ export interface operations { parameters: { query?: { /** @description search by client name (partial match, case-insensitive) */ - search?: string; + name?: string; /** @description search by branch name */ branch?: string; /** @description filters all clients created before given date */ @@ -786,7 +786,7 @@ export interface operations { parameters: { query?: { /** @description search by connection name (partial match, case-insensitive) */ - search?: string; + name?: string; /** @description if true, returns only the latest version per (name, environment) pair */ latestOnly?: boolean; environment?: components['parameters']['environmentQueryParam'];