diff --git a/AGENTS.md b/AGENTS.md index 4dc8ed30..051bb826 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -92,6 +92,31 @@ field, so storage keys, cookie names, CSS class prefixes, state keys, etc. shoul in `@thunderid/javascript`, re-exported via `@thunderid/browser`/`@thunderid/node`) instead of hardcoding a literal or repeating an inline `vendor ?? 'thunderid'` fallback. That default belongs in one place. +## Adding or changing a component + +The [SDK development specification](https://github.com/thunder-id/thunderid/blob/main/docs-internals/sdk-development/spec.md) (UI) is the contract. In practice, every component: + +1. **Lives in its functional group:** `auth/` (sign-in, sign-up, recovery, invites, callback, with `buttons/`, `flow/` and `guards/` inside), `account/` (the signed-in user's own data), `admin/` (management), or `common/` (shared across groups, such as the language switcher and branding). Primitives and the data layer keep their own folders. +2. **Ships a styled variant and a `Base` variant.** `Base` takes its data and callbacks as parameters, with no SDK state, so an app can drive it itself (e.g. `BaseUserList`). +3. **Accepts a custom render on both variants.** Passing it replaces the built-in UI, while the component keeps its data loading, state and actions. The mechanism is the platform's own: + - React and Next.js: a `children` function, the same pattern as `SignIn`. + - Vue and Nuxt: the default scoped slot. + It receives everything needed to draw the component: rows, columns, paging (`limit`, `offset`, `totalResults`, page change) and selection for a list; values, setters, submit and validity for a form; the record and the actions the app has allowed for a details, members, assignments or permissions view; plus loading and error state. Behaviour the specification requires still holds under a custom render: data-changing actions are off unless the app turns them on, a delete asks for confirmation, and server errors appear as translated messages. +4. **Takes every string from the translation bundle**, in every shipped locale. +5. **Tags elements rendered from a server-described flow step** with `thunderid-field-` and `thunderid-action-`. +6. **Has tests** for the built-in UI and for a custom render (Vitest and Testing Library). +7. **Has a playground story,** including a **Custom rendering** variant drawn with the platform's own UI toolkit, and a capability check if it calls the server. + +A component that changes here usually needs the same change in the sibling SDKs. See the parity rules in the specification. + +## Samples + +The quickstarts and integration samples live in [thunder-id/samples](https://github.com/thunder-id/samples), and each one pins a released version of this SDK. A change here therefore can't break a sample until the sample bumps its version, which is exactly when a break would surface for a real app. Before raising a pull request, check every sample that uses this SDK: + +- **A sample stops compiling or behaving the same** (a renamed type or package, a changed signature, a moved default): raise a samples pull request that bumps the SDK version and fixes the sample. Keep it as a draft until the SDK release it needs is out. +- **The change adds something a sample exists to show** (a self-care capability for `self-care/account`, a management component or operation for `b2b/manage`, a new sign-in option for the quickstarts): update that sample in the same samples pull request, so it demonstrates the new capability rather than working around its absence. +- Link the samples pull request from this pull request's **Related PRs**, or say why no sample is affected. + ## Code style - TypeScript, ESLint via `@thunderid/eslint-plugin`, Prettier. @@ -116,3 +141,13 @@ packages/ playgrounds/ Playground apps that exercise the SDKs from source (not published) tests/e2e/ Playwright suite driving the quickstarts against a real ThunderID server ``` + +Inside a package, code is grouped by function, not by kind: + +- Components (`react`, `vue`, `nextjs`, `nuxt`): `auth/` (sign-in, sign-up, recovery, invites, callback, plus + `buttons/`, `flow/` for the flow renderers and `guards/` for `SignedIn`/`SignedOut`/`Loading`), `account/` (the + signed-in user's own components), `admin/` (management UI), `common/` (`Attribution`, `LanguageSwitcher`) and + `primitives/`. +- Data layer (`javascript/src/api`, `react/src/{api,hooks}`, `vue/src/{api,composables}`, `nextjs/src/server/actions`): + `auth/` for auth flows, `account/` for the `/users/me` endpoints, `management/` for the management APIs. Generic + helpers stay at the root of their folder. diff --git a/packages/express/src/ThunderIDExpressClient.ts b/packages/express/src/ThunderIDExpressClient.ts index 8200b7e0..0a3927a3 100644 --- a/packages/express/src/ThunderIDExpressClient.ts +++ b/packages/express/src/ThunderIDExpressClient.ts @@ -4,6 +4,8 @@ import { ThunderIDNodeClient, ThunderIDAuthException, + ThunderIDNodeManagementClient, + ThunderIDRuntimeError, Storage, TokenResponse, User, @@ -49,6 +51,31 @@ class ThunderIDExpressClient { + * const admin = req.thunderIDAuth.managementFromRequest(req); + * res.json(await admin.users.list({limit: 20})); + * }); + * ``` + * + * @throws {ThunderIDRuntimeError} `EXPRESS-CLIENT-MFR-NF01` when the request carries no session cookie. + */ + public managementFromRequest(req: express.Request): ThunderIDNodeManagementClient { + const cookies = req.cookies as Record | undefined; + const sessionId: string | undefined = cookies?.[this.getSessionCookieName()]; + + if (!sessionId) { + throw new ThunderIDRuntimeError('No session cookie found in the request.', 'EXPRESS-CLIENT-MFR-NF01', 'express'); + } + + return this.management(sessionId); + } + public async updateUserCredentialsFromRequest(req: express.Request, payload: Record): Promise { const cookies = req.cookies as Record | undefined; const sessionId: string | undefined = cookies?.[this.getSessionCookieName()]; diff --git a/packages/express/src/__tests__/ThunderIDExpressClient.test.ts b/packages/express/src/__tests__/ThunderIDExpressClient.test.ts index b35b60dd..da0121d9 100644 --- a/packages/express/src/__tests__/ThunderIDExpressClient.test.ts +++ b/packages/express/src/__tests__/ThunderIDExpressClient.test.ts @@ -30,3 +30,44 @@ describe('ThunderIDExpressClient', () => { }); }); }); + +describe('ThunderIDExpressClient managementFromRequest', () => { + it('binds the management client to the session cookie and sends that session token', async () => { + const fetchMock = vi + .fn() + .mockImplementation(() => + Promise.resolve( + new Response(JSON.stringify({id: 'u1'}), {headers: {'Content-Type': 'application/json'}, status: 200}), + ), + ); + vi.stubGlobal('fetch', fetchMock); + + try { + const client = new ThunderIDExpressClient(); + await client.initialize({baseUrl: 'https://auth.example.com', clientId: 'test-client'}); + const getAccessToken = vi.spyOn(client, 'getAccessToken').mockResolvedValue('session-token'); + + const req = {cookies: {[client.getSessionCookieName()]: 'session-cookie-123'}} as unknown as express.Request; + + await client.managementFromRequest(req).users.update('u1', {attributes: {name: 'A'}} as never); + + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(getAccessToken).toHaveBeenCalledWith('session-cookie-123'); + expect(url).toBe('https://auth.example.com/users/u1'); + expect(init.method).toBe('PUT'); + expect((init.headers as Record)['Authorization']).toBe('Bearer session-token'); + expect(JSON.parse(init.body as string)).toEqual({attributes: {name: 'A'}}); + } finally { + vi.unstubAllGlobals(); + } + }); + + it('throws a runtime error when the request has no session cookie', async () => { + const client = new ThunderIDExpressClient(); + await client.initialize({baseUrl: 'https://auth.example.com', clientId: 'test-client'}); + + expect(() => client.managementFromRequest({cookies: {}} as unknown as express.Request)).toThrowError( + expect.objectContaining({code: 'EXPRESS-CLIENT-MFR-NF01'}), + ); + }); +}); diff --git a/packages/javascript/src/api/__tests__/getUsersMe.test.ts b/packages/javascript/src/api/account/__tests__/getUsersMe.test.ts similarity index 98% rename from packages/javascript/src/api/__tests__/getUsersMe.test.ts rename to packages/javascript/src/api/account/__tests__/getUsersMe.test.ts index 97ef1d33..c561c57c 100644 --- a/packages/javascript/src/api/__tests__/getUsersMe.test.ts +++ b/packages/javascript/src/api/account/__tests__/getUsersMe.test.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import {describe, it, expect, vi} from 'vitest'; -import ThunderIDAPIError from '../../errors/ThunderIDAPIError'; +import ThunderIDAPIError from '../../../errors/ThunderIDAPIError'; import getUsersMe from '../getUsersMe'; // Mock user data diff --git a/packages/javascript/src/api/__tests__/getUsersMeMeta.test.ts b/packages/javascript/src/api/account/__tests__/getUsersMeMeta.test.ts similarity index 96% rename from packages/javascript/src/api/__tests__/getUsersMeMeta.test.ts rename to packages/javascript/src/api/account/__tests__/getUsersMeMeta.test.ts index 57bd4581..5b8f47ef 100644 --- a/packages/javascript/src/api/__tests__/getUsersMeMeta.test.ts +++ b/packages/javascript/src/api/account/__tests__/getUsersMeMeta.test.ts @@ -3,7 +3,7 @@ import {describe, it, expect, vi} from 'vitest'; import getUsersMeMeta from '../getUsersMeMeta'; -import ThunderIDAPIError from '../../errors/ThunderIDAPIError'; +import ThunderIDAPIError from '../../../errors/ThunderIDAPIError'; describe('getUsersMeMeta', () => { it('fetches user schema metadata successfully with custom fetcher', async () => { diff --git a/packages/javascript/src/api/__tests__/updateMeCredentials.test.ts b/packages/javascript/src/api/account/__tests__/updateMeCredentials.test.ts similarity index 98% rename from packages/javascript/src/api/__tests__/updateMeCredentials.test.ts rename to packages/javascript/src/api/account/__tests__/updateMeCredentials.test.ts index c62db758..7677d798 100644 --- a/packages/javascript/src/api/__tests__/updateMeCredentials.test.ts +++ b/packages/javascript/src/api/account/__tests__/updateMeCredentials.test.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import {Mock, beforeEach, describe, expect, it, vi} from 'vitest'; -import ThunderIDAPIError from '../../errors/ThunderIDAPIError'; +import ThunderIDAPIError from '../../../errors/ThunderIDAPIError'; import updateMeCredentials from '../updateMeCredentials'; describe('updateMeCredentials', (): void => { diff --git a/packages/javascript/src/api/__tests__/updateMeProfile.test.ts b/packages/javascript/src/api/account/__tests__/updateMeProfile.test.ts similarity index 98% rename from packages/javascript/src/api/__tests__/updateMeProfile.test.ts rename to packages/javascript/src/api/account/__tests__/updateMeProfile.test.ts index 50b87e7a..bfcb2cbd 100644 --- a/packages/javascript/src/api/__tests__/updateMeProfile.test.ts +++ b/packages/javascript/src/api/account/__tests__/updateMeProfile.test.ts @@ -2,8 +2,8 @@ // SPDX-License-Identifier: Apache-2.0 import {Mock, beforeEach, describe, expect, it, vi} from 'vitest'; -import ThunderIDAPIError from '../../errors/ThunderIDAPIError'; -import type {User} from '../../models/user'; +import ThunderIDAPIError from '../../../errors/ThunderIDAPIError'; +import type {User} from '../../../models/user'; import updateMeProfile from '../updateMeProfile'; describe('updateMeProfile', (): void => { diff --git a/packages/javascript/src/api/getUsersMe.ts b/packages/javascript/src/api/account/getUsersMe.ts similarity index 97% rename from packages/javascript/src/api/getUsersMe.ts rename to packages/javascript/src/api/account/getUsersMe.ts index feabaac1..dd975e47 100644 --- a/packages/javascript/src/api/getUsersMe.ts +++ b/packages/javascript/src/api/account/getUsersMe.ts @@ -1,8 +1,8 @@ // Copyright 2025 The ThunderID Authors // SPDX-License-Identifier: Apache-2.0 -import ThunderIDAPIError from '../errors/ThunderIDAPIError'; -import {User} from '../models/user'; +import ThunderIDAPIError from '../../errors/ThunderIDAPIError'; +import {User} from '../../models/user'; /** * Configuration for the getUsersMe request diff --git a/packages/javascript/src/api/getUsersMeMeta.ts b/packages/javascript/src/api/account/getUsersMeMeta.ts similarity index 97% rename from packages/javascript/src/api/getUsersMeMeta.ts rename to packages/javascript/src/api/account/getUsersMeMeta.ts index 0e90b03f..17d53d17 100644 --- a/packages/javascript/src/api/getUsersMeMeta.ts +++ b/packages/javascript/src/api/account/getUsersMeMeta.ts @@ -1,7 +1,7 @@ // Copyright 2025-2026 The ThunderID Authors // SPDX-License-Identifier: Apache-2.0 -import ThunderIDAPIError from '../errors/ThunderIDAPIError'; +import ThunderIDAPIError from '../../errors/ThunderIDAPIError'; /** * Attribute schema metadata returned by GET /users/me/meta diff --git a/packages/javascript/src/api/updateMeCredentials.ts b/packages/javascript/src/api/account/updateMeCredentials.ts similarity index 98% rename from packages/javascript/src/api/updateMeCredentials.ts rename to packages/javascript/src/api/account/updateMeCredentials.ts index 1c3ecfd0..619e55d7 100644 --- a/packages/javascript/src/api/updateMeCredentials.ts +++ b/packages/javascript/src/api/account/updateMeCredentials.ts @@ -1,7 +1,7 @@ // Copyright 2026 The ThunderID Authors // SPDX-License-Identifier: Apache-2.0 -import ThunderIDAPIError from '../errors/ThunderIDAPIError'; +import ThunderIDAPIError from '../../errors/ThunderIDAPIError'; /** * Configuration for the updateMeCredentials request diff --git a/packages/javascript/src/api/updateMeProfile.ts b/packages/javascript/src/api/account/updateMeProfile.ts similarity index 97% rename from packages/javascript/src/api/updateMeProfile.ts rename to packages/javascript/src/api/account/updateMeProfile.ts index 8695e5f9..a7618732 100644 --- a/packages/javascript/src/api/updateMeProfile.ts +++ b/packages/javascript/src/api/account/updateMeProfile.ts @@ -1,8 +1,8 @@ // Copyright 2025 The ThunderID Authors // SPDX-License-Identifier: Apache-2.0 -import ThunderIDAPIError from '../errors/ThunderIDAPIError'; -import {User} from '../models/user'; +import ThunderIDAPIError from '../../errors/ThunderIDAPIError'; +import {User} from '../../models/user'; /** * Configuration for the updateMeProfile request diff --git a/packages/javascript/src/api/__tests__/executeEmbeddedSignInFlow.test.ts b/packages/javascript/src/api/auth/__tests__/executeEmbeddedSignInFlow.test.ts similarity index 99% rename from packages/javascript/src/api/__tests__/executeEmbeddedSignInFlow.test.ts rename to packages/javascript/src/api/auth/__tests__/executeEmbeddedSignInFlow.test.ts index a421c989..f5886395 100644 --- a/packages/javascript/src/api/__tests__/executeEmbeddedSignInFlow.test.ts +++ b/packages/javascript/src/api/auth/__tests__/executeEmbeddedSignInFlow.test.ts @@ -2,8 +2,8 @@ // SPDX-License-Identifier: Apache-2.0 import {beforeEach, describe, expect, it, vi} from 'vitest'; -import {EmbeddedSignInFlowResponse, EmbeddedSignInFlowStatus} from '../../models/embedded-signin-flow'; -import logger from '../../utils/logger'; +import {EmbeddedSignInFlowResponse, EmbeddedSignInFlowStatus} from '../../../models/embedded-signin-flow'; +import logger from '../../../utils/logger'; import executeEmbeddedSignInFlow from '../executeEmbeddedSignInFlow'; const URL = 'https://localhost:8090/flow/execute'; diff --git a/packages/javascript/src/api/__tests__/executeEmbeddedSignUpFlow.test.ts b/packages/javascript/src/api/auth/__tests__/executeEmbeddedSignUpFlow.test.ts similarity index 99% rename from packages/javascript/src/api/__tests__/executeEmbeddedSignUpFlow.test.ts rename to packages/javascript/src/api/auth/__tests__/executeEmbeddedSignUpFlow.test.ts index 2444f877..e43197b3 100644 --- a/packages/javascript/src/api/__tests__/executeEmbeddedSignUpFlow.test.ts +++ b/packages/javascript/src/api/auth/__tests__/executeEmbeddedSignUpFlow.test.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import {beforeEach, describe, expect, it, vi} from 'vitest'; -import {EmbeddedSignUpFlowResponse, EmbeddedSignUpFlowStatus} from '../../models/embedded-signup-flow'; +import {EmbeddedSignUpFlowResponse, EmbeddedSignUpFlowStatus} from '../../../models/embedded-signup-flow'; import executeEmbeddedSignUpFlow from '../executeEmbeddedSignUpFlow'; const URL = 'https://localhost:8090/flow/execute'; diff --git a/packages/javascript/src/api/__tests__/getUserInfo.test.ts b/packages/javascript/src/api/auth/__tests__/getUserInfo.test.ts similarity index 98% rename from packages/javascript/src/api/__tests__/getUserInfo.test.ts rename to packages/javascript/src/api/auth/__tests__/getUserInfo.test.ts index 25de1596..024c69b0 100644 --- a/packages/javascript/src/api/__tests__/getUserInfo.test.ts +++ b/packages/javascript/src/api/auth/__tests__/getUserInfo.test.ts @@ -2,8 +2,8 @@ // SPDX-License-Identifier: Apache-2.0 import {describe, it, expect, vi, beforeEach} from 'vitest'; -import ThunderIDAPIError from '../../errors/ThunderIDAPIError'; -import {User} from '../../models/user'; +import ThunderIDAPIError from '../../../errors/ThunderIDAPIError'; +import {User} from '../../../models/user'; import getUserInfo from '../getUserInfo'; describe('getUserInfo', (): void => { diff --git a/packages/javascript/src/api/executeEmbeddedRecoveryFlow.ts b/packages/javascript/src/api/auth/executeEmbeddedRecoveryFlow.ts similarity index 95% rename from packages/javascript/src/api/executeEmbeddedRecoveryFlow.ts rename to packages/javascript/src/api/auth/executeEmbeddedRecoveryFlow.ts index 89ad6b3d..2d4f51b3 100644 --- a/packages/javascript/src/api/executeEmbeddedRecoveryFlow.ts +++ b/packages/javascript/src/api/auth/executeEmbeddedRecoveryFlow.ts @@ -1,9 +1,9 @@ // Copyright 2026 The ThunderID Authors // SPDX-License-Identifier: Apache-2.0 -import ThunderIDAPIError from '../errors/ThunderIDAPIError'; -import {EmbeddedFlowExecuteRequestConfig as EmbeddedFlowExecuteRequestConfig} from '../models/embedded-flow'; -import {EmbeddedRecoveryFlowResponse} from '../models/embedded-recovery-flow'; +import ThunderIDAPIError from '../../errors/ThunderIDAPIError'; +import {EmbeddedFlowExecuteRequestConfig as EmbeddedFlowExecuteRequestConfig} from '../../models/embedded-flow'; +import {EmbeddedRecoveryFlowResponse} from '../../models/embedded-recovery-flow'; /** * Executes an embedded recovery flow by sending a request to the flow execution endpoint. diff --git a/packages/javascript/src/api/executeEmbeddedSignInFlow.ts b/packages/javascript/src/api/auth/executeEmbeddedSignInFlow.ts similarity index 95% rename from packages/javascript/src/api/executeEmbeddedSignInFlow.ts rename to packages/javascript/src/api/auth/executeEmbeddedSignInFlow.ts index 3e5d9c88..71a41228 100644 --- a/packages/javascript/src/api/executeEmbeddedSignInFlow.ts +++ b/packages/javascript/src/api/auth/executeEmbeddedSignInFlow.ts @@ -1,11 +1,11 @@ // Copyright 2025-2026 The ThunderID Authors // SPDX-License-Identifier: Apache-2.0 -import ThunderIDAPIError from '../errors/ThunderIDAPIError'; -import {EmbeddedFlowExecuteRequestConfig} from '../models/embedded-flow'; -import {EmbeddedSignInFlowResponse, EmbeddedSignInFlowStatus} from '../models/embedded-signin-flow'; -import injectRequestedPermissions from '../utils/injectRequestedPermissions'; -import logger from '../utils/logger'; +import ThunderIDAPIError from '../../errors/ThunderIDAPIError'; +import {EmbeddedFlowExecuteRequestConfig} from '../../models/embedded-flow'; +import {EmbeddedSignInFlowResponse, EmbeddedSignInFlowStatus} from '../../models/embedded-signin-flow'; +import injectRequestedPermissions from '../../utils/injectRequestedPermissions'; +import logger from '../../utils/logger'; const readErrorAssertion = (body: string): string | undefined => { try { diff --git a/packages/javascript/src/api/executeEmbeddedSignUpFlow.ts b/packages/javascript/src/api/auth/executeEmbeddedSignUpFlow.ts similarity index 95% rename from packages/javascript/src/api/executeEmbeddedSignUpFlow.ts rename to packages/javascript/src/api/auth/executeEmbeddedSignUpFlow.ts index e96a727d..f643193a 100644 --- a/packages/javascript/src/api/executeEmbeddedSignUpFlow.ts +++ b/packages/javascript/src/api/auth/executeEmbeddedSignUpFlow.ts @@ -1,10 +1,10 @@ // Copyright 2025-2026 The ThunderID Authors // SPDX-License-Identifier: Apache-2.0 -import ThunderIDAPIError from '../errors/ThunderIDAPIError'; -import {EmbeddedFlowExecuteRequestConfig as EmbeddedFlowExecuteRequestConfig} from '../models/embedded-flow'; -import {EmbeddedSignUpFlowResponse, EmbeddedSignUpFlowStatus} from '../models/embedded-signup-flow'; -import injectRequestedPermissions from '../utils/injectRequestedPermissions'; +import ThunderIDAPIError from '../../errors/ThunderIDAPIError'; +import {EmbeddedFlowExecuteRequestConfig as EmbeddedFlowExecuteRequestConfig} from '../../models/embedded-flow'; +import {EmbeddedSignUpFlowResponse, EmbeddedSignUpFlowStatus} from '../../models/embedded-signup-flow'; +import injectRequestedPermissions from '../../utils/injectRequestedPermissions'; const executeEmbeddedSignUpFlow = async ({ url, diff --git a/packages/javascript/src/api/executeEmbeddedUserOnboardingFlow.ts b/packages/javascript/src/api/auth/executeEmbeddedUserOnboardingFlow.ts similarity index 97% rename from packages/javascript/src/api/executeEmbeddedUserOnboardingFlow.ts rename to packages/javascript/src/api/auth/executeEmbeddedUserOnboardingFlow.ts index bc306189..a79e6259 100644 --- a/packages/javascript/src/api/executeEmbeddedUserOnboardingFlow.ts +++ b/packages/javascript/src/api/auth/executeEmbeddedUserOnboardingFlow.ts @@ -1,8 +1,8 @@ // Copyright 2025-2026 The ThunderID Authors // SPDX-License-Identifier: Apache-2.0 -import ThunderIDAPIError from '../errors/ThunderIDAPIError'; -import {EmbeddedFlowType, EmbeddedFlowExecuteRequestConfig, FlowExecutionError} from '../models/embedded-flow'; +import ThunderIDAPIError from '../../errors/ThunderIDAPIError'; +import {EmbeddedFlowType, EmbeddedFlowExecuteRequestConfig, FlowExecutionError} from '../../models/embedded-flow'; /** * Response from the user onboarding flow execution. diff --git a/packages/javascript/src/api/flows/getFlows.ts b/packages/javascript/src/api/auth/flows/getFlows.ts similarity index 87% rename from packages/javascript/src/api/flows/getFlows.ts rename to packages/javascript/src/api/auth/flows/getFlows.ts index d31d18d4..002acaca 100644 --- a/packages/javascript/src/api/flows/getFlows.ts +++ b/packages/javascript/src/api/auth/flows/getFlows.ts @@ -1,9 +1,9 @@ // Copyright 2026 The ThunderID Authors // SPDX-License-Identifier: Apache-2.0 -import {ApiFilteringParams, ManagementRequestConfig} from '../../models/api'; -import {ManagedFlowListResponse} from '../../models/managed-flow'; -import requestManagementResource from '../../utils/requestManagementResource'; +import {ApiFilteringParams, ManagementRequestConfig} from '../../../models/api'; +import {ManagedFlowListResponse} from '../../../models/managed-flow'; +import requestManagementResource from '../../../utils/requestManagementResource'; /** * Configuration for the getFlows request. diff --git a/packages/javascript/src/api/getFlowMeta.ts b/packages/javascript/src/api/auth/getFlowMeta.ts similarity index 94% rename from packages/javascript/src/api/getFlowMeta.ts rename to packages/javascript/src/api/auth/getFlowMeta.ts index 0a78047c..402b4143 100644 --- a/packages/javascript/src/api/getFlowMeta.ts +++ b/packages/javascript/src/api/auth/getFlowMeta.ts @@ -1,8 +1,8 @@ // Copyright 2026 The ThunderID Authors // SPDX-License-Identifier: Apache-2.0 -import ThunderIDAPIError from '../errors/ThunderIDAPIError'; -import {FlowMetadataResponse, GetFlowMetaRequestConfig} from '../models/flow-meta'; +import ThunderIDAPIError from '../../errors/ThunderIDAPIError'; +import {FlowMetadataResponse, GetFlowMetaRequestConfig} from '../../models/flow-meta'; /** * Fetches aggregated flow metadata from the `GET /flow/meta` endpoint. @@ -22,7 +22,7 @@ import {FlowMetadataResponse, GetFlowMetaRequestConfig} from '../models/flow-met * * @example * ```typescript - * import getFlowMeta from './api/getFlowMeta'; + * import getFlowMeta from './api/auth/getFlowMeta'; * import { FlowMetaType } from './models/flow-meta'; * * const meta = await getFlowMeta({ diff --git a/packages/javascript/src/api/getUserInfo.ts b/packages/javascript/src/api/auth/getUserInfo.ts similarity index 94% rename from packages/javascript/src/api/getUserInfo.ts rename to packages/javascript/src/api/auth/getUserInfo.ts index ebd0d13d..87120ffb 100644 --- a/packages/javascript/src/api/getUserInfo.ts +++ b/packages/javascript/src/api/auth/getUserInfo.ts @@ -1,8 +1,8 @@ // Copyright 2025 The ThunderID Authors // SPDX-License-Identifier: Apache-2.0 -import ThunderIDAPIError from '../errors/ThunderIDAPIError'; -import {User} from '../models/user'; +import ThunderIDAPIError from '../../errors/ThunderIDAPIError'; +import {User} from '../../models/user'; /** * Retrieves the user information from the specified OIDC userinfo endpoint. diff --git a/packages/javascript/src/api/__tests__/managementApi.test.ts b/packages/javascript/src/api/management/__tests__/managementApi.test.ts similarity index 98% rename from packages/javascript/src/api/__tests__/managementApi.test.ts rename to packages/javascript/src/api/management/__tests__/managementApi.test.ts index 10c61ef7..a747f0bf 100644 --- a/packages/javascript/src/api/__tests__/managementApi.test.ts +++ b/packages/javascript/src/api/management/__tests__/managementApi.test.ts @@ -2,7 +2,8 @@ // SPDX-License-Identifier: Apache-2.0 import {describe, it, expect, vi, type Mock} from 'vitest'; -import {ApiFetcher} from '../../models/api'; +import {ApiFetcher} from '../../../models/api'; +import getFlows from '../../auth/flows/getFlows'; import createAgent from '../agents/createAgent'; import deleteAgent from '../agents/deleteAgent'; import getAgent from '../agents/getAgent'; @@ -13,7 +14,6 @@ import deleteApplication from '../applications/deleteApplication'; import getApplication from '../applications/getApplication'; import getApplications from '../applications/getApplications'; import updateApplication from '../applications/updateApplication'; -import getFlows from '../flows/getFlows'; import getUserTypes from '../user-types/getUserTypes'; import createUser from '../users/createUser'; import deleteUser from '../users/deleteUser'; diff --git a/packages/javascript/src/api/management/__tests__/rolesAndGroupsApi.test.ts b/packages/javascript/src/api/management/__tests__/rolesAndGroupsApi.test.ts new file mode 100644 index 00000000..11fd797a --- /dev/null +++ b/packages/javascript/src/api/management/__tests__/rolesAndGroupsApi.test.ts @@ -0,0 +1,218 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {describe, it, expect, vi, type Mock} from 'vitest'; +import {ApiFetcher} from '../../../models/api'; +import getAgentGroups from '../agents/getAgentGroups'; +import getAgentRoles from '../agents/getAgentRoles'; +import getAgentTypes from '../agents/getAgentTypes'; +import addGroupMembers from '../groups/addGroupMembers'; +import createGroup from '../groups/createGroup'; +import deleteGroup from '../groups/deleteGroup'; +import getGroup from '../groups/getGroup'; +import getGroupMembers from '../groups/getGroupMembers'; +import getGroups from '../groups/getGroups'; +import removeGroupMembers from '../groups/removeGroupMembers'; +import updateGroup from '../groups/updateGroup'; +import addRoleAssignments from '../roles/addRoleAssignments'; +import createRole from '../roles/createRole'; +import deleteRole from '../roles/deleteRole'; +import getRole from '../roles/getRole'; +import getRoleAssignments from '../roles/getRoleAssignments'; +import getRoles from '../roles/getRoles'; +import removeRoleAssignments from '../roles/removeRoleAssignments'; +import updateRole from '../roles/updateRole'; +import getUserGroups from '../users/getUserGroups'; + +type FetcherMock = Mock; +type Run = (fetcher: ApiFetcher) => Promise; + +interface SentRequest { + body: BodyInit | null | undefined; + method: string | undefined; + url: string; +} + +const baseUrl = 'https://localhost:8090'; + +const createFetcher = (body: unknown = {}, status = 200): FetcherMock => + vi.fn().mockResolvedValue({ + json: () => Promise.resolve(body), + ok: true, + status, + statusText: 'OK', + text: () => Promise.resolve(JSON.stringify(body)), + } as Response); + +const sentRequest = (fetcher: FetcherMock): SentRequest => { + const [url, init] = fetcher.mock.calls[0]; + + return {body: init.body, method: init.method, url}; +}; + +describe('roles and groups management API', () => { + it.each<[string, Run, string, string]>([ + ['getRoles', (fetcher: ApiFetcher) => getRoles({baseUrl, fetcher, limit: 5}), 'GET', `${baseUrl}/roles?limit=5`], + ['getRole', (fetcher: ApiFetcher) => getRole({baseUrl, fetcher, roleId: 'r-1'}), 'GET', `${baseUrl}/roles/r-1`], + [ + 'deleteRole', + (fetcher: ApiFetcher) => deleteRole({baseUrl, fetcher, roleId: 'r-1'}), + 'DELETE', + `${baseUrl}/roles/r-1`, + ], + [ + 'getRoleAssignments', + (fetcher: ApiFetcher) => getRoleAssignments({baseUrl, fetcher, roleId: 'r-1', type: 'group'}), + 'GET', + `${baseUrl}/roles/r-1/assignments?include=display&type=group`, + ], + [ + 'getGroups', + (fetcher: ApiFetcher) => getGroups({baseUrl, fetcher, offset: 10}), + 'GET', + `${baseUrl}/groups?include=display&offset=10`, + ], + [ + 'getGroup', + (fetcher: ApiFetcher) => getGroup({baseUrl, fetcher, groupId: 'g-1'}), + 'GET', + `${baseUrl}/groups/g-1?include=display`, + ], + [ + 'deleteGroup', + (fetcher: ApiFetcher) => deleteGroup({baseUrl, fetcher, groupId: 'g-1'}), + 'DELETE', + `${baseUrl}/groups/g-1`, + ], + [ + 'getGroupMembers', + (fetcher: ApiFetcher) => getGroupMembers({baseUrl, fetcher, groupId: 'g-1', limit: 20}), + 'GET', + `${baseUrl}/groups/g-1/members?include=display&limit=20`, + ], + [ + 'getUserGroups', + (fetcher: ApiFetcher) => getUserGroups({baseUrl, fetcher, userId: 'u-1'}), + 'GET', + `${baseUrl}/users/u-1/groups`, + ], + [ + 'getAgentGroups', + (fetcher: ApiFetcher) => getAgentGroups({agentId: 'ag-1', baseUrl, fetcher}), + 'GET', + `${baseUrl}/agents/ag-1/groups`, + ], + [ + 'getAgentRoles', + (fetcher: ApiFetcher) => getAgentRoles({agentId: 'ag-1', baseUrl, fetcher}), + 'GET', + `${baseUrl}/agents/ag-1/roles`, + ], + [ + 'getAgentTypes', + (fetcher: ApiFetcher) => getAgentTypes({baseUrl, fetcher, limit: 50}), + 'GET', + `${baseUrl}/agent-types?limit=50`, + ], + ])('%s sends the expected request', async (_name: string, run: Run, method: string, url: string) => { + const fetcher: FetcherMock = createFetcher(); + + await run(fetcher); + + expect(sentRequest(fetcher)).toEqual({body: undefined, method, url}); + }); + + it.each<[string, Run, string, string, unknown]>([ + [ + 'createRole', + (fetcher: ApiFetcher) => createRole({baseUrl, fetcher, payload: {name: 'Developer', ouId: 'ou-1'}}), + 'POST', + `${baseUrl}/roles`, + {name: 'Developer', ouId: 'ou-1'}, + ], + [ + 'updateRole', + (fetcher: ApiFetcher) => + updateRole({baseUrl, fetcher, payload: {name: 'Developer', ouId: 'ou-1', permissions: []}, roleId: 'r-1'}), + 'PUT', + `${baseUrl}/roles/r-1`, + {name: 'Developer', ouId: 'ou-1', permissions: []}, + ], + [ + 'createGroup', + (fetcher: ApiFetcher) => createGroup({baseUrl, fetcher, payload: {name: 'Platform', ouId: 'ou-1'}}), + 'POST', + `${baseUrl}/groups`, + {name: 'Platform', ouId: 'ou-1'}, + ], + [ + 'updateGroup', + (fetcher: ApiFetcher) => + updateGroup({baseUrl, fetcher, groupId: 'g-1', payload: {name: 'Platform', ouId: 'ou-1'}}), + 'PUT', + `${baseUrl}/groups/g-1`, + {name: 'Platform', ouId: 'ou-1'}, + ], + [ + 'addGroupMembers', + (fetcher: ApiFetcher) => + addGroupMembers({baseUrl, fetcher, groupId: 'g-1', members: [{id: 'u-1', type: 'user'}]}), + 'POST', + `${baseUrl}/groups/g-1/members/add`, + {members: [{id: 'u-1', type: 'user'}]}, + ], + [ + 'removeGroupMembers', + (fetcher: ApiFetcher) => + removeGroupMembers({baseUrl, fetcher, groupId: 'g-1', members: [{id: 'ag-1', type: 'agent'}]}), + 'POST', + `${baseUrl}/groups/g-1/members/remove`, + {members: [{id: 'ag-1', type: 'agent'}]}, + ], + ])( + '%s sends the payload and returns the stored resource', + async (_name: string, run: Run, method: string, url: string, body: unknown) => { + const stored: Record = {id: 'new-id', name: 'Resource'}; + const fetcher: FetcherMock = createFetcher(stored); + + const result: unknown = await run(fetcher); + + expect(result).toEqual(stored); + expect(sentRequest(fetcher)).toEqual({body: JSON.stringify(body), method, url}); + }, + ); + + it.each<[string, Run, string]>([ + [ + 'addRoleAssignments', + (fetcher: ApiFetcher) => + addRoleAssignments({assignments: [{id: 'g-1', type: 'group'}], baseUrl, fetcher, roleId: 'r-1'}), + `${baseUrl}/roles/r-1/assignments/add`, + ], + [ + 'removeRoleAssignments', + (fetcher: ApiFetcher) => + removeRoleAssignments({assignments: [{id: 'g-1', type: 'group'}], baseUrl, fetcher, roleId: 'r-1'}), + `${baseUrl}/roles/r-1/assignments/remove`, + ], + ])('%s posts the assignments and resolves without a body', async (_name: string, run: Run, url: string) => { + const fetcher: FetcherMock = createFetcher(undefined, 204); + + const result: unknown = await run(fetcher); + + expect(result).toBeUndefined(); + expect(sentRequest(fetcher)).toEqual({ + body: JSON.stringify({assignments: [{id: 'g-1', type: 'group'}]}), + method: 'POST', + url, + }); + }); + + it('appends the sub-resource path to a collection URL override', async () => { + const fetcher: FetcherMock = createFetcher(); + + await getGroupMembers({fetcher, groupId: 'g-1', url: 'https://rs.example.com/groups'}); + + expect(sentRequest(fetcher).url).toBe('https://rs.example.com/groups/g-1/members?include=display'); + }); +}); diff --git a/packages/javascript/src/api/agents/createAgent.ts b/packages/javascript/src/api/management/agents/createAgent.ts similarity index 81% rename from packages/javascript/src/api/agents/createAgent.ts rename to packages/javascript/src/api/management/agents/createAgent.ts index 48a7329f..e26c5018 100644 --- a/packages/javascript/src/api/agents/createAgent.ts +++ b/packages/javascript/src/api/management/agents/createAgent.ts @@ -1,9 +1,9 @@ // Copyright 2026 The ThunderID Authors // SPDX-License-Identifier: Apache-2.0 -import {Agent, CreateAgentRequest} from '../../models/agent-resource'; -import {ManagementRequestConfig} from '../../models/api'; -import requestManagementResource from '../../utils/requestManagementResource'; +import {Agent, CreateAgentRequest} from '../../../models/agent-resource'; +import {ManagementRequestConfig} from '../../../models/api'; +import requestManagementResource from '../../../utils/requestManagementResource'; /** * Configuration for the createAgent request. diff --git a/packages/javascript/src/api/agents/deleteAgent.ts b/packages/javascript/src/api/management/agents/deleteAgent.ts similarity index 86% rename from packages/javascript/src/api/agents/deleteAgent.ts rename to packages/javascript/src/api/management/agents/deleteAgent.ts index a4587878..fbbc3011 100644 --- a/packages/javascript/src/api/agents/deleteAgent.ts +++ b/packages/javascript/src/api/management/agents/deleteAgent.ts @@ -1,8 +1,8 @@ // Copyright 2026 The ThunderID Authors // SPDX-License-Identifier: Apache-2.0 -import {ManagementRequestConfig} from '../../models/api'; -import requestManagementResource from '../../utils/requestManagementResource'; +import {ManagementRequestConfig} from '../../../models/api'; +import requestManagementResource from '../../../utils/requestManagementResource'; /** * Configuration for the deleteAgent request. diff --git a/packages/javascript/src/api/agents/getAgent.ts b/packages/javascript/src/api/management/agents/getAgent.ts similarity index 82% rename from packages/javascript/src/api/agents/getAgent.ts rename to packages/javascript/src/api/management/agents/getAgent.ts index a90745c5..a5cffaac 100644 --- a/packages/javascript/src/api/agents/getAgent.ts +++ b/packages/javascript/src/api/management/agents/getAgent.ts @@ -1,9 +1,9 @@ // Copyright 2026 The ThunderID Authors // SPDX-License-Identifier: Apache-2.0 -import {Agent} from '../../models/agent-resource'; -import {ManagementRequestConfig} from '../../models/api'; -import requestManagementResource from '../../utils/requestManagementResource'; +import {Agent} from '../../../models/agent-resource'; +import {ManagementRequestConfig} from '../../../models/api'; +import requestManagementResource from '../../../utils/requestManagementResource'; /** * Configuration for the getAgent request. diff --git a/packages/javascript/src/api/management/agents/getAgentGroups.ts b/packages/javascript/src/api/management/agents/getAgentGroups.ts new file mode 100644 index 00000000..99c7d8af --- /dev/null +++ b/packages/javascript/src/api/management/agents/getAgentGroups.ts @@ -0,0 +1,46 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ApiFilteringParams, ManagementRequestConfig} from '../../../models/api'; +import {MembershipGroupListResponse} from '../../../models/group'; +import requestManagementResource from '../../../utils/requestManagementResource'; + +/** + * Configuration for the getAgentGroups request. + */ +export type GetAgentGroupsConfig = ManagementRequestConfig & + Pick & { + /** + * Identifier of the agent whose groups to list. + */ + agentId: string; + }; + +/** + * Retrieves a page of the groups an agent belongs to. + * + * @param config - Request configuration object. + * @returns A promise that resolves with the page the server returned. + * + * @example + * ```typescript + * const {groups} = await getAgentGroups({baseUrl: 'https://localhost:8090', agentId}); + * ``` + */ +const getAgentGroups = async ({ + agentId, + limit, + offset, + ...config +}: GetAgentGroupsConfig): Promise => + requestManagementResource(config, { + collection: 'agents', + failureMessage: 'Failed to fetch agent groups', + id: agentId, + method: 'GET', + operation: 'getAgentGroups', + query: {limit, offset}, + subPath: 'groups', + }); + +export default getAgentGroups; diff --git a/packages/javascript/src/api/management/agents/getAgentRoles.ts b/packages/javascript/src/api/management/agents/getAgentRoles.ts new file mode 100644 index 00000000..b991e240 --- /dev/null +++ b/packages/javascript/src/api/management/agents/getAgentRoles.ts @@ -0,0 +1,46 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {AgentRoleListResponse} from '../../../models/agent-resource'; +import {ApiFilteringParams, ManagementRequestConfig} from '../../../models/api'; +import requestManagementResource from '../../../utils/requestManagementResource'; + +/** + * Configuration for the getAgentRoles request. + */ +export type GetAgentRolesConfig = ManagementRequestConfig & + Pick & { + /** + * Identifier of the agent whose roles to list. + */ + agentId: string; + }; + +/** + * Retrieves a page of the role names assigned to an agent, directly or through its groups. + * + * @param config - Request configuration object. + * @returns A promise that resolves with the page the server returned. + * + * @example + * ```typescript + * const {roles} = await getAgentRoles({baseUrl: 'https://localhost:8090', agentId}); + * ``` + */ +const getAgentRoles = async ({ + agentId, + limit, + offset, + ...config +}: GetAgentRolesConfig): Promise => + requestManagementResource(config, { + collection: 'agents', + failureMessage: 'Failed to fetch agent roles', + id: agentId, + method: 'GET', + operation: 'getAgentRoles', + query: {limit, offset}, + subPath: 'roles', + }); + +export default getAgentRoles; diff --git a/packages/javascript/src/api/management/agents/getAgentTypes.ts b/packages/javascript/src/api/management/agents/getAgentTypes.ts new file mode 100644 index 00000000..d185e49f --- /dev/null +++ b/packages/javascript/src/api/management/agents/getAgentTypes.ts @@ -0,0 +1,33 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {AgentTypeListResponse} from '../../../models/agent-resource'; +import {ApiFilteringParams, ManagementRequestConfig} from '../../../models/api'; +import requestManagementResource from '../../../utils/requestManagementResource'; + +/** + * Configuration for the getAgentTypes request. + */ +export type GetAgentTypesConfig = ManagementRequestConfig & Pick; + +/** + * Retrieves a page of agent types. + * + * @param config - Request configuration object. + * @returns A promise that resolves with the page the server returned. + * + * @example + * ```typescript + * const {types} = await getAgentTypes({baseUrl: 'https://localhost:8090'}); + * ``` + */ +const getAgentTypes = async ({limit, offset, ...config}: GetAgentTypesConfig): Promise => + requestManagementResource(config, { + collection: 'agent-types', + failureMessage: 'Failed to fetch agent types', + method: 'GET', + operation: 'getAgentTypes', + query: {limit, offset}, + }); + +export default getAgentTypes; diff --git a/packages/javascript/src/api/agents/getAgents.ts b/packages/javascript/src/api/management/agents/getAgents.ts similarity index 64% rename from packages/javascript/src/api/agents/getAgents.ts rename to packages/javascript/src/api/management/agents/getAgents.ts index 96f6b507..8eaf9bb1 100644 --- a/packages/javascript/src/api/agents/getAgents.ts +++ b/packages/javascript/src/api/management/agents/getAgents.ts @@ -1,14 +1,14 @@ // Copyright 2026 The ThunderID Authors // SPDX-License-Identifier: Apache-2.0 -import {AgentListResponse} from '../../models/agent-resource'; -import {ApiFilteringParams, ManagementRequestConfig} from '../../models/api'; -import requestManagementResource from '../../utils/requestManagementResource'; +import {AgentListResponse} from '../../../models/agent-resource'; +import {ApiFilteringParams, ManagementRequestConfig} from '../../../models/api'; +import requestManagementResource from '../../../utils/requestManagementResource'; /** * Configuration for the getAgents request. */ -export type GetAgentsConfig = ManagementRequestConfig & Pick; +export type GetAgentsConfig = ManagementRequestConfig & Pick; /** * Retrieves a page of agents. @@ -20,17 +20,18 @@ export type GetAgentsConfig = ManagementRequestConfig & Pick => +const getAgents = async ({filter, limit, offset, ...config}: GetAgentsConfig): Promise => requestManagementResource(config, { collection: 'agents', failureMessage: 'Failed to fetch agents', method: 'GET', operation: 'getAgents', - query: {include: 'display', limit, offset}, + query: {filter, include: 'display', limit, offset}, }); export default getAgents; diff --git a/packages/javascript/src/api/agents/updateAgent.ts b/packages/javascript/src/api/management/agents/updateAgent.ts similarity index 82% rename from packages/javascript/src/api/agents/updateAgent.ts rename to packages/javascript/src/api/management/agents/updateAgent.ts index 0e1cf690..d40a3c9a 100644 --- a/packages/javascript/src/api/agents/updateAgent.ts +++ b/packages/javascript/src/api/management/agents/updateAgent.ts @@ -1,9 +1,9 @@ // Copyright 2026 The ThunderID Authors // SPDX-License-Identifier: Apache-2.0 -import {Agent, UpdateAgentRequest} from '../../models/agent-resource'; -import {ManagementRequestConfig} from '../../models/api'; -import requestManagementResource from '../../utils/requestManagementResource'; +import {Agent, UpdateAgentRequest} from '../../../models/agent-resource'; +import {ManagementRequestConfig} from '../../../models/api'; +import requestManagementResource from '../../../utils/requestManagementResource'; /** * Configuration for the updateAgent request. diff --git a/packages/javascript/src/api/applications/createApplication.ts b/packages/javascript/src/api/management/applications/createApplication.ts similarity index 81% rename from packages/javascript/src/api/applications/createApplication.ts rename to packages/javascript/src/api/management/applications/createApplication.ts index 5b333bfb..17a0a8ad 100644 --- a/packages/javascript/src/api/applications/createApplication.ts +++ b/packages/javascript/src/api/management/applications/createApplication.ts @@ -1,9 +1,9 @@ // Copyright 2025-2026 The ThunderID Authors // SPDX-License-Identifier: Apache-2.0 -import {ManagementRequestConfig} from '../../models/api'; -import {Application, CreateApplicationRequest} from '../../models/application'; -import requestManagementResource from '../../utils/requestManagementResource'; +import {ManagementRequestConfig} from '../../../models/api'; +import {Application, CreateApplicationRequest} from '../../../models/application'; +import requestManagementResource from '../../../utils/requestManagementResource'; /** * Configuration for the createApplication request. diff --git a/packages/javascript/src/api/applications/deleteApplication.ts b/packages/javascript/src/api/management/applications/deleteApplication.ts similarity index 87% rename from packages/javascript/src/api/applications/deleteApplication.ts rename to packages/javascript/src/api/management/applications/deleteApplication.ts index 01d4fdf3..a9a9f90d 100644 --- a/packages/javascript/src/api/applications/deleteApplication.ts +++ b/packages/javascript/src/api/management/applications/deleteApplication.ts @@ -1,8 +1,8 @@ // Copyright 2025-2026 The ThunderID Authors // SPDX-License-Identifier: Apache-2.0 -import {ManagementRequestConfig} from '../../models/api'; -import requestManagementResource from '../../utils/requestManagementResource'; +import {ManagementRequestConfig} from '../../../models/api'; +import requestManagementResource from '../../../utils/requestManagementResource'; /** * Configuration for the deleteApplication request. diff --git a/packages/javascript/src/api/applications/getApplication.ts b/packages/javascript/src/api/management/applications/getApplication.ts similarity index 83% rename from packages/javascript/src/api/applications/getApplication.ts rename to packages/javascript/src/api/management/applications/getApplication.ts index a0c303b7..0bde0060 100644 --- a/packages/javascript/src/api/applications/getApplication.ts +++ b/packages/javascript/src/api/management/applications/getApplication.ts @@ -1,9 +1,9 @@ // Copyright 2025-2026 The ThunderID Authors // SPDX-License-Identifier: Apache-2.0 -import {ManagementRequestConfig} from '../../models/api'; -import {Application} from '../../models/application'; -import requestManagementResource from '../../utils/requestManagementResource'; +import {ManagementRequestConfig} from '../../../models/api'; +import {Application} from '../../../models/application'; +import requestManagementResource from '../../../utils/requestManagementResource'; /** * Configuration for the getApplication request. diff --git a/packages/javascript/src/api/applications/getApplications.ts b/packages/javascript/src/api/management/applications/getApplications.ts similarity index 86% rename from packages/javascript/src/api/applications/getApplications.ts rename to packages/javascript/src/api/management/applications/getApplications.ts index 4d6629c6..3488370e 100644 --- a/packages/javascript/src/api/applications/getApplications.ts +++ b/packages/javascript/src/api/management/applications/getApplications.ts @@ -1,9 +1,9 @@ // Copyright 2025-2026 The ThunderID Authors // SPDX-License-Identifier: Apache-2.0 -import {ApiFilteringParams, ManagementRequestConfig} from '../../models/api'; -import {ApplicationListResponse} from '../../models/application'; -import requestManagementResource from '../../utils/requestManagementResource'; +import {ApiFilteringParams, ManagementRequestConfig} from '../../../models/api'; +import {ApplicationListResponse} from '../../../models/application'; +import requestManagementResource from '../../../utils/requestManagementResource'; /** * Configuration for the getApplications request. diff --git a/packages/javascript/src/api/applications/updateApplication.ts b/packages/javascript/src/api/management/applications/updateApplication.ts similarity index 84% rename from packages/javascript/src/api/applications/updateApplication.ts rename to packages/javascript/src/api/management/applications/updateApplication.ts index 1cf43214..960cb6e2 100644 --- a/packages/javascript/src/api/applications/updateApplication.ts +++ b/packages/javascript/src/api/management/applications/updateApplication.ts @@ -1,9 +1,9 @@ // Copyright 2025-2026 The ThunderID Authors // SPDX-License-Identifier: Apache-2.0 -import {ManagementRequestConfig} from '../../models/api'; -import {Application, UpdateApplicationRequest} from '../../models/application'; -import requestManagementResource from '../../utils/requestManagementResource'; +import {ManagementRequestConfig} from '../../../models/api'; +import {Application, UpdateApplicationRequest} from '../../../models/application'; +import requestManagementResource from '../../../utils/requestManagementResource'; /** * Configuration for the updateApplication request. diff --git a/packages/javascript/src/api/getOrganizationUnitChildren.ts b/packages/javascript/src/api/management/getOrganizationUnitChildren.ts similarity index 98% rename from packages/javascript/src/api/getOrganizationUnitChildren.ts rename to packages/javascript/src/api/management/getOrganizationUnitChildren.ts index fe8d4e8d..5552912b 100644 --- a/packages/javascript/src/api/getOrganizationUnitChildren.ts +++ b/packages/javascript/src/api/management/getOrganizationUnitChildren.ts @@ -1,7 +1,7 @@ // Copyright 2025 The ThunderID Authors // SPDX-License-Identifier: Apache-2.0 -import ThunderIDAPIError from '../errors/ThunderIDAPIError'; +import ThunderIDAPIError from '../../errors/ThunderIDAPIError'; /** * Represents a single organization unit entry. diff --git a/packages/javascript/src/api/management/groups/addGroupMembers.ts b/packages/javascript/src/api/management/groups/addGroupMembers.ts new file mode 100644 index 00000000..2ac8138d --- /dev/null +++ b/packages/javascript/src/api/management/groups/addGroupMembers.ts @@ -0,0 +1,44 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ManagementRequestConfig} from '../../../models/api'; +import {Group, GroupMember} from '../../../models/group'; +import requestManagementResource from '../../../utils/requestManagementResource'; + +/** + * Configuration for the addGroupMembers request. + */ +export interface AddGroupMembersConfig extends ManagementRequestConfig { + /** + * Identifier of the group to add members to. + */ + groupId: string; + /** + * The identities to add. + */ + members: Pick[]; +} + +/** + * Adds users, agents or groups to a group. + * + * @param config - Request configuration object. + * @returns A promise that resolves with the resource the server returned. + * + * @example + * ```typescript + * await addGroupMembers({baseUrl: 'https://localhost:8090', groupId, members: [{id: userId, type: 'user'}]}); + * ``` + */ +const addGroupMembers = async ({groupId, members, ...config}: AddGroupMembersConfig): Promise => + requestManagementResource(config, { + body: {members}, + collection: 'groups', + failureMessage: 'Failed to add group members', + id: groupId, + method: 'POST', + operation: 'addGroupMembers', + subPath: 'members/add', + }); + +export default addGroupMembers; diff --git a/packages/javascript/src/api/management/groups/createGroup.ts b/packages/javascript/src/api/management/groups/createGroup.ts new file mode 100644 index 00000000..599721dc --- /dev/null +++ b/packages/javascript/src/api/management/groups/createGroup.ts @@ -0,0 +1,38 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ManagementRequestConfig} from '../../../models/api'; +import {CreateGroupRequest, Group} from '../../../models/group'; +import requestManagementResource from '../../../utils/requestManagementResource'; + +/** + * Configuration for the createGroup request. + */ +export interface CreateGroupConfig extends ManagementRequestConfig { + /** + * The group to create. + */ + payload: CreateGroupRequest; +} + +/** + * Creates a group, optionally with its first members. + * + * @param config - Request configuration object. + * @returns A promise that resolves with the resource the server returned. + * + * @example + * ```typescript + * const group = await createGroup({baseUrl: 'https://localhost:8090', payload: {name: 'Platform team', ouId}}); + * ``` + */ +const createGroup = async ({payload, ...config}: CreateGroupConfig): Promise => + requestManagementResource(config, { + body: payload, + collection: 'groups', + failureMessage: 'Failed to create group', + method: 'POST', + operation: 'createGroup', + }); + +export default createGroup; diff --git a/packages/javascript/src/api/management/groups/deleteGroup.ts b/packages/javascript/src/api/management/groups/deleteGroup.ts new file mode 100644 index 00000000..096ef4aa --- /dev/null +++ b/packages/javascript/src/api/management/groups/deleteGroup.ts @@ -0,0 +1,37 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ManagementRequestConfig} from '../../../models/api'; +import requestManagementResource from '../../../utils/requestManagementResource'; + +/** + * Configuration for the deleteGroup request. + */ +export interface DeleteGroupConfig extends ManagementRequestConfig { + /** + * Identifier of the group to delete. + */ + groupId: string; +} + +/** + * Deletes a group. + * + * @param config - Request configuration object. + * @returns A promise that resolves once the server has applied the change. + * + * @example + * ```typescript + * await deleteGroup({baseUrl: 'https://localhost:8090', groupId}); + * ``` + */ +const deleteGroup = async ({groupId, ...config}: DeleteGroupConfig): Promise => + requestManagementResource(config, { + collection: 'groups', + failureMessage: 'Failed to delete group', + id: groupId, + method: 'DELETE', + operation: 'deleteGroup', + }); + +export default deleteGroup; diff --git a/packages/javascript/src/api/management/groups/getGroup.ts b/packages/javascript/src/api/management/groups/getGroup.ts new file mode 100644 index 00000000..63040a87 --- /dev/null +++ b/packages/javascript/src/api/management/groups/getGroup.ts @@ -0,0 +1,39 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ManagementRequestConfig} from '../../../models/api'; +import {Group} from '../../../models/group'; +import requestManagementResource from '../../../utils/requestManagementResource'; + +/** + * Configuration for the getGroup request. + */ +export interface GetGroupConfig extends ManagementRequestConfig { + /** + * Identifier of the group to retrieve. + */ + groupId: string; +} + +/** + * Retrieves a group. + * + * @param config - Request configuration object. + * @returns A promise that resolves with the resource the server returned. + * + * @example + * ```typescript + * const group = await getGroup({baseUrl: 'https://localhost:8090', groupId}); + * ``` + */ +const getGroup = async ({groupId, ...config}: GetGroupConfig): Promise => + requestManagementResource(config, { + collection: 'groups', + failureMessage: 'Failed to fetch group', + id: groupId, + method: 'GET', + operation: 'getGroup', + query: {include: 'display'}, + }); + +export default getGroup; diff --git a/packages/javascript/src/api/management/groups/getGroupMembers.ts b/packages/javascript/src/api/management/groups/getGroupMembers.ts new file mode 100644 index 00000000..d12b3e2e --- /dev/null +++ b/packages/javascript/src/api/management/groups/getGroupMembers.ts @@ -0,0 +1,46 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ApiFilteringParams, ManagementRequestConfig} from '../../../models/api'; +import {GroupMemberListResponse} from '../../../models/group'; +import requestManagementResource from '../../../utils/requestManagementResource'; + +/** + * Configuration for the getGroupMembers request. + */ +export type GetGroupMembersConfig = ManagementRequestConfig & + Pick & { + /** + * Identifier of the group whose members to list. + */ + groupId: string; + }; + +/** + * Retrieves a page of the users, agents and groups in a group. + * + * @param config - Request configuration object. + * @returns A promise that resolves with the page the server returned. + * + * @example + * ```typescript + * const {members} = await getGroupMembers({baseUrl: 'https://localhost:8090', groupId}); + * ``` + */ +const getGroupMembers = async ({ + groupId, + limit, + offset, + ...config +}: GetGroupMembersConfig): Promise => + requestManagementResource(config, { + collection: 'groups', + failureMessage: 'Failed to fetch group members', + id: groupId, + method: 'GET', + operation: 'getGroupMembers', + query: {include: 'display', limit, offset}, + subPath: 'members', + }); + +export default getGroupMembers; diff --git a/packages/javascript/src/api/management/groups/getGroups.ts b/packages/javascript/src/api/management/groups/getGroups.ts new file mode 100644 index 00000000..0b22dabf --- /dev/null +++ b/packages/javascript/src/api/management/groups/getGroups.ts @@ -0,0 +1,33 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ApiFilteringParams, ManagementRequestConfig} from '../../../models/api'; +import {GroupListResponse} from '../../../models/group'; +import requestManagementResource from '../../../utils/requestManagementResource'; + +/** + * Configuration for the getGroups request. + */ +export type GetGroupsConfig = ManagementRequestConfig & Pick; + +/** + * Retrieves a page of groups. + * + * @param config - Request configuration object. + * @returns A promise that resolves with the page the server returned. + * + * @example + * ```typescript + * const {groups} = await getGroups({baseUrl: 'https://localhost:8090', limit: 20}); + * ``` + */ +const getGroups = async ({limit, offset, ...config}: GetGroupsConfig): Promise => + requestManagementResource(config, { + collection: 'groups', + failureMessage: 'Failed to fetch groups', + method: 'GET', + operation: 'getGroups', + query: {include: 'display', limit, offset}, + }); + +export default getGroups; diff --git a/packages/javascript/src/api/management/groups/removeGroupMembers.ts b/packages/javascript/src/api/management/groups/removeGroupMembers.ts new file mode 100644 index 00000000..1b7a0434 --- /dev/null +++ b/packages/javascript/src/api/management/groups/removeGroupMembers.ts @@ -0,0 +1,44 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ManagementRequestConfig} from '../../../models/api'; +import {Group, GroupMember} from '../../../models/group'; +import requestManagementResource from '../../../utils/requestManagementResource'; + +/** + * Configuration for the removeGroupMembers request. + */ +export interface RemoveGroupMembersConfig extends ManagementRequestConfig { + /** + * Identifier of the group to remove members from. + */ + groupId: string; + /** + * The identities to remove. + */ + members: Pick[]; +} + +/** + * Removes users, agents or groups from a group. + * + * @param config - Request configuration object. + * @returns A promise that resolves with the resource the server returned. + * + * @example + * ```typescript + * await removeGroupMembers({baseUrl: 'https://localhost:8090', groupId, members: [{id: userId, type: 'user'}]}); + * ``` + */ +const removeGroupMembers = async ({groupId, members, ...config}: RemoveGroupMembersConfig): Promise => + requestManagementResource(config, { + body: {members}, + collection: 'groups', + failureMessage: 'Failed to remove group members', + id: groupId, + method: 'POST', + operation: 'removeGroupMembers', + subPath: 'members/remove', + }); + +export default removeGroupMembers; diff --git a/packages/javascript/src/api/management/groups/updateGroup.ts b/packages/javascript/src/api/management/groups/updateGroup.ts new file mode 100644 index 00000000..cc869b5f --- /dev/null +++ b/packages/javascript/src/api/management/groups/updateGroup.ts @@ -0,0 +1,43 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ManagementRequestConfig} from '../../../models/api'; +import {Group, UpdateGroupRequest} from '../../../models/group'; +import requestManagementResource from '../../../utils/requestManagementResource'; + +/** + * Configuration for the updateGroup request. + */ +export interface UpdateGroupConfig extends ManagementRequestConfig { + /** + * Identifier of the group to update. + */ + groupId: string; + /** + * The group's new state. + */ + payload: UpdateGroupRequest; +} + +/** + * Updates a group's name, description or organization unit. + * + * @param config - Request configuration object. + * @returns A promise that resolves with the resource the server returned. + * + * @example + * ```typescript + * const group = await updateGroup({baseUrl: 'https://localhost:8090', groupId, payload}); + * ``` + */ +const updateGroup = async ({groupId, payload, ...config}: UpdateGroupConfig): Promise => + requestManagementResource(config, { + body: payload, + collection: 'groups', + failureMessage: 'Failed to update group', + id: groupId, + method: 'PUT', + operation: 'updateGroup', + }); + +export default updateGroup; diff --git a/packages/javascript/src/api/management/roles/addRoleAssignments.ts b/packages/javascript/src/api/management/roles/addRoleAssignments.ts new file mode 100644 index 00000000..13174ef9 --- /dev/null +++ b/packages/javascript/src/api/management/roles/addRoleAssignments.ts @@ -0,0 +1,44 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ManagementRequestConfig} from '../../../models/api'; +import {RoleAssignmentInput} from '../../../models/role'; +import requestManagementResource from '../../../utils/requestManagementResource'; + +/** + * Configuration for the addRoleAssignments request. + */ +export interface AddRoleAssignmentsConfig extends ManagementRequestConfig { + /** + * The identities to assign the role to. + */ + assignments: RoleAssignmentInput[]; + /** + * Identifier of the role to assign. + */ + roleId: string; +} + +/** + * Assigns a role to users, groups, apps or agents. + * + * @param config - Request configuration object. + * @returns A promise that resolves once the server has applied the change. + * + * @example + * ```typescript + * await addRoleAssignments({baseUrl: 'https://localhost:8090', roleId, assignments: [{id: groupId, type: 'group'}]}); + * ``` + */ +const addRoleAssignments = async ({assignments, roleId, ...config}: AddRoleAssignmentsConfig): Promise => + requestManagementResource(config, { + body: {assignments}, + collection: 'roles', + failureMessage: 'Failed to add role assignments', + id: roleId, + method: 'POST', + operation: 'addRoleAssignments', + subPath: 'assignments/add', + }); + +export default addRoleAssignments; diff --git a/packages/javascript/src/api/management/roles/createRole.ts b/packages/javascript/src/api/management/roles/createRole.ts new file mode 100644 index 00000000..93bb2459 --- /dev/null +++ b/packages/javascript/src/api/management/roles/createRole.ts @@ -0,0 +1,38 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ManagementRequestConfig} from '../../../models/api'; +import {CreateRoleRequest, Role} from '../../../models/role'; +import requestManagementResource from '../../../utils/requestManagementResource'; + +/** + * Configuration for the createRole request. + */ +export interface CreateRoleConfig extends ManagementRequestConfig { + /** + * The role to create. + */ + payload: CreateRoleRequest; +} + +/** + * Creates a role, optionally with its first assignments. + * + * @param config - Request configuration object. + * @returns A promise that resolves with the resource the server returned. + * + * @example + * ```typescript + * const role = await createRole({baseUrl: 'https://localhost:8090', payload: {name: 'Developer', ouId}}); + * ``` + */ +const createRole = async ({payload, ...config}: CreateRoleConfig): Promise => + requestManagementResource(config, { + body: payload, + collection: 'roles', + failureMessage: 'Failed to create role', + method: 'POST', + operation: 'createRole', + }); + +export default createRole; diff --git a/packages/javascript/src/api/management/roles/deleteRole.ts b/packages/javascript/src/api/management/roles/deleteRole.ts new file mode 100644 index 00000000..ff7328b4 --- /dev/null +++ b/packages/javascript/src/api/management/roles/deleteRole.ts @@ -0,0 +1,37 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ManagementRequestConfig} from '../../../models/api'; +import requestManagementResource from '../../../utils/requestManagementResource'; + +/** + * Configuration for the deleteRole request. + */ +export interface DeleteRoleConfig extends ManagementRequestConfig { + /** + * Identifier of the role to delete. + */ + roleId: string; +} + +/** + * Deletes a role. + * + * @param config - Request configuration object. + * @returns A promise that resolves once the server has applied the change. + * + * @example + * ```typescript + * await deleteRole({baseUrl: 'https://localhost:8090', roleId}); + * ``` + */ +const deleteRole = async ({roleId, ...config}: DeleteRoleConfig): Promise => + requestManagementResource(config, { + collection: 'roles', + failureMessage: 'Failed to delete role', + id: roleId, + method: 'DELETE', + operation: 'deleteRole', + }); + +export default deleteRole; diff --git a/packages/javascript/src/api/management/roles/getRole.ts b/packages/javascript/src/api/management/roles/getRole.ts new file mode 100644 index 00000000..0b0c8401 --- /dev/null +++ b/packages/javascript/src/api/management/roles/getRole.ts @@ -0,0 +1,38 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ManagementRequestConfig} from '../../../models/api'; +import {Role} from '../../../models/role'; +import requestManagementResource from '../../../utils/requestManagementResource'; + +/** + * Configuration for the getRole request. + */ +export interface GetRoleConfig extends ManagementRequestConfig { + /** + * Identifier of the role to retrieve. + */ + roleId: string; +} + +/** + * Retrieves a role with the permissions it grants. + * + * @param config - Request configuration object. + * @returns A promise that resolves with the resource the server returned. + * + * @example + * ```typescript + * const role = await getRole({baseUrl: 'https://localhost:8090', roleId}); + * ``` + */ +const getRole = async ({roleId, ...config}: GetRoleConfig): Promise => + requestManagementResource(config, { + collection: 'roles', + failureMessage: 'Failed to fetch role', + id: roleId, + method: 'GET', + operation: 'getRole', + }); + +export default getRole; diff --git a/packages/javascript/src/api/management/roles/getRoleAssignments.ts b/packages/javascript/src/api/management/roles/getRoleAssignments.ts new file mode 100644 index 00000000..d4a10a3d --- /dev/null +++ b/packages/javascript/src/api/management/roles/getRoleAssignments.ts @@ -0,0 +1,51 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ApiFilteringParams, ManagementRequestConfig} from '../../../models/api'; +import {RoleAssigneeType, RoleAssignmentListResponse} from '../../../models/role'; +import requestManagementResource from '../../../utils/requestManagementResource'; + +/** + * Configuration for the getRoleAssignments request. + */ +export type GetRoleAssignmentsConfig = ManagementRequestConfig & + Pick & { + /** + * Identifier of the role whose assignments to list. + */ + roleId: string; + /** + * Only return assignees of this type. Returns every type when omitted. + */ + type?: RoleAssigneeType; + }; + +/** + * Retrieves a page of the users, groups, apps and agents a role is assigned to. + * + * @param config - Request configuration object. + * @returns A promise that resolves with the page the server returned. + * + * @example + * ```typescript + * const {assignments} = await getRoleAssignments({baseUrl: 'https://localhost:8090', roleId, type: 'group'}); + * ``` + */ +const getRoleAssignments = async ({ + limit, + offset, + roleId, + type, + ...config +}: GetRoleAssignmentsConfig): Promise => + requestManagementResource(config, { + collection: 'roles', + failureMessage: 'Failed to fetch role assignments', + id: roleId, + method: 'GET', + operation: 'getRoleAssignments', + query: {include: 'display', limit, offset, type}, + subPath: 'assignments', + }); + +export default getRoleAssignments; diff --git a/packages/javascript/src/api/management/roles/getRoles.ts b/packages/javascript/src/api/management/roles/getRoles.ts new file mode 100644 index 00000000..87cff418 --- /dev/null +++ b/packages/javascript/src/api/management/roles/getRoles.ts @@ -0,0 +1,33 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ApiFilteringParams, ManagementRequestConfig} from '../../../models/api'; +import {RoleListResponse} from '../../../models/role'; +import requestManagementResource from '../../../utils/requestManagementResource'; + +/** + * Configuration for the getRoles request. + */ +export type GetRolesConfig = ManagementRequestConfig & Pick; + +/** + * Retrieves a page of roles. + * + * @param config - Request configuration object. + * @returns A promise that resolves with the page the server returned. + * + * @example + * ```typescript + * const {roles} = await getRoles({baseUrl: 'https://localhost:8090', limit: 20}); + * ``` + */ +const getRoles = async ({limit, offset, ...config}: GetRolesConfig): Promise => + requestManagementResource(config, { + collection: 'roles', + failureMessage: 'Failed to fetch roles', + method: 'GET', + operation: 'getRoles', + query: {limit, offset}, + }); + +export default getRoles; diff --git a/packages/javascript/src/api/management/roles/removeRoleAssignments.ts b/packages/javascript/src/api/management/roles/removeRoleAssignments.ts new file mode 100644 index 00000000..ba5ee079 --- /dev/null +++ b/packages/javascript/src/api/management/roles/removeRoleAssignments.ts @@ -0,0 +1,44 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ManagementRequestConfig} from '../../../models/api'; +import {RoleAssignmentInput} from '../../../models/role'; +import requestManagementResource from '../../../utils/requestManagementResource'; + +/** + * Configuration for the removeRoleAssignments request. + */ +export interface RemoveRoleAssignmentsConfig extends ManagementRequestConfig { + /** + * The identities to remove the role from. + */ + assignments: RoleAssignmentInput[]; + /** + * Identifier of the role to unassign. + */ + roleId: string; +} + +/** + * Removes a role from users, groups, apps or agents. + * + * @param config - Request configuration object. + * @returns A promise that resolves once the server has applied the change. + * + * @example + * ```typescript + * await removeRoleAssignments({baseUrl: 'https://localhost:8090', roleId, assignments: [{id: userId, type: 'user'}]}); + * ``` + */ +const removeRoleAssignments = async ({assignments, roleId, ...config}: RemoveRoleAssignmentsConfig): Promise => + requestManagementResource(config, { + body: {assignments}, + collection: 'roles', + failureMessage: 'Failed to remove role assignments', + id: roleId, + method: 'POST', + operation: 'removeRoleAssignments', + subPath: 'assignments/remove', + }); + +export default removeRoleAssignments; diff --git a/packages/javascript/src/api/management/roles/updateRole.ts b/packages/javascript/src/api/management/roles/updateRole.ts new file mode 100644 index 00000000..1bb3e495 --- /dev/null +++ b/packages/javascript/src/api/management/roles/updateRole.ts @@ -0,0 +1,43 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ManagementRequestConfig} from '../../../models/api'; +import {Role, UpdateRoleRequest} from '../../../models/role'; +import requestManagementResource from '../../../utils/requestManagementResource'; + +/** + * Configuration for the updateRole request. + */ +export interface UpdateRoleConfig extends ManagementRequestConfig { + /** + * The role's new state. + */ + payload: UpdateRoleRequest; + /** + * Identifier of the role to update. + */ + roleId: string; +} + +/** + * Replaces a role's name, description and permissions. + * + * @param config - Request configuration object. + * @returns A promise that resolves with the resource the server returned. + * + * @example + * ```typescript + * const role = await updateRole({baseUrl: 'https://localhost:8090', roleId, payload}); + * ``` + */ +const updateRole = async ({payload, roleId, ...config}: UpdateRoleConfig): Promise => + requestManagementResource(config, { + body: payload, + collection: 'roles', + failureMessage: 'Failed to update role', + id: roleId, + method: 'PUT', + operation: 'updateRole', + }); + +export default updateRole; diff --git a/packages/javascript/src/api/user-types/getUserTypes.ts b/packages/javascript/src/api/management/user-types/getUserTypes.ts similarity index 84% rename from packages/javascript/src/api/user-types/getUserTypes.ts rename to packages/javascript/src/api/management/user-types/getUserTypes.ts index abaafdd7..b57827e0 100644 --- a/packages/javascript/src/api/user-types/getUserTypes.ts +++ b/packages/javascript/src/api/management/user-types/getUserTypes.ts @@ -1,9 +1,9 @@ // Copyright 2026 The ThunderID Authors // SPDX-License-Identifier: Apache-2.0 -import {ApiFilteringParams, ManagementRequestConfig} from '../../models/api'; -import {ManagedUserTypeListResponse} from '../../models/managed-user-type'; -import requestManagementResource from '../../utils/requestManagementResource'; +import {ApiFilteringParams, ManagementRequestConfig} from '../../../models/api'; +import {ManagedUserTypeListResponse} from '../../../models/managed-user-type'; +import requestManagementResource from '../../../utils/requestManagementResource'; /** * Configuration for the getUserTypes request. diff --git a/packages/javascript/src/api/users/createUser.ts b/packages/javascript/src/api/management/users/createUser.ts similarity index 80% rename from packages/javascript/src/api/users/createUser.ts rename to packages/javascript/src/api/management/users/createUser.ts index ab986283..7392a87e 100644 --- a/packages/javascript/src/api/users/createUser.ts +++ b/packages/javascript/src/api/management/users/createUser.ts @@ -1,9 +1,9 @@ // Copyright 2025-2026 The ThunderID Authors // SPDX-License-Identifier: Apache-2.0 -import {ManagementRequestConfig} from '../../models/api'; -import {CreateManagedUserRequest, ManagedUser} from '../../models/managed-user'; -import requestManagementResource from '../../utils/requestManagementResource'; +import {ManagementRequestConfig} from '../../../models/api'; +import {CreateManagedUserRequest, ManagedUser} from '../../../models/managed-user'; +import requestManagementResource from '../../../utils/requestManagementResource'; /** * Configuration for the createUser request. diff --git a/packages/javascript/src/api/users/deleteUser.ts b/packages/javascript/src/api/management/users/deleteUser.ts similarity index 86% rename from packages/javascript/src/api/users/deleteUser.ts rename to packages/javascript/src/api/management/users/deleteUser.ts index 80c245af..040b7a6a 100644 --- a/packages/javascript/src/api/users/deleteUser.ts +++ b/packages/javascript/src/api/management/users/deleteUser.ts @@ -1,8 +1,8 @@ // Copyright 2025-2026 The ThunderID Authors // SPDX-License-Identifier: Apache-2.0 -import {ManagementRequestConfig} from '../../models/api'; -import requestManagementResource from '../../utils/requestManagementResource'; +import {ManagementRequestConfig} from '../../../models/api'; +import requestManagementResource from '../../../utils/requestManagementResource'; /** * Configuration for the deleteUser request. diff --git a/packages/javascript/src/api/users/getUser.ts b/packages/javascript/src/api/management/users/getUser.ts similarity index 82% rename from packages/javascript/src/api/users/getUser.ts rename to packages/javascript/src/api/management/users/getUser.ts index 6cd7370c..55028231 100644 --- a/packages/javascript/src/api/users/getUser.ts +++ b/packages/javascript/src/api/management/users/getUser.ts @@ -1,9 +1,9 @@ // Copyright 2025-2026 The ThunderID Authors // SPDX-License-Identifier: Apache-2.0 -import {ManagementRequestConfig} from '../../models/api'; -import {ManagedUser} from '../../models/managed-user'; -import requestManagementResource from '../../utils/requestManagementResource'; +import {ManagementRequestConfig} from '../../../models/api'; +import {ManagedUser} from '../../../models/managed-user'; +import requestManagementResource from '../../../utils/requestManagementResource'; /** * Configuration for the getUser request. diff --git a/packages/javascript/src/api/management/users/getUserGroups.ts b/packages/javascript/src/api/management/users/getUserGroups.ts new file mode 100644 index 00000000..c264b0be --- /dev/null +++ b/packages/javascript/src/api/management/users/getUserGroups.ts @@ -0,0 +1,46 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ApiFilteringParams, ManagementRequestConfig} from '../../../models/api'; +import {MembershipGroupListResponse} from '../../../models/group'; +import requestManagementResource from '../../../utils/requestManagementResource'; + +/** + * Configuration for the getUserGroups request. + */ +export type GetUserGroupsConfig = ManagementRequestConfig & + Pick & { + /** + * Identifier of the user whose groups to list. + */ + userId: string; + }; + +/** + * Retrieves a page of the groups a user belongs to. + * + * @param config - Request configuration object. + * @returns A promise that resolves with the page the server returned. + * + * @example + * ```typescript + * const {groups} = await getUserGroups({baseUrl: 'https://localhost:8090', userId}); + * ``` + */ +const getUserGroups = async ({ + limit, + offset, + userId, + ...config +}: GetUserGroupsConfig): Promise => + requestManagementResource(config, { + collection: 'users', + failureMessage: 'Failed to fetch user groups', + id: userId, + method: 'GET', + operation: 'getUserGroups', + query: {limit, offset}, + subPath: 'groups', + }); + +export default getUserGroups; diff --git a/packages/javascript/src/api/users/getUsers.ts b/packages/javascript/src/api/management/users/getUsers.ts similarity index 86% rename from packages/javascript/src/api/users/getUsers.ts rename to packages/javascript/src/api/management/users/getUsers.ts index ca1233e5..80b6e29a 100644 --- a/packages/javascript/src/api/users/getUsers.ts +++ b/packages/javascript/src/api/management/users/getUsers.ts @@ -1,9 +1,9 @@ // Copyright 2025-2026 The ThunderID Authors // SPDX-License-Identifier: Apache-2.0 -import {ApiFilteringParams, ManagementRequestConfig} from '../../models/api'; -import {ManagedUserListResponse} from '../../models/managed-user'; -import requestManagementResource from '../../utils/requestManagementResource'; +import {ApiFilteringParams, ManagementRequestConfig} from '../../../models/api'; +import {ManagedUserListResponse} from '../../../models/managed-user'; +import requestManagementResource from '../../../utils/requestManagementResource'; /** * Configuration for the getUsers request. diff --git a/packages/javascript/src/api/users/updateUser.ts b/packages/javascript/src/api/management/users/updateUser.ts similarity index 82% rename from packages/javascript/src/api/users/updateUser.ts rename to packages/javascript/src/api/management/users/updateUser.ts index ff71cdf3..c76203ad 100644 --- a/packages/javascript/src/api/users/updateUser.ts +++ b/packages/javascript/src/api/management/users/updateUser.ts @@ -1,9 +1,9 @@ // Copyright 2025-2026 The ThunderID Authors // SPDX-License-Identifier: Apache-2.0 -import {ManagementRequestConfig} from '../../models/api'; -import {ManagedUser, UpdateManagedUserRequest} from '../../models/managed-user'; -import requestManagementResource from '../../utils/requestManagementResource'; +import {ManagementRequestConfig} from '../../../models/api'; +import {ManagedUser, UpdateManagedUserRequest} from '../../../models/managed-user'; +import requestManagementResource from '../../../utils/requestManagementResource'; /** * Configuration for the updateUser request. diff --git a/packages/javascript/src/constants/AgentQueryKeys.ts b/packages/javascript/src/constants/AgentQueryKeys.ts index ef5142d5..992d9bd0 100644 --- a/packages/javascript/src/constants/AgentQueryKeys.ts +++ b/packages/javascript/src/constants/AgentQueryKeys.ts @@ -8,9 +8,11 @@ const AgentQueryKeys: { AGENT: 'agent'; AGENTS: 'agents'; + AGENT_TYPES: 'agent-types'; } = { AGENT: 'agent', AGENTS: 'agents', + AGENT_TYPES: 'agent-types', }; export default AgentQueryKeys; diff --git a/packages/javascript/src/constants/GroupQueryKeys.ts b/packages/javascript/src/constants/GroupQueryKeys.ts new file mode 100644 index 00000000..f0c92aef --- /dev/null +++ b/packages/javascript/src/constants/GroupQueryKeys.ts @@ -0,0 +1,20 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +/** + * Cache keys for group resources and group memberships. The SDK's reactive wrappers use them to + * refetch after a mutation, and an application can reuse them as keys in its own data fetching cache. + */ +const GroupQueryKeys: { + GROUP: 'group'; + GROUPS: 'groups'; + GROUP_MEMBERS: 'group-members'; + MEMBERSHIPS: 'group-memberships'; +} = { + GROUP: 'group', + GROUPS: 'groups', + GROUP_MEMBERS: 'group-members', + MEMBERSHIPS: 'group-memberships', +}; + +export default GroupQueryKeys; diff --git a/packages/javascript/src/constants/RoleQueryKeys.ts b/packages/javascript/src/constants/RoleQueryKeys.ts new file mode 100644 index 00000000..a71ea17f --- /dev/null +++ b/packages/javascript/src/constants/RoleQueryKeys.ts @@ -0,0 +1,18 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +/** + * Cache keys for role resources. The SDK's reactive wrappers use them to refetch after a mutation, + * and an application can reuse them as keys in its own data fetching cache. + */ +const RoleQueryKeys: { + ROLE: 'role'; + ROLES: 'roles'; + ROLE_ASSIGNMENTS: 'role-assignments'; +} = { + ROLE: 'role', + ROLES: 'roles', + ROLE_ASSIGNMENTS: 'role-assignments', +}; + +export default RoleQueryKeys; diff --git a/packages/javascript/src/constants/ThunderIDWordmark.ts b/packages/javascript/src/constants/ThunderIDWordmark.ts new file mode 100644 index 00000000..ef80a992 --- /dev/null +++ b/packages/javascript/src/constants/ThunderIDWordmark.ts @@ -0,0 +1,59 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +/** + * One shape of the ThunderID wordmark. Accent shapes form the mark and keep the brand blue; the rest + * form the lettering, which UI layers draw in the surrounding text color. + */ +export interface ThunderIDWordmarkPath { + accent: boolean; + d: string; +} + +/** + * The ThunderID wordmark artwork, shared by every UI layer that credits ThunderID. + */ +const ThunderIDWordmark: {accentColor: string; paths: ThunderIDWordmarkPath[]; viewBox: string} = { + accentColor: '#3688FF', + paths: [ + {accent: false, d: 'M286.26 92.5581V70.7585H391.069V92.5581H351.882V199.098H325.532V92.5581H286.26Z'}, + { + accent: false, + d: 'M434.366 143.381V199.098H408.524V70.7585H433.837V126.836H431.761C434.281 118.807 438.115 112.578 443.263 108.193C448.41 103.807 455.04 101.604 463.195 101.604C469.846 101.604 475.672 103.045 480.628 105.947C485.585 108.85 489.461 113.002 492.215 118.446C494.969 123.891 496.346 130.352 496.346 137.873V199.12H470.418V142.364C470.418 136.39 468.872 131.708 465.822 128.319C462.75 124.929 458.493 123.234 453.028 123.234C449.405 123.234 446.186 124.018 443.347 125.607C440.509 127.196 438.285 129.463 436.717 132.45C435.129 135.437 434.345 139.081 434.345 143.381H434.366Z', + }, + { + accent: false, + d: 'M551.504 200.306C544.853 200.306 539.007 198.865 534.029 195.963C529.03 193.061 525.175 188.908 522.442 183.485C519.71 178.062 518.354 171.579 518.354 164.058V102.812H544.196V159.567C544.196 165.541 545.743 170.223 548.856 173.613C551.949 177.002 556.207 178.697 561.608 178.697C565.273 178.697 568.535 177.913 571.331 176.324C574.148 174.735 576.33 172.447 577.919 169.439C579.507 166.431 580.291 162.787 580.291 158.55V102.833H606.218V199.12H581.753L581.414 175.096H582.875C580.354 183.188 576.478 189.417 571.289 193.781C566.099 198.145 559.511 200.327 551.525 200.327L551.504 200.306Z', + }, + { + accent: false, + d: 'M653.984 143.381V199.098H628.142V102.812H652.523L652.946 126.836H651.4C653.921 118.807 657.755 112.578 662.902 108.193C668.049 103.807 674.679 101.604 682.834 101.604C689.486 101.604 695.311 103.045 700.268 105.947C705.224 108.85 709.079 113.002 711.812 118.446C714.544 123.87 715.9 130.352 715.9 137.873V199.12H690.058V142.364C690.058 136.39 688.511 131.708 685.461 128.319C682.389 124.929 678.132 123.234 672.667 123.234C669.045 123.234 665.825 124.018 662.987 125.607C660.148 127.196 657.924 129.463 656.356 132.45C654.768 135.437 653.984 139.081 653.984 143.381Z', + }, + { + accent: false, + d: 'M773.071 200.751C765.721 200.751 759.07 198.844 753.138 195.031C747.186 191.217 742.505 185.625 739.052 178.231C735.599 170.859 733.884 161.812 733.884 151.135C733.884 140.458 735.663 131.009 739.222 123.658C742.78 116.307 747.525 110.799 753.435 107.112C759.345 103.447 765.869 101.604 772.986 101.604C778.43 101.604 782.984 102.515 786.585 104.358C790.208 106.201 793.131 108.468 795.376 111.159C797.621 113.849 799.273 116.476 800.375 118.997H801.243V70.7585H827.086V199.098H801.688V183.676H800.396C799.252 186.26 797.537 188.866 795.27 191.472C793.004 194.077 790.059 196.281 786.437 198.06C782.815 199.84 778.366 200.73 773.092 200.73L773.071 200.751ZM781.078 179.989C785.441 179.989 789.17 178.782 792.283 176.367C795.376 173.952 797.748 170.583 799.337 166.24C800.947 161.897 801.752 156.834 801.752 151.029C801.752 145.224 800.968 140.098 799.379 135.818C797.791 131.539 795.461 128.213 792.368 125.819C789.275 123.446 785.505 122.239 781.078 122.239C776.651 122.239 772.732 123.467 769.661 125.946C766.589 128.425 764.28 131.793 762.734 136.115C761.188 140.415 760.404 145.394 760.404 151.008C760.404 156.622 761.188 161.622 762.776 165.986C764.365 170.35 766.674 173.761 769.745 176.24C772.817 178.718 776.587 179.947 781.078 179.947V179.989Z', + }, + { + accent: false, + d: 'M892.603 201.005C882.795 201.005 874.322 198.992 867.205 194.967C860.088 190.942 854.623 185.243 850.789 177.828C846.976 170.414 845.07 161.643 845.07 151.474C845.07 141.305 846.955 132.852 850.746 125.374C854.538 117.917 859.897 112.091 866.845 107.896C873.793 103.701 881.948 101.604 891.311 101.604C897.623 101.604 903.511 102.6 908.955 104.612C914.399 106.625 919.208 109.655 923.338 113.701C927.469 117.747 930.71 122.811 933.018 128.912C935.348 134.992 936.513 142.174 936.513 150.436V157.681H855.64V141.496H923.847L911.794 145.796C911.794 140.797 911.031 136.454 909.506 132.746C907.981 129.039 905.715 126.179 902.707 124.124C899.699 122.09 895.971 121.073 891.544 121.073C887.116 121.073 883.261 122.111 880.126 124.166C876.991 126.243 874.619 129.039 873.03 132.556C871.42 136.094 870.615 140.119 870.615 144.652V156.114C870.615 161.685 871.548 166.367 873.412 170.159C875.276 173.952 877.881 176.79 881.249 178.676C884.617 180.561 888.536 181.515 893.005 181.515C896.055 181.515 898.809 181.091 901.266 180.222C903.744 179.354 905.841 178.083 907.6 176.388C909.358 174.693 910.692 172.617 911.603 170.138L935.031 174.524C933.485 179.799 930.773 184.438 926.939 188.442C923.084 192.425 918.276 195.518 912.514 197.7C906.752 199.882 900.101 200.963 892.581 200.963L892.603 201.005Z', + }, + { + accent: false, + d: 'M954.18 199.098V102.812H979.153V119.612H980.191C981.971 113.637 984.936 109.125 989.109 106.095C993.282 103.066 998.048 101.519 1003.45 101.519C1004.76 101.519 1006.2 101.583 1007.75 101.731C1009.3 101.88 1010.65 102.091 1011.8 102.388V125.48C1010.65 125.077 1008.98 124.76 1006.8 124.527C1004.61 124.293 1002.54 124.188 1000.59 124.188C996.692 124.188 993.155 125.035 989.999 126.73C986.843 128.425 984.385 130.776 982.627 133.784C980.869 136.793 980.001 140.31 980.001 144.335V199.12H954.158L954.18 199.098Z', + }, + {accent: false, d: 'M1055.64 70.7797V199.12H1029.38V70.7797H1055.64Z'}, + { + accent: false, + d: 'M1105.15 70.7797V199.12H1078.88V70.7797H1105.15ZM1124.02 199.098H1090.78V176.451H1122.73C1130.99 176.451 1137.94 174.99 1143.53 172.066C1149.12 169.143 1153.34 164.609 1156.15 158.465C1158.97 152.322 1160.37 144.462 1160.37 134.865C1160.37 125.268 1158.95 117.514 1156.11 111.392C1153.27 105.269 1149.08 100.757 1143.53 97.8332C1137.98 94.9096 1131.12 93.4479 1122.9 93.4479H1090.17V70.8009H1124.44C1137.3 70.8009 1148.38 73.3643 1157.64 78.5123C1166.89 83.6603 1174.05 90.9904 1179.03 100.566C1184.03 110.121 1186.53 121.561 1186.53 134.886C1186.53 148.212 1184.03 159.757 1179.03 169.333C1174.03 178.93 1166.87 186.281 1157.55 191.429C1148.21 196.577 1137.05 199.141 1124 199.141L1124.02 199.098Z', + }, + {accent: false, d: 'M55.4763 26.4391L58.8866 0H0V26.4391H55.4763Z'}, + {accent: true, d: 'M39.8438 147.407L49.5455 72.2839H4.9909e-05V256.743H60.5602L80.048 147.407H39.8438Z'}, + { + accent: true, + d: 'M192.42 59.361C182.782 40.2307 168.929 25.5705 150.903 15.3381C145.501 12.2662 139.761 9.6605 133.703 7.5208L115.401 103.702H159.757L76.2987 256.743H83.3735C109.449 256.743 131.69 251.574 150.14 241.236C168.569 230.897 182.634 216.131 192.356 196.959C202.058 177.765 206.909 154.8 206.909 128.043C206.909 101.286 202.079 78.5123 192.441 59.3821L192.42 59.361Z', + }, + ], + viewBox: '0 0 1187 257', +}; + +export default ThunderIDWordmark; diff --git a/packages/javascript/src/constants/UserQueryKeys.ts b/packages/javascript/src/constants/UserQueryKeys.ts index f24131f6..5ca1491f 100644 --- a/packages/javascript/src/constants/UserQueryKeys.ts +++ b/packages/javascript/src/constants/UserQueryKeys.ts @@ -8,9 +8,11 @@ const UserQueryKeys: { USER: 'user'; USERS: 'users'; + USER_TYPES: 'user-types'; } = { USER: 'user', USERS: 'users', + USER_TYPES: 'user-types', }; export default UserQueryKeys; diff --git a/packages/javascript/src/i18n/models/i18n.ts b/packages/javascript/src/i18n/models/i18n.ts index bd400aba..65feb395 100644 --- a/packages/javascript/src/i18n/models/i18n.ts +++ b/packages/javascript/src/i18n/models/i18n.ts @@ -203,6 +203,133 @@ export interface I18nTranslations { 'errors.signup.components.not.available': string; 'errors.signin.components.not.available': string; 'errors.signin.timeout': string; + + /* |---------------------------------------------------------------| */ + /* | Admin | */ + /* |---------------------------------------------------------------| */ + + 'admin.common.loading': string; + 'attribution.secured_by': string; + 'attribution.powered_by': string; + 'admin.common.actions.cancel': string; + 'admin.common.actions.confirm': string; + 'admin.common.actions.remove': string; + 'admin.common.columns.actions': string; + 'admin.common.columns.organization_unit': string; + 'admin.common.errors.forbidden': string; + 'admin.common.errors.not_found': string; + 'admin.common.errors.no_ou': string; + 'admin.common.fields.identity': string; + 'admin.common.fields.identity_type': string; + 'admin.common.fields.identity_placeholder': string; + 'admin.common.fields.identity_empty': string; + 'admin.common.identity_types.user': string; + 'admin.common.identity_types.group': string; + 'admin.common.identity_types.agent': string; + 'admin.common.identity_types.app': string; + 'admin.common.pagination.label': string; + 'admin.common.pagination.previous': string; + 'admin.common.pagination.next': string; + 'admin.common.pagination.range': string; + 'admin.common.search.attribute': string; + 'admin.common.search.submit': string; + 'admin.common.search.clear': string; + 'admin.common.search.placeholder': string; + 'admin.common.search.no_results': string; + 'admin.common.filters.type': string; + 'admin.common.filters.all_types': string; + 'admin.users.list.search.username': string; + 'admin.users.list.search.email': string; + 'admin.agents.list.search.name': string; + 'admin.users.list.title': string; + 'admin.users.list.columns.name': string; + 'admin.users.list.columns.type': string; + 'admin.users.list.empty': string; + 'admin.users.list.error': string; + 'admin.users.create.title': string; + 'admin.users.create.submit': string; + 'admin.users.create.error': string; + 'admin.users.create.fields.type': string; + 'admin.users.create.fields.username': string; + 'admin.users.create.fields.email': string; + 'admin.users.create.fields.given_name': string; + 'admin.users.create.fields.family_name': string; + 'admin.users.create.fields.password': string; + 'admin.users.details.subtitle': string; + 'admin.users.details.attributes': string; + 'admin.users.details.no_attributes': string; + 'admin.users.details.groups': string; + 'admin.users.details.no_groups': string; + 'admin.users.details.error': string; + 'admin.users.details.delete.action': string; + 'admin.users.details.delete.confirm': string; + 'admin.users.details.delete.error': string; + 'admin.users.details.edit.action': string; + 'admin.users.details.edit.save': string; + 'admin.users.details.edit.error': string; + 'admin.groups.list.title': string; + 'admin.groups.list.columns.name': string; + 'admin.groups.list.columns.description': string; + 'admin.groups.list.empty': string; + 'admin.groups.list.error': string; + 'admin.groups.create.title': string; + 'admin.groups.create.submit': string; + 'admin.groups.create.fields.name': string; + 'admin.groups.create.fields.description': string; + 'admin.groups.create.error': string; + 'admin.groups.members.title': string; + 'admin.groups.members.add': string; + 'admin.groups.members.columns.member': string; + 'admin.groups.members.columns.type': string; + 'admin.groups.members.empty': string; + 'admin.groups.members.remove_label': string; + 'admin.groups.members.error': string; + 'admin.groups.members.update_error': string; + 'admin.roles.list.title': string; + 'admin.roles.list.columns.name': string; + 'admin.roles.list.columns.description': string; + 'admin.roles.list.empty': string; + 'admin.roles.list.error': string; + 'admin.roles.create.title': string; + 'admin.roles.create.submit': string; + 'admin.roles.create.fields.name': string; + 'admin.roles.create.fields.description': string; + 'admin.roles.create.error': string; + 'admin.roles.assignments.title': string; + 'admin.roles.assignments.add': string; + 'admin.roles.assignments.columns.assignee': string; + 'admin.roles.assignments.columns.type': string; + 'admin.roles.assignments.empty': string; + 'admin.roles.assignments.remove_label': string; + 'admin.roles.assignments.error': string; + 'admin.roles.assignments.update_error': string; + 'admin.roles.permissions.title': string; + 'admin.roles.permissions.empty': string; + 'admin.roles.permissions.error': string; + 'admin.agents.list.title': string; + 'admin.agents.list.columns.name': string; + 'admin.agents.list.columns.type': string; + 'admin.agents.list.columns.client_id': string; + 'admin.agents.list.empty': string; + 'admin.agents.list.error': string; + 'admin.agents.create.title': string; + 'admin.agents.create.submit': string; + 'admin.agents.create.fields.name': string; + 'admin.agents.create.fields.type': string; + 'admin.agents.create.fields.description': string; + 'admin.agents.create.error': string; + 'admin.agents.details.overview': string; + 'admin.agents.details.type': string; + 'admin.agents.details.owner': string; + 'admin.agents.details.no_owner': string; + 'admin.agents.details.groups': string; + 'admin.agents.details.no_groups': string; + 'admin.agents.details.roles': string; + 'admin.agents.details.no_roles': string; + 'admin.agents.details.error': string; + 'admin.agents.details.delete.action': string; + 'admin.agents.details.delete.confirm': string; + 'admin.agents.details.delete.error': string; } export type I18nTextDirection = 'ltr' | 'rtl'; diff --git a/packages/javascript/src/i18n/translations/en-US.ts b/packages/javascript/src/i18n/translations/en-US.ts index e7ee4b73..914843c0 100644 --- a/packages/javascript/src/i18n/translations/en-US.ts +++ b/packages/javascript/src/i18n/translations/en-US.ts @@ -204,6 +204,129 @@ const translations: I18nTranslations = { 'An error occurred while initializing the sign-up flow. Please try again later.', 'errors.signup.popup.failure': 'Could not continue the sign-up in the pop-up window. Please try again.', 'errors.signup.components.not.available': 'Sign-up form is not available at the moment. Please try again later.', + /* Admin */ + 'admin.common.loading': 'Loading…', + 'attribution.secured_by': 'Secured by', + 'attribution.powered_by': 'Powered by', + 'admin.common.actions.cancel': 'Cancel', + 'admin.common.actions.confirm': 'Confirm', + 'admin.common.actions.remove': 'Remove', + 'admin.common.columns.actions': 'Actions', + 'admin.common.columns.organization_unit': 'Organization unit', + 'admin.common.errors.forbidden': 'You do not have permission to do this.', + 'admin.common.errors.not_found': 'This item could not be found.', + 'admin.common.errors.no_ou': 'Choose an organization unit before creating this item.', + 'admin.common.fields.identity': 'Name', + 'admin.common.fields.identity_type': 'Type', + 'admin.common.fields.identity_placeholder': 'Select an identity', + 'admin.common.fields.identity_empty': 'Nothing to add', + 'admin.common.identity_types.user': 'User', + 'admin.common.identity_types.group': 'Group', + 'admin.common.identity_types.agent': 'Agent', + 'admin.common.identity_types.app': 'Application', + 'admin.common.pagination.label': 'Pagination', + 'admin.common.pagination.previous': 'Previous', + 'admin.common.pagination.next': 'Next', + 'admin.common.pagination.range': '{start} to {end} of {total}', + 'admin.common.search.attribute': 'Search by', + 'admin.common.search.submit': 'Search', + 'admin.common.search.clear': 'Clear search', + 'admin.common.search.placeholder': 'Exact {attribute}', + 'admin.common.search.no_results': 'No matches for "{value}". Search matches the whole value.', + 'admin.common.filters.type': 'Assignee type', + 'admin.common.filters.all_types': 'All types', + 'admin.users.list.search.username': 'Username', + 'admin.users.list.search.email': 'Email', + 'admin.agents.list.search.name': 'Name', + 'admin.users.list.title': 'Users', + 'admin.users.list.columns.name': 'Name', + 'admin.users.list.columns.type': 'User type', + 'admin.users.list.empty': 'No users found.', + 'admin.users.list.error': 'Could not load users. Please try again.', + 'admin.users.create.title': 'Add user', + 'admin.users.create.submit': 'Create user', + 'admin.users.create.error': 'Could not create the user. Please try again.', + 'admin.users.create.fields.type': 'User type', + 'admin.users.create.fields.username': 'Username', + 'admin.users.create.fields.email': 'Email', + 'admin.users.create.fields.given_name': 'First name', + 'admin.users.create.fields.family_name': 'Last name', + 'admin.users.create.fields.password': 'Password', + 'admin.users.details.subtitle': 'User type: {type}', + 'admin.users.details.attributes': 'Attributes', + 'admin.users.details.no_attributes': 'No attributes.', + 'admin.users.details.groups': 'Groups', + 'admin.users.details.no_groups': 'Not a member of any group.', + 'admin.users.details.error': 'Could not load this user. Please try again.', + 'admin.users.details.delete.action': 'Delete user', + 'admin.users.details.delete.confirm': 'Delete {name}?', + 'admin.users.details.delete.error': 'Could not delete the user. Please try again.', + 'admin.users.details.edit.action': 'Edit', + 'admin.users.details.edit.save': 'Save changes', + 'admin.users.details.edit.error': 'Could not save the user. Please try again.', + 'admin.groups.list.title': 'Groups', + 'admin.groups.list.columns.name': 'Name', + 'admin.groups.list.columns.description': 'Description', + 'admin.groups.list.empty': 'No groups found.', + 'admin.groups.list.error': 'Could not load groups. Please try again.', + 'admin.groups.create.title': 'Create group', + 'admin.groups.create.submit': 'Create group', + 'admin.groups.create.fields.name': 'Name', + 'admin.groups.create.fields.description': 'Description', + 'admin.groups.create.error': 'Could not create the group. Please try again.', + 'admin.groups.members.title': 'Members', + 'admin.groups.members.add': 'Add member', + 'admin.groups.members.columns.member': 'Member', + 'admin.groups.members.columns.type': 'Type', + 'admin.groups.members.empty': 'This group has no members.', + 'admin.groups.members.remove_label': 'Remove {name} from the group', + 'admin.groups.members.error': 'Could not load members. Please try again.', + 'admin.groups.members.update_error': 'Could not update the members. Please try again.', + 'admin.roles.list.title': 'Roles', + 'admin.roles.list.columns.name': 'Name', + 'admin.roles.list.columns.description': 'Description', + 'admin.roles.list.empty': 'No roles found.', + 'admin.roles.list.error': 'Could not load roles. Please try again.', + 'admin.roles.create.title': 'Create role', + 'admin.roles.create.submit': 'Create role', + 'admin.roles.create.fields.name': 'Name', + 'admin.roles.create.fields.description': 'Description', + 'admin.roles.create.error': 'Could not create the role. Please try again.', + 'admin.roles.assignments.title': 'Assigned to', + 'admin.roles.assignments.add': 'Assign role', + 'admin.roles.assignments.columns.assignee': 'Assignee', + 'admin.roles.assignments.columns.type': 'Type', + 'admin.roles.assignments.empty': 'This role is not assigned to anyone.', + 'admin.roles.assignments.remove_label': 'Remove the role from {name}', + 'admin.roles.assignments.error': 'Could not load assignments. Please try again.', + 'admin.roles.assignments.update_error': 'Could not update the assignments. Please try again.', + 'admin.roles.permissions.title': 'Permissions', + 'admin.roles.permissions.empty': 'This role grants no permissions.', + 'admin.roles.permissions.error': 'Could not load the permissions. Please try again.', + 'admin.agents.list.title': 'Agents', + 'admin.agents.list.columns.name': 'Name', + 'admin.agents.list.columns.type': 'Agent type', + 'admin.agents.list.columns.client_id': 'Client ID', + 'admin.agents.list.empty': 'No agents found.', + 'admin.agents.list.error': 'Could not load agents. Please try again.', + 'admin.agents.create.title': 'Register agent', + 'admin.agents.create.submit': 'Register agent', + 'admin.agents.create.fields.name': 'Name', + 'admin.agents.create.fields.type': 'Agent type', + 'admin.agents.create.fields.description': 'Description', + 'admin.agents.create.error': 'Could not register the agent. Please try again.', + 'admin.agents.details.overview': 'Overview', + 'admin.agents.details.type': 'Agent type', + 'admin.agents.details.owner': 'Owner', + 'admin.agents.details.no_owner': 'No owner', + 'admin.agents.details.groups': 'Groups', + 'admin.agents.details.no_groups': 'Not a member of any group.', + 'admin.agents.details.roles': 'Roles', + 'admin.agents.details.no_roles': 'No roles assigned.', + 'admin.agents.details.error': 'Could not load this agent. Please try again.', + 'admin.agents.details.delete.action': 'Delete agent', + 'admin.agents.details.delete.confirm': 'Delete {name}?', + 'admin.agents.details.delete.error': 'Could not delete the agent. Please try again.', }; const metadata: I18nMetadata = { diff --git a/packages/javascript/src/i18n/translations/fr-FR.ts b/packages/javascript/src/i18n/translations/fr-FR.ts index 43c94099..91043233 100644 --- a/packages/javascript/src/i18n/translations/fr-FR.ts +++ b/packages/javascript/src/i18n/translations/fr-FR.ts @@ -211,6 +211,129 @@ const translations: I18nTranslations = { 'errors.signin.components.not.available': "Le formulaire de connexion n'est pas disponible pour le moment. Veuillez réessayer plus tard.", 'errors.signin.timeout': "Le temps imparti pour effectuer cette e'tape est expire'.", + /* Admin */ + 'admin.common.loading': 'Chargement…', + 'attribution.secured_by': 'Sécurisé par', + 'attribution.powered_by': 'Propulsé par', + 'admin.common.actions.cancel': 'Annuler', + 'admin.common.actions.confirm': 'Confirmer', + 'admin.common.actions.remove': 'Retirer', + 'admin.common.columns.actions': 'Actions', + 'admin.common.columns.organization_unit': 'Unité organisationnelle', + 'admin.common.errors.forbidden': "Vous n'avez pas l'autorisation d'effectuer cette action.", + 'admin.common.errors.not_found': 'Cet élément est introuvable.', + 'admin.common.errors.no_ou': 'Choisissez une unité organisationnelle avant de créer cet élément.', + 'admin.common.fields.identity': 'Nom', + 'admin.common.fields.identity_type': 'Type', + 'admin.common.fields.identity_placeholder': 'Sélectionnez une identité', + 'admin.common.fields.identity_empty': 'Rien à ajouter', + 'admin.common.identity_types.user': 'Utilisateur', + 'admin.common.identity_types.group': 'Groupe', + 'admin.common.identity_types.agent': 'Agent', + 'admin.common.identity_types.app': 'Application', + 'admin.common.pagination.label': 'Pagination', + 'admin.common.pagination.previous': 'Précédent', + 'admin.common.pagination.next': 'Suivant', + 'admin.common.pagination.range': '{start} à {end} sur {total}', + 'admin.common.search.attribute': 'Rechercher par', + 'admin.common.search.submit': 'Rechercher', + 'admin.common.search.clear': 'Effacer la recherche', + 'admin.common.search.placeholder': '{attribute} exact', + 'admin.common.search.no_results': 'Aucun résultat pour « {value} ». La recherche porte sur la valeur entière.', + 'admin.common.filters.type': "Type d'attributaire", + 'admin.common.filters.all_types': 'Tous les types', + 'admin.users.list.search.username': "Nom d'utilisateur", + 'admin.users.list.search.email': 'E-mail', + 'admin.agents.list.search.name': 'Nom', + 'admin.users.list.title': 'Utilisateurs', + 'admin.users.list.columns.name': 'Nom', + 'admin.users.list.columns.type': "Type d'utilisateur", + 'admin.users.list.empty': 'Aucun utilisateur trouvé.', + 'admin.users.list.error': 'Impossible de charger les utilisateurs. Veuillez réessayer.', + 'admin.users.create.title': 'Ajouter un utilisateur', + 'admin.users.create.submit': 'Créer l’utilisateur', + 'admin.users.create.error': "Impossible de créer l'utilisateur. Veuillez réessayer.", + 'admin.users.create.fields.type': "Type d'utilisateur", + 'admin.users.create.fields.username': "Nom d'utilisateur", + 'admin.users.create.fields.email': 'E-mail', + 'admin.users.create.fields.given_name': 'Prénom', + 'admin.users.create.fields.family_name': 'Nom de famille', + 'admin.users.create.fields.password': 'Mot de passe', + 'admin.users.details.subtitle': "Type d'utilisateur : {type}", + 'admin.users.details.attributes': 'Attributs', + 'admin.users.details.no_attributes': 'Aucun attribut.', + 'admin.users.details.groups': 'Groupes', + 'admin.users.details.no_groups': "Membre d'aucun groupe.", + 'admin.users.details.error': 'Impossible de charger cet utilisateur. Veuillez réessayer.', + 'admin.users.details.delete.action': "Supprimer l'utilisateur", + 'admin.users.details.delete.confirm': 'Supprimer {name} ?', + 'admin.users.details.delete.error': "Impossible de supprimer l'utilisateur. Veuillez réessayer.", + 'admin.users.details.edit.action': 'Modifier', + 'admin.users.details.edit.save': 'Enregistrer les modifications', + 'admin.users.details.edit.error': "Impossible d'enregistrer l'utilisateur. Veuillez réessayer.", + 'admin.groups.list.title': 'Groupes', + 'admin.groups.list.columns.name': 'Nom', + 'admin.groups.list.columns.description': 'Description', + 'admin.groups.list.empty': 'Aucun groupe trouvé.', + 'admin.groups.list.error': 'Impossible de charger les groupes. Veuillez réessayer.', + 'admin.groups.create.title': 'Créer un groupe', + 'admin.groups.create.submit': 'Créer le groupe', + 'admin.groups.create.fields.name': 'Nom', + 'admin.groups.create.fields.description': 'Description', + 'admin.groups.create.error': 'Impossible de créer le groupe. Veuillez réessayer.', + 'admin.groups.members.title': 'Membres', + 'admin.groups.members.add': 'Ajouter un membre', + 'admin.groups.members.columns.member': 'Membre', + 'admin.groups.members.columns.type': 'Type', + 'admin.groups.members.empty': "Ce groupe n'a aucun membre.", + 'admin.groups.members.remove_label': 'Retirer {name} du groupe', + 'admin.groups.members.error': 'Impossible de charger les membres. Veuillez réessayer.', + 'admin.groups.members.update_error': 'Impossible de mettre à jour les membres. Veuillez réessayer.', + 'admin.roles.list.title': 'Rôles', + 'admin.roles.list.columns.name': 'Nom', + 'admin.roles.list.columns.description': 'Description', + 'admin.roles.list.empty': 'Aucun rôle trouvé.', + 'admin.roles.list.error': 'Impossible de charger les rôles. Veuillez réessayer.', + 'admin.roles.create.title': 'Créer un rôle', + 'admin.roles.create.submit': 'Créer le rôle', + 'admin.roles.create.fields.name': 'Nom', + 'admin.roles.create.fields.description': 'Description', + 'admin.roles.create.error': 'Impossible de créer le rôle. Veuillez réessayer.', + 'admin.roles.assignments.title': 'Attribué à', + 'admin.roles.assignments.add': 'Attribuer le rôle', + 'admin.roles.assignments.columns.assignee': 'Bénéficiaire', + 'admin.roles.assignments.columns.type': 'Type', + 'admin.roles.assignments.empty': "Ce rôle n'est attribué à personne.", + 'admin.roles.assignments.remove_label': 'Retirer le rôle de {name}', + 'admin.roles.assignments.error': 'Impossible de charger les attributions. Veuillez réessayer.', + 'admin.roles.assignments.update_error': 'Impossible de mettre à jour les attributions. Veuillez réessayer.', + 'admin.roles.permissions.title': 'Autorisations', + 'admin.roles.permissions.empty': "Ce rôle n'accorde aucune autorisation.", + 'admin.roles.permissions.error': 'Impossible de charger les autorisations. Veuillez réessayer.', + 'admin.agents.list.title': 'Agents', + 'admin.agents.list.columns.name': 'Nom', + 'admin.agents.list.columns.type': "Type d'agent", + 'admin.agents.list.columns.client_id': 'ID client', + 'admin.agents.list.empty': 'Aucun agent trouvé.', + 'admin.agents.list.error': 'Impossible de charger les agents. Veuillez réessayer.', + 'admin.agents.create.title': 'Enregistrer un agent', + 'admin.agents.create.submit': "Enregistrer l'agent", + 'admin.agents.create.fields.name': 'Nom', + 'admin.agents.create.fields.type': "Type d'agent", + 'admin.agents.create.fields.description': 'Description', + 'admin.agents.create.error': "Impossible d'enregistrer l'agent. Veuillez réessayer.", + 'admin.agents.details.overview': "Vue d'ensemble", + 'admin.agents.details.type': "Type d'agent", + 'admin.agents.details.owner': 'Propriétaire', + 'admin.agents.details.no_owner': 'Aucun propriétaire', + 'admin.agents.details.groups': 'Groupes', + 'admin.agents.details.no_groups': "Membre d'aucun groupe.", + 'admin.agents.details.roles': 'Rôles', + 'admin.agents.details.no_roles': 'Aucun rôle attribué.', + 'admin.agents.details.error': 'Impossible de charger cet agent. Veuillez réessayer.', + 'admin.agents.details.delete.action': "Supprimer l'agent", + 'admin.agents.details.delete.confirm': 'Supprimer {name} ?', + 'admin.agents.details.delete.error': "Impossible de supprimer l'agent. Veuillez réessayer.", }; const metadata: I18nMetadata = { diff --git a/packages/javascript/src/i18n/translations/hi-IN.ts b/packages/javascript/src/i18n/translations/hi-IN.ts index 57ae9e66..4f99e05f 100644 --- a/packages/javascript/src/i18n/translations/hi-IN.ts +++ b/packages/javascript/src/i18n/translations/hi-IN.ts @@ -201,6 +201,129 @@ const translations: I18nTranslations = { 'errors.signup.components.not.available': 'साइन-अप फॉर्म फिलहाल उपलब्ध नहीं है। कृपया बाद में पुनः प्रयास करें।', 'errors.signin.components.not.available': 'साइन-इन फॉर्म फिलहाल उपलब्ध नहीं है। कृपया बाद में पुनः प्रयास करें।', 'errors.signin.timeout': 'स्टेप पूरा करने के लिए दिया गया समय खत्म हो गया है।', + /* Admin */ + 'admin.common.loading': 'लोड हो रहा है…', + 'attribution.secured_by': 'सुरक्षा प्रदाता', + 'attribution.powered_by': 'संचालक', + 'admin.common.actions.cancel': 'रद्द करें', + 'admin.common.actions.confirm': 'पुष्टि करें', + 'admin.common.actions.remove': 'हटाएं', + 'admin.common.columns.actions': 'कार्रवाइयाँ', + 'admin.common.columns.organization_unit': 'संगठन इकाई', + 'admin.common.errors.forbidden': 'आपको यह करने की अनुमति नहीं है।', + 'admin.common.errors.not_found': 'यह आइटम नहीं मिला।', + 'admin.common.errors.no_ou': 'यह आइटम बनाने से पहले एक संगठन इकाई चुनें।', + 'admin.common.fields.identity': 'नाम', + 'admin.common.fields.identity_type': 'प्रकार', + 'admin.common.fields.identity_placeholder': 'एक पहचान चुनें', + 'admin.common.fields.identity_empty': 'जोड़ने के लिए कुछ नहीं है', + 'admin.common.identity_types.user': 'उपयोगकर्ता', + 'admin.common.identity_types.group': 'समूह', + 'admin.common.identity_types.agent': 'एजेंट', + 'admin.common.identity_types.app': 'एप्लिकेशन', + 'admin.common.pagination.label': 'पृष्ठ क्रमांकन', + 'admin.common.pagination.previous': 'पिछला', + 'admin.common.pagination.next': 'अगला', + 'admin.common.pagination.range': '{total} में से {start} से {end}', + 'admin.common.search.attribute': 'इसके अनुसार खोजें', + 'admin.common.search.submit': 'खोजें', + 'admin.common.search.clear': 'खोज साफ़ करें', + 'admin.common.search.placeholder': 'सटीक {attribute}', + 'admin.common.search.no_results': '"{value}" के लिए कोई मिलान नहीं। खोज पूरे मान से मेल खाती है।', + 'admin.common.filters.type': 'असाइनी प्रकार', + 'admin.common.filters.all_types': 'सभी प्रकार', + 'admin.users.list.search.username': 'उपयोगकर्ता नाम', + 'admin.users.list.search.email': 'ईमेल', + 'admin.agents.list.search.name': 'नाम', + 'admin.users.list.title': 'उपयोगकर्ता', + 'admin.users.list.columns.name': 'नाम', + 'admin.users.list.columns.type': 'उपयोगकर्ता प्रकार', + 'admin.users.list.empty': 'कोई उपयोगकर्ता नहीं मिला।', + 'admin.users.list.error': 'उपयोगकर्ताओं को लोड नहीं किया जा सका। कृपया फिर से प्रयास करें।', + 'admin.users.create.title': 'उपयोगकर्ता जोड़ें', + 'admin.users.create.submit': 'उपयोगकर्ता बनाएं', + 'admin.users.create.error': 'उपयोगकर्ता नहीं बनाया जा सका। कृपया फिर से प्रयास करें।', + 'admin.users.create.fields.type': 'उपयोगकर्ता प्रकार', + 'admin.users.create.fields.username': 'उपयोगकर्ता नाम', + 'admin.users.create.fields.email': 'ईमेल', + 'admin.users.create.fields.given_name': 'पहला नाम', + 'admin.users.create.fields.family_name': 'अंतिम नाम', + 'admin.users.create.fields.password': 'पासवर्ड', + 'admin.users.details.subtitle': 'उपयोगकर्ता प्रकार: {type}', + 'admin.users.details.attributes': 'विशेषताएँ', + 'admin.users.details.no_attributes': 'कोई विशेषता नहीं।', + 'admin.users.details.groups': 'समूह', + 'admin.users.details.no_groups': 'किसी समूह का सदस्य नहीं।', + 'admin.users.details.error': 'यह उपयोगकर्ता लोड नहीं किया जा सका। कृपया फिर से प्रयास करें।', + 'admin.users.details.delete.action': 'उपयोगकर्ता हटाएं', + 'admin.users.details.delete.confirm': '{name} को हटाएं?', + 'admin.users.details.delete.error': 'उपयोगकर्ता को हटाया नहीं जा सका। कृपया फिर से प्रयास करें।', + 'admin.users.details.edit.action': 'संपादित करें', + 'admin.users.details.edit.save': 'परिवर्तन सहेजें', + 'admin.users.details.edit.error': 'उपयोगकर्ता को सहेजा नहीं जा सका। कृपया फिर से प्रयास करें।', + 'admin.groups.list.title': 'समूह', + 'admin.groups.list.columns.name': 'नाम', + 'admin.groups.list.columns.description': 'विवरण', + 'admin.groups.list.empty': 'कोई समूह नहीं मिला।', + 'admin.groups.list.error': 'समूहों को लोड नहीं किया जा सका। कृपया फिर से प्रयास करें।', + 'admin.groups.create.title': 'समूह बनाएं', + 'admin.groups.create.submit': 'समूह बनाएं', + 'admin.groups.create.fields.name': 'नाम', + 'admin.groups.create.fields.description': 'विवरण', + 'admin.groups.create.error': 'समूह नहीं बनाया जा सका। कृपया फिर से प्रयास करें।', + 'admin.groups.members.title': 'सदस्य', + 'admin.groups.members.add': 'सदस्य जोड़ें', + 'admin.groups.members.columns.member': 'सदस्य', + 'admin.groups.members.columns.type': 'प्रकार', + 'admin.groups.members.empty': 'इस समूह में कोई सदस्य नहीं है।', + 'admin.groups.members.remove_label': '{name} को समूह से हटाएं', + 'admin.groups.members.error': 'सदस्यों को लोड नहीं किया जा सका। कृपया फिर से प्रयास करें।', + 'admin.groups.members.update_error': 'सदस्यों को अपडेट नहीं किया जा सका। कृपया फिर से प्रयास करें।', + 'admin.roles.list.title': 'भूमिकाएँ', + 'admin.roles.list.columns.name': 'नाम', + 'admin.roles.list.columns.description': 'विवरण', + 'admin.roles.list.empty': 'कोई भूमिका नहीं मिली।', + 'admin.roles.list.error': 'भूमिकाओं को लोड नहीं किया जा सका। कृपया फिर से प्रयास करें।', + 'admin.roles.create.title': 'भूमिका बनाएं', + 'admin.roles.create.submit': 'भूमिका बनाएं', + 'admin.roles.create.fields.name': 'नाम', + 'admin.roles.create.fields.description': 'विवरण', + 'admin.roles.create.error': 'भूमिका नहीं बनाई जा सकी। कृपया फिर से प्रयास करें।', + 'admin.roles.assignments.title': 'किसे सौंपी गई', + 'admin.roles.assignments.add': 'भूमिका सौंपें', + 'admin.roles.assignments.columns.assignee': 'प्राप्तकर्ता', + 'admin.roles.assignments.columns.type': 'प्रकार', + 'admin.roles.assignments.empty': 'यह भूमिका किसी को नहीं सौंपी गई है।', + 'admin.roles.assignments.remove_label': '{name} से भूमिका हटाएं', + 'admin.roles.assignments.error': 'असाइनमेंट लोड नहीं किए जा सके। कृपया फिर से प्रयास करें।', + 'admin.roles.assignments.update_error': 'असाइनमेंट अपडेट नहीं किए जा सके। कृपया फिर से प्रयास करें।', + 'admin.roles.permissions.title': 'अनुमतियाँ', + 'admin.roles.permissions.empty': 'यह भूमिका कोई अनुमति नहीं देती।', + 'admin.roles.permissions.error': 'अनुमतियाँ लोड नहीं की जा सकीं। कृपया फिर से प्रयास करें।', + 'admin.agents.list.title': 'एजेंट', + 'admin.agents.list.columns.name': 'नाम', + 'admin.agents.list.columns.type': 'एजेंट प्रकार', + 'admin.agents.list.columns.client_id': 'क्लाइंट आईडी', + 'admin.agents.list.empty': 'कोई एजेंट नहीं मिला।', + 'admin.agents.list.error': 'एजेंटों को लोड नहीं किया जा सका। कृपया फिर से प्रयास करें।', + 'admin.agents.create.title': 'एजेंट पंजीकृत करें', + 'admin.agents.create.submit': 'एजेंट पंजीकृत करें', + 'admin.agents.create.fields.name': 'नाम', + 'admin.agents.create.fields.type': 'एजेंट प्रकार', + 'admin.agents.create.fields.description': 'विवरण', + 'admin.agents.create.error': 'एजेंट पंजीकृत नहीं किया जा सका। कृपया फिर से प्रयास करें।', + 'admin.agents.details.overview': 'अवलोकन', + 'admin.agents.details.type': 'एजेंट प्रकार', + 'admin.agents.details.owner': 'स्वामी', + 'admin.agents.details.no_owner': 'कोई स्वामी नहीं', + 'admin.agents.details.groups': 'समूह', + 'admin.agents.details.no_groups': 'किसी समूह का सदस्य नहीं।', + 'admin.agents.details.roles': 'भूमिकाएँ', + 'admin.agents.details.no_roles': 'कोई भूमिका नहीं सौंपी गई।', + 'admin.agents.details.error': 'यह एजेंट लोड नहीं किया जा सका। कृपया फिर से प्रयास करें।', + 'admin.agents.details.delete.action': 'एजेंट हटाएं', + 'admin.agents.details.delete.confirm': '{name} को हटाएं?', + 'admin.agents.details.delete.error': 'एजेंट को हटाया नहीं जा सका। कृपया फिर से प्रयास करें।', }; const metadata: I18nMetadata = { diff --git a/packages/javascript/src/i18n/translations/ja-JP.ts b/packages/javascript/src/i18n/translations/ja-JP.ts index fa523075..df912847 100644 --- a/packages/javascript/src/i18n/translations/ja-JP.ts +++ b/packages/javascript/src/i18n/translations/ja-JP.ts @@ -202,6 +202,129 @@ const translations: I18nTranslations = { 'errors.signup.components.not.available': 'サインアップフォームは現在利用できません。後でもう一度お試しください。', 'errors.signin.components.not.available': 'サインインフォームは現在利用できません。後でもう一度お試しください。', 'errors.signin.timeout': 'ステップを完了するために許可された時間が経過しました。', + /* Admin */ + 'admin.common.loading': '読み込み中…', + 'attribution.secured_by': 'セキュリティ提供', + 'attribution.powered_by': '提供', + 'admin.common.actions.cancel': 'キャンセル', + 'admin.common.actions.confirm': '確認', + 'admin.common.actions.remove': '削除', + 'admin.common.columns.actions': '操作', + 'admin.common.columns.organization_unit': '組織単位', + 'admin.common.errors.forbidden': 'この操作を行う権限がありません。', + 'admin.common.errors.not_found': 'この項目は見つかりませんでした。', + 'admin.common.errors.no_ou': 'この項目を作成する前に組織単位を選択してください。', + 'admin.common.fields.identity': '名前', + 'admin.common.fields.identity_type': '種類', + 'admin.common.fields.identity_placeholder': 'IDを選択', + 'admin.common.fields.identity_empty': '追加できる項目はありません', + 'admin.common.identity_types.user': 'ユーザー', + 'admin.common.identity_types.group': 'グループ', + 'admin.common.identity_types.agent': 'エージェント', + 'admin.common.identity_types.app': 'アプリケーション', + 'admin.common.pagination.label': 'ページ送り', + 'admin.common.pagination.previous': '前へ', + 'admin.common.pagination.next': '次へ', + 'admin.common.pagination.range': '{total} 件中 {start}〜{end} 件', + 'admin.common.search.attribute': '検索対象', + 'admin.common.search.submit': '検索', + 'admin.common.search.clear': '検索をクリア', + 'admin.common.search.placeholder': '{attribute}(完全一致)', + 'admin.common.search.no_results': '「{value}」に一致する項目はありません。検索は値全体と一致します。', + 'admin.common.filters.type': '割り当て先の種類', + 'admin.common.filters.all_types': 'すべての種類', + 'admin.users.list.search.username': 'ユーザー名', + 'admin.users.list.search.email': 'メール', + 'admin.agents.list.search.name': '名前', + 'admin.users.list.title': 'ユーザー', + 'admin.users.list.columns.name': '名前', + 'admin.users.list.columns.type': 'ユーザータイプ', + 'admin.users.list.empty': 'ユーザーが見つかりません。', + 'admin.users.list.error': 'ユーザーを読み込めませんでした。もう一度お試しください。', + 'admin.users.create.title': 'ユーザーを追加', + 'admin.users.create.submit': 'ユーザーを作成', + 'admin.users.create.error': 'ユーザーを作成できませんでした。もう一度お試しください。', + 'admin.users.create.fields.type': 'ユーザータイプ', + 'admin.users.create.fields.username': 'ユーザー名', + 'admin.users.create.fields.email': 'メールアドレス', + 'admin.users.create.fields.given_name': '名', + 'admin.users.create.fields.family_name': '姓', + 'admin.users.create.fields.password': 'パスワード', + 'admin.users.details.subtitle': 'ユーザータイプ: {type}', + 'admin.users.details.attributes': '属性', + 'admin.users.details.no_attributes': '属性はありません。', + 'admin.users.details.groups': 'グループ', + 'admin.users.details.no_groups': 'どのグループにも属していません。', + 'admin.users.details.error': 'このユーザーを読み込めませんでした。もう一度お試しください。', + 'admin.users.details.delete.action': 'ユーザーを削除', + 'admin.users.details.delete.confirm': '{name} を削除しますか?', + 'admin.users.details.delete.error': 'ユーザーを削除できませんでした。もう一度お試しください。', + 'admin.users.details.edit.action': '編集', + 'admin.users.details.edit.save': '変更を保存', + 'admin.users.details.edit.error': 'ユーザーを保存できませんでした。もう一度お試しください。', + 'admin.groups.list.title': 'グループ', + 'admin.groups.list.columns.name': '名前', + 'admin.groups.list.columns.description': '説明', + 'admin.groups.list.empty': 'グループが見つかりません。', + 'admin.groups.list.error': 'グループを読み込めませんでした。もう一度お試しください。', + 'admin.groups.create.title': 'グループを作成', + 'admin.groups.create.submit': 'グループを作成', + 'admin.groups.create.fields.name': '名前', + 'admin.groups.create.fields.description': '説明', + 'admin.groups.create.error': 'グループを作成できませんでした。もう一度お試しください。', + 'admin.groups.members.title': 'メンバー', + 'admin.groups.members.add': 'メンバーを追加', + 'admin.groups.members.columns.member': 'メンバー', + 'admin.groups.members.columns.type': '種類', + 'admin.groups.members.empty': 'このグループにはメンバーがいません。', + 'admin.groups.members.remove_label': '{name} をグループから削除', + 'admin.groups.members.error': 'メンバーを読み込めませんでした。もう一度お試しください。', + 'admin.groups.members.update_error': 'メンバーを更新できませんでした。もう一度お試しください。', + 'admin.roles.list.title': 'ロール', + 'admin.roles.list.columns.name': '名前', + 'admin.roles.list.columns.description': '説明', + 'admin.roles.list.empty': 'ロールが見つかりません。', + 'admin.roles.list.error': 'ロールを読み込めませんでした。もう一度お試しください。', + 'admin.roles.create.title': 'ロールを作成', + 'admin.roles.create.submit': 'ロールを作成', + 'admin.roles.create.fields.name': '名前', + 'admin.roles.create.fields.description': '説明', + 'admin.roles.create.error': 'ロールを作成できませんでした。もう一度お試しください。', + 'admin.roles.assignments.title': '割り当て先', + 'admin.roles.assignments.add': 'ロールを割り当て', + 'admin.roles.assignments.columns.assignee': '割り当て先', + 'admin.roles.assignments.columns.type': '種類', + 'admin.roles.assignments.empty': 'このロールは誰にも割り当てられていません。', + 'admin.roles.assignments.remove_label': '{name} からロールを削除', + 'admin.roles.assignments.error': '割り当てを読み込めませんでした。もう一度お試しください。', + 'admin.roles.assignments.update_error': '割り当てを更新できませんでした。もう一度お試しください。', + 'admin.roles.permissions.title': '権限', + 'admin.roles.permissions.empty': 'このロールには権限がありません。', + 'admin.roles.permissions.error': '権限を読み込めませんでした。もう一度お試しください。', + 'admin.agents.list.title': 'エージェント', + 'admin.agents.list.columns.name': '名前', + 'admin.agents.list.columns.type': 'エージェントタイプ', + 'admin.agents.list.columns.client_id': 'クライアントID', + 'admin.agents.list.empty': 'エージェントが見つかりません。', + 'admin.agents.list.error': 'エージェントを読み込めませんでした。もう一度お試しください。', + 'admin.agents.create.title': 'エージェントを登録', + 'admin.agents.create.submit': 'エージェントを登録', + 'admin.agents.create.fields.name': '名前', + 'admin.agents.create.fields.type': 'エージェントタイプ', + 'admin.agents.create.fields.description': '説明', + 'admin.agents.create.error': 'エージェントを登録できませんでした。もう一度お試しください。', + 'admin.agents.details.overview': '概要', + 'admin.agents.details.type': 'エージェントタイプ', + 'admin.agents.details.owner': '所有者', + 'admin.agents.details.no_owner': '所有者なし', + 'admin.agents.details.groups': 'グループ', + 'admin.agents.details.no_groups': 'どのグループにも属していません。', + 'admin.agents.details.roles': 'ロール', + 'admin.agents.details.no_roles': '割り当てられたロールはありません。', + 'admin.agents.details.error': 'このエージェントを読み込めませんでした。もう一度お試しください。', + 'admin.agents.details.delete.action': 'エージェントを削除', + 'admin.agents.details.delete.confirm': '{name} を削除しますか?', + 'admin.agents.details.delete.error': 'エージェントを削除できませんでした。もう一度お試しください。', }; const metadata: I18nMetadata = { diff --git a/packages/javascript/src/i18n/translations/pt-BR.ts b/packages/javascript/src/i18n/translations/pt-BR.ts index 30a0f1fe..89a110da 100644 --- a/packages/javascript/src/i18n/translations/pt-BR.ts +++ b/packages/javascript/src/i18n/translations/pt-BR.ts @@ -205,6 +205,129 @@ const translations: I18nTranslations = { 'errors.signin.components.not.available': 'O formulário de login não está disponível no momento. Tente novamente mais tarde.', 'errors.signin.timeout': 'O tempo permitido para concluir a etapa expirou.', + /* Admin */ + 'admin.common.loading': 'Carregando…', + 'attribution.secured_by': 'Protegido por', + 'attribution.powered_by': 'Desenvolvido por', + 'admin.common.actions.cancel': 'Cancelar', + 'admin.common.actions.confirm': 'Confirmar', + 'admin.common.actions.remove': 'Remover', + 'admin.common.columns.actions': 'Ações', + 'admin.common.columns.organization_unit': 'Unidade organizacional', + 'admin.common.errors.forbidden': 'Você não tem permissão para fazer isso.', + 'admin.common.errors.not_found': 'Este item não foi encontrado.', + 'admin.common.errors.no_ou': 'Escolha uma unidade organizacional antes de criar este item.', + 'admin.common.fields.identity': 'Nome', + 'admin.common.fields.identity_type': 'Tipo', + 'admin.common.fields.identity_placeholder': 'Selecione uma identidade', + 'admin.common.fields.identity_empty': 'Nada para adicionar', + 'admin.common.identity_types.user': 'Usuário', + 'admin.common.identity_types.group': 'Grupo', + 'admin.common.identity_types.agent': 'Agente', + 'admin.common.identity_types.app': 'Aplicativo', + 'admin.common.pagination.label': 'Paginação', + 'admin.common.pagination.previous': 'Anterior', + 'admin.common.pagination.next': 'Próximo', + 'admin.common.pagination.range': '{start} a {end} de {total}', + 'admin.common.search.attribute': 'Pesquisar por', + 'admin.common.search.submit': 'Pesquisar', + 'admin.common.search.clear': 'Limpar pesquisa', + 'admin.common.search.placeholder': '{attribute} exato', + 'admin.common.search.no_results': 'Nenhum resultado para "{value}". A pesquisa corresponde ao valor inteiro.', + 'admin.common.filters.type': 'Tipo de atribuído', + 'admin.common.filters.all_types': 'Todos os tipos', + 'admin.users.list.search.username': 'Nome de usuário', + 'admin.users.list.search.email': 'E-mail', + 'admin.agents.list.search.name': 'Nome', + 'admin.users.list.title': 'Usuários', + 'admin.users.list.columns.name': 'Nome', + 'admin.users.list.columns.type': 'Tipo de usuário', + 'admin.users.list.empty': 'Nenhum usuário encontrado.', + 'admin.users.list.error': 'Não foi possível carregar os usuários. Tente novamente.', + 'admin.users.create.title': 'Adicionar usuário', + 'admin.users.create.submit': 'Criar usuário', + 'admin.users.create.error': 'Não foi possível criar o usuário. Tente novamente.', + 'admin.users.create.fields.type': 'Tipo de usuário', + 'admin.users.create.fields.username': 'Nome de usuário', + 'admin.users.create.fields.email': 'E-mail', + 'admin.users.create.fields.given_name': 'Nome', + 'admin.users.create.fields.family_name': 'Sobrenome', + 'admin.users.create.fields.password': 'Senha', + 'admin.users.details.subtitle': 'Tipo de usuário: {type}', + 'admin.users.details.attributes': 'Atributos', + 'admin.users.details.no_attributes': 'Nenhum atributo.', + 'admin.users.details.groups': 'Grupos', + 'admin.users.details.no_groups': 'Não participa de nenhum grupo.', + 'admin.users.details.error': 'Não foi possível carregar este usuário. Tente novamente.', + 'admin.users.details.delete.action': 'Excluir usuário', + 'admin.users.details.delete.confirm': 'Excluir {name}?', + 'admin.users.details.delete.error': 'Não foi possível excluir o usuário. Tente novamente.', + 'admin.users.details.edit.action': 'Editar', + 'admin.users.details.edit.save': 'Salvar alterações', + 'admin.users.details.edit.error': 'Não foi possível salvar o usuário. Tente novamente.', + 'admin.groups.list.title': 'Grupos', + 'admin.groups.list.columns.name': 'Nome', + 'admin.groups.list.columns.description': 'Descrição', + 'admin.groups.list.empty': 'Nenhum grupo encontrado.', + 'admin.groups.list.error': 'Não foi possível carregar os grupos. Tente novamente.', + 'admin.groups.create.title': 'Criar grupo', + 'admin.groups.create.submit': 'Criar grupo', + 'admin.groups.create.fields.name': 'Nome', + 'admin.groups.create.fields.description': 'Descrição', + 'admin.groups.create.error': 'Não foi possível criar o grupo. Tente novamente.', + 'admin.groups.members.title': 'Membros', + 'admin.groups.members.add': 'Adicionar membro', + 'admin.groups.members.columns.member': 'Membro', + 'admin.groups.members.columns.type': 'Tipo', + 'admin.groups.members.empty': 'Este grupo não tem membros.', + 'admin.groups.members.remove_label': 'Remover {name} do grupo', + 'admin.groups.members.error': 'Não foi possível carregar os membros. Tente novamente.', + 'admin.groups.members.update_error': 'Não foi possível atualizar os membros. Tente novamente.', + 'admin.roles.list.title': 'Funções', + 'admin.roles.list.columns.name': 'Nome', + 'admin.roles.list.columns.description': 'Descrição', + 'admin.roles.list.empty': 'Nenhuma função encontrada.', + 'admin.roles.list.error': 'Não foi possível carregar as funções. Tente novamente.', + 'admin.roles.create.title': 'Criar função', + 'admin.roles.create.submit': 'Criar função', + 'admin.roles.create.fields.name': 'Nome', + 'admin.roles.create.fields.description': 'Descrição', + 'admin.roles.create.error': 'Não foi possível criar a função. Tente novamente.', + 'admin.roles.assignments.title': 'Atribuída a', + 'admin.roles.assignments.add': 'Atribuir função', + 'admin.roles.assignments.columns.assignee': 'Atribuído', + 'admin.roles.assignments.columns.type': 'Tipo', + 'admin.roles.assignments.empty': 'Esta função não está atribuída a ninguém.', + 'admin.roles.assignments.remove_label': 'Remover a função de {name}', + 'admin.roles.assignments.error': 'Não foi possível carregar as atribuições. Tente novamente.', + 'admin.roles.assignments.update_error': 'Não foi possível atualizar as atribuições. Tente novamente.', + 'admin.roles.permissions.title': 'Permissões', + 'admin.roles.permissions.empty': 'Esta função não concede permissões.', + 'admin.roles.permissions.error': 'Não foi possível carregar as permissões. Tente novamente.', + 'admin.agents.list.title': 'Agentes', + 'admin.agents.list.columns.name': 'Nome', + 'admin.agents.list.columns.type': 'Tipo de agente', + 'admin.agents.list.columns.client_id': 'ID do cliente', + 'admin.agents.list.empty': 'Nenhum agente encontrado.', + 'admin.agents.list.error': 'Não foi possível carregar os agentes. Tente novamente.', + 'admin.agents.create.title': 'Registrar agente', + 'admin.agents.create.submit': 'Registrar agente', + 'admin.agents.create.fields.name': 'Nome', + 'admin.agents.create.fields.type': 'Tipo de agente', + 'admin.agents.create.fields.description': 'Descrição', + 'admin.agents.create.error': 'Não foi possível registrar o agente. Tente novamente.', + 'admin.agents.details.overview': 'Visão geral', + 'admin.agents.details.type': 'Tipo de agente', + 'admin.agents.details.owner': 'Responsável', + 'admin.agents.details.no_owner': 'Sem responsável', + 'admin.agents.details.groups': 'Grupos', + 'admin.agents.details.no_groups': 'Não participa de nenhum grupo.', + 'admin.agents.details.roles': 'Funções', + 'admin.agents.details.no_roles': 'Nenhuma função atribuída.', + 'admin.agents.details.error': 'Não foi possível carregar este agente. Tente novamente.', + 'admin.agents.details.delete.action': 'Excluir agente', + 'admin.agents.details.delete.confirm': 'Excluir {name}?', + 'admin.agents.details.delete.error': 'Não foi possível excluir o agente. Tente novamente.', }; const metadata: I18nMetadata = { diff --git a/packages/javascript/src/i18n/translations/pt-PT.ts b/packages/javascript/src/i18n/translations/pt-PT.ts index 2b7e7b73..6d368dd7 100644 --- a/packages/javascript/src/i18n/translations/pt-PT.ts +++ b/packages/javascript/src/i18n/translations/pt-PT.ts @@ -206,6 +206,129 @@ const translations: I18nTranslations = { 'errors.signin.components.not.available': 'O formulário de início de sessão não está disponível de momento. Tente novamente mais tarde.', 'errors.signin.timeout': 'O tempo permitido para completar a etapa expirou.', + /* Admin */ + 'admin.common.loading': 'A carregar…', + 'attribution.secured_by': 'Protegido por', + 'attribution.powered_by': 'Desenvolvido por', + 'admin.common.actions.cancel': 'Cancelar', + 'admin.common.actions.confirm': 'Confirmar', + 'admin.common.actions.remove': 'Remover', + 'admin.common.columns.actions': 'Ações', + 'admin.common.columns.organization_unit': 'Unidade organizacional', + 'admin.common.errors.forbidden': 'Não tem permissão para fazer isto.', + 'admin.common.errors.not_found': 'Não foi possível encontrar este item.', + 'admin.common.errors.no_ou': 'Escolha uma unidade organizacional antes de criar este item.', + 'admin.common.fields.identity': 'Nome', + 'admin.common.fields.identity_type': 'Tipo', + 'admin.common.fields.identity_placeholder': 'Selecione uma identidade', + 'admin.common.fields.identity_empty': 'Nada para adicionar', + 'admin.common.identity_types.user': 'Utilizador', + 'admin.common.identity_types.group': 'Grupo', + 'admin.common.identity_types.agent': 'Agente', + 'admin.common.identity_types.app': 'Aplicação', + 'admin.common.pagination.label': 'Paginação', + 'admin.common.pagination.previous': 'Anterior', + 'admin.common.pagination.next': 'Seguinte', + 'admin.common.pagination.range': '{start} a {end} de {total}', + 'admin.common.search.attribute': 'Pesquisar por', + 'admin.common.search.submit': 'Pesquisar', + 'admin.common.search.clear': 'Limpar pesquisa', + 'admin.common.search.placeholder': '{attribute} exato', + 'admin.common.search.no_results': 'Sem resultados para "{value}". A pesquisa corresponde ao valor completo.', + 'admin.common.filters.type': 'Tipo de atribuído', + 'admin.common.filters.all_types': 'Todos os tipos', + 'admin.users.list.search.username': 'Nome de utilizador', + 'admin.users.list.search.email': 'E-mail', + 'admin.agents.list.search.name': 'Nome', + 'admin.users.list.title': 'Utilizadores', + 'admin.users.list.columns.name': 'Nome', + 'admin.users.list.columns.type': 'Tipo de utilizador', + 'admin.users.list.empty': 'Nenhum utilizador encontrado.', + 'admin.users.list.error': 'Não foi possível carregar os utilizadores. Tente novamente.', + 'admin.users.create.title': 'Adicionar utilizador', + 'admin.users.create.submit': 'Criar utilizador', + 'admin.users.create.error': 'Não foi possível criar o utilizador. Tente novamente.', + 'admin.users.create.fields.type': 'Tipo de utilizador', + 'admin.users.create.fields.username': 'Nome de utilizador', + 'admin.users.create.fields.email': 'E-mail', + 'admin.users.create.fields.given_name': 'Nome próprio', + 'admin.users.create.fields.family_name': 'Apelido', + 'admin.users.create.fields.password': 'Palavra-passe', + 'admin.users.details.subtitle': 'Tipo de utilizador: {type}', + 'admin.users.details.attributes': 'Atributos', + 'admin.users.details.no_attributes': 'Sem atributos.', + 'admin.users.details.groups': 'Grupos', + 'admin.users.details.no_groups': 'Não pertence a nenhum grupo.', + 'admin.users.details.error': 'Não foi possível carregar este utilizador. Tente novamente.', + 'admin.users.details.delete.action': 'Eliminar utilizador', + 'admin.users.details.delete.confirm': 'Eliminar {name}?', + 'admin.users.details.delete.error': 'Não foi possível eliminar o utilizador. Tente novamente.', + 'admin.users.details.edit.action': 'Editar', + 'admin.users.details.edit.save': 'Guardar alterações', + 'admin.users.details.edit.error': 'Não foi possível guardar o utilizador. Tente novamente.', + 'admin.groups.list.title': 'Grupos', + 'admin.groups.list.columns.name': 'Nome', + 'admin.groups.list.columns.description': 'Descrição', + 'admin.groups.list.empty': 'Nenhum grupo encontrado.', + 'admin.groups.list.error': 'Não foi possível carregar os grupos. Tente novamente.', + 'admin.groups.create.title': 'Criar grupo', + 'admin.groups.create.submit': 'Criar grupo', + 'admin.groups.create.fields.name': 'Nome', + 'admin.groups.create.fields.description': 'Descrição', + 'admin.groups.create.error': 'Não foi possível criar o grupo. Tente novamente.', + 'admin.groups.members.title': 'Membros', + 'admin.groups.members.add': 'Adicionar membro', + 'admin.groups.members.columns.member': 'Membro', + 'admin.groups.members.columns.type': 'Tipo', + 'admin.groups.members.empty': 'Este grupo não tem membros.', + 'admin.groups.members.remove_label': 'Remover {name} do grupo', + 'admin.groups.members.error': 'Não foi possível carregar os membros. Tente novamente.', + 'admin.groups.members.update_error': 'Não foi possível atualizar os membros. Tente novamente.', + 'admin.roles.list.title': 'Funções', + 'admin.roles.list.columns.name': 'Nome', + 'admin.roles.list.columns.description': 'Descrição', + 'admin.roles.list.empty': 'Nenhuma função encontrada.', + 'admin.roles.list.error': 'Não foi possível carregar as funções. Tente novamente.', + 'admin.roles.create.title': 'Criar função', + 'admin.roles.create.submit': 'Criar função', + 'admin.roles.create.fields.name': 'Nome', + 'admin.roles.create.fields.description': 'Descrição', + 'admin.roles.create.error': 'Não foi possível criar a função. Tente novamente.', + 'admin.roles.assignments.title': 'Atribuída a', + 'admin.roles.assignments.add': 'Atribuir função', + 'admin.roles.assignments.columns.assignee': 'Destinatário', + 'admin.roles.assignments.columns.type': 'Tipo', + 'admin.roles.assignments.empty': 'Esta função não está atribuída a ninguém.', + 'admin.roles.assignments.remove_label': 'Remover a função de {name}', + 'admin.roles.assignments.error': 'Não foi possível carregar as atribuições. Tente novamente.', + 'admin.roles.assignments.update_error': 'Não foi possível atualizar as atribuições. Tente novamente.', + 'admin.roles.permissions.title': 'Permissões', + 'admin.roles.permissions.empty': 'Esta função não concede permissões.', + 'admin.roles.permissions.error': 'Não foi possível carregar as permissões. Tente novamente.', + 'admin.agents.list.title': 'Agentes', + 'admin.agents.list.columns.name': 'Nome', + 'admin.agents.list.columns.type': 'Tipo de agente', + 'admin.agents.list.columns.client_id': 'ID de cliente', + 'admin.agents.list.empty': 'Nenhum agente encontrado.', + 'admin.agents.list.error': 'Não foi possível carregar os agentes. Tente novamente.', + 'admin.agents.create.title': 'Registar agente', + 'admin.agents.create.submit': 'Registar agente', + 'admin.agents.create.fields.name': 'Nome', + 'admin.agents.create.fields.type': 'Tipo de agente', + 'admin.agents.create.fields.description': 'Descrição', + 'admin.agents.create.error': 'Não foi possível registar o agente. Tente novamente.', + 'admin.agents.details.overview': 'Visão geral', + 'admin.agents.details.type': 'Tipo de agente', + 'admin.agents.details.owner': 'Responsável', + 'admin.agents.details.no_owner': 'Sem responsável', + 'admin.agents.details.groups': 'Grupos', + 'admin.agents.details.no_groups': 'Não pertence a nenhum grupo.', + 'admin.agents.details.roles': 'Funções', + 'admin.agents.details.no_roles': 'Nenhuma função atribuída.', + 'admin.agents.details.error': 'Não foi possível carregar este agente. Tente novamente.', + 'admin.agents.details.delete.action': 'Eliminar agente', + 'admin.agents.details.delete.confirm': 'Eliminar {name}?', + 'admin.agents.details.delete.error': 'Não foi possível eliminar o agente. Tente novamente.', }; const metadata: I18nMetadata = { diff --git a/packages/javascript/src/i18n/translations/si-LK.ts b/packages/javascript/src/i18n/translations/si-LK.ts index 1118d341..eadde04d 100644 --- a/packages/javascript/src/i18n/translations/si-LK.ts +++ b/packages/javascript/src/i18n/translations/si-LK.ts @@ -204,6 +204,129 @@ const translations: I18nTranslations = { 'errors.signup.components.not.available': 'ගිණුම් තැනීමේ පිටුව දැන් ලබා ගත නොහැකිය. කරුණාකර පසුව නැවත උත්සාහ කරන්න.', 'errors.signin.components.not.available': 'ප්‍රවේශ වීමේ පිටුව දැන් ලබා ගත නොහැකිය. කරුණාකර පසුව නැවත උත්සාහ කරන්න.', 'errors.signin.timeout': 'පියවර සම්පූර්ණ කිරීමට ලබා දී තිබූ කාලය ඉකුත් වී ඇත.', + /* Admin */ + 'admin.common.loading': 'පූරණය වෙමින්…', + 'attribution.secured_by': 'ආරක්ෂාව සපයන්නේ', + 'attribution.powered_by': 'බලගැන්වෙන්නේ', + 'admin.common.actions.cancel': 'අවලංගු කරන්න', + 'admin.common.actions.confirm': 'තහවුරු කරන්න', + 'admin.common.actions.remove': 'ඉවත් කරන්න', + 'admin.common.columns.actions': 'ක්‍රියා', + 'admin.common.columns.organization_unit': 'සංවිධාන ඒකකය', + 'admin.common.errors.forbidden': 'මෙය කිරීමට ඔබට අවසර නැත.', + 'admin.common.errors.not_found': 'මෙම අයිතමය සොයාගත නොහැකි විය.', + 'admin.common.errors.no_ou': 'මෙම අයිතමය සෑදීමට පෙර සංවිධාන ඒකකයක් තෝරන්න.', + 'admin.common.fields.identity': 'නම', + 'admin.common.fields.identity_type': 'වර්ගය', + 'admin.common.fields.identity_placeholder': 'අනන්‍යතාවයක් තෝරන්න', + 'admin.common.fields.identity_empty': 'එක් කිරීමට කිසිවක් නැත', + 'admin.common.identity_types.user': 'පරිශීලකයා', + 'admin.common.identity_types.group': 'කණ්ඩායම', + 'admin.common.identity_types.agent': 'නියෝජිතයා', + 'admin.common.identity_types.app': 'යෙදුම', + 'admin.common.pagination.label': 'පිටු අංකනය', + 'admin.common.pagination.previous': 'පෙර', + 'admin.common.pagination.next': 'ඊළඟ', + 'admin.common.pagination.range': '{total} න් {start} සිට {end}', + 'admin.common.search.attribute': 'සෙවීම', + 'admin.common.search.submit': 'සොයන්න', + 'admin.common.search.clear': 'සෙවීම හිස් කරන්න', + 'admin.common.search.placeholder': 'නිවැරදි {attribute}', + 'admin.common.search.no_results': '"{value}" සඳහා ගැළපීම් නැත. සෙවීම සම්පූර්ණ අගයට ගැළපේ.', + 'admin.common.filters.type': 'පැවරුම් ලබන්නාගේ වර්ගය', + 'admin.common.filters.all_types': 'සියලු වර්ග', + 'admin.users.list.search.username': 'පරිශීලක නාමය', + 'admin.users.list.search.email': 'ඊමේල්', + 'admin.agents.list.search.name': 'නම', + 'admin.users.list.title': 'පරිශීලකයින්', + 'admin.users.list.columns.name': 'නම', + 'admin.users.list.columns.type': 'පරිශීලක වර්ගය', + 'admin.users.list.empty': 'පරිශීලකයින් හමු නොවීය.', + 'admin.users.list.error': 'පරිශීලකයින් පූරණය කළ නොහැකි විය. නැවත උත්සාහ කරන්න.', + 'admin.users.create.title': 'පරිශීලකයෙකු එක් කරන්න', + 'admin.users.create.submit': 'පරිශීලකයා සාදන්න', + 'admin.users.create.error': 'පරිශීලකයා සෑදිය නොහැකි විය. නැවත උත්සාහ කරන්න.', + 'admin.users.create.fields.type': 'පරිශීලක වර්ගය', + 'admin.users.create.fields.username': 'පරිශීලක නාමය', + 'admin.users.create.fields.email': 'ඊමේල්', + 'admin.users.create.fields.given_name': 'මුල් නම', + 'admin.users.create.fields.family_name': 'අවසාන නම', + 'admin.users.create.fields.password': 'මුරපදය', + 'admin.users.details.subtitle': 'පරිශීලක වර්ගය: {type}', + 'admin.users.details.attributes': 'ගුණාංග', + 'admin.users.details.no_attributes': 'ගුණාංග නැත.', + 'admin.users.details.groups': 'කණ්ඩායම්', + 'admin.users.details.no_groups': 'කිසිදු කණ්ඩායමක සාමාජිකයෙකු නොවේ.', + 'admin.users.details.error': 'මෙම පරිශීලකයා පූරණය කළ නොහැකි විය. නැවත උත්සාහ කරන්න.', + 'admin.users.details.delete.action': 'පරිශීලකයා මකන්න', + 'admin.users.details.delete.confirm': '{name} මකන්නද?', + 'admin.users.details.delete.error': 'පරිශීලකයා මැකිය නොහැකි විය. නැවත උත්සාහ කරන්න.', + 'admin.users.details.edit.action': 'සංස්කරණය', + 'admin.users.details.edit.save': 'වෙනස්කම් සුරකින්න', + 'admin.users.details.edit.error': 'පරිශීලකයා සුරැකිය නොහැකි විය. නැවත උත්සාහ කරන්න.', + 'admin.groups.list.title': 'කණ්ඩායම්', + 'admin.groups.list.columns.name': 'නම', + 'admin.groups.list.columns.description': 'විස්තරය', + 'admin.groups.list.empty': 'කණ්ඩායම් හමු නොවීය.', + 'admin.groups.list.error': 'කණ්ඩායම් පූරණය කළ නොහැකි විය. නැවත උත්සාහ කරන්න.', + 'admin.groups.create.title': 'කණ්ඩායමක් සාදන්න', + 'admin.groups.create.submit': 'කණ්ඩායම සාදන්න', + 'admin.groups.create.fields.name': 'නම', + 'admin.groups.create.fields.description': 'විස්තරය', + 'admin.groups.create.error': 'කණ්ඩායම සෑදිය නොහැකි විය. නැවත උත්සාහ කරන්න.', + 'admin.groups.members.title': 'සාමාජිකයින්', + 'admin.groups.members.add': 'සාමාජිකයෙකු එක් කරන්න', + 'admin.groups.members.columns.member': 'සාමාජිකයා', + 'admin.groups.members.columns.type': 'වර්ගය', + 'admin.groups.members.empty': 'මෙම කණ්ඩායමේ සාමාජිකයින් නැත.', + 'admin.groups.members.remove_label': '{name} කණ්ඩායමෙන් ඉවත් කරන්න', + 'admin.groups.members.error': 'සාමාජිකයින් පූරණය කළ නොහැකි විය. නැවත උත්සාහ කරන්න.', + 'admin.groups.members.update_error': 'සාමාජිකයින් යාවත්කාලීන කළ නොහැකි විය. නැවත උත්සාහ කරන්න.', + 'admin.roles.list.title': 'භූමිකා', + 'admin.roles.list.columns.name': 'නම', + 'admin.roles.list.columns.description': 'විස්තරය', + 'admin.roles.list.empty': 'භූමිකා හමු නොවීය.', + 'admin.roles.list.error': 'භූමිකා පූරණය කළ නොහැකි විය. නැවත උත්සාහ කරන්න.', + 'admin.roles.create.title': 'භූමිකාවක් සාදන්න', + 'admin.roles.create.submit': 'භූමිකාව සාදන්න', + 'admin.roles.create.fields.name': 'නම', + 'admin.roles.create.fields.description': 'විස්තරය', + 'admin.roles.create.error': 'භූමිකාව සෑදිය නොහැකි විය. නැවත උත්සාහ කරන්න.', + 'admin.roles.assignments.title': 'පවරා ඇත්තේ', + 'admin.roles.assignments.add': 'භූමිකාව පවරන්න', + 'admin.roles.assignments.columns.assignee': 'ලබන්නා', + 'admin.roles.assignments.columns.type': 'වර්ගය', + 'admin.roles.assignments.empty': 'මෙම භූමිකාව කිසිවෙකුට පවරා නැත.', + 'admin.roles.assignments.remove_label': '{name} ගෙන් භූමිකාව ඉවත් කරන්න', + 'admin.roles.assignments.error': 'පැවරීම් පූරණය කළ නොහැකි විය. නැවත උත්සාහ කරන්න.', + 'admin.roles.assignments.update_error': 'පැවරීම් යාවත්කාලීන කළ නොහැකි විය. නැවත උත්සාහ කරන්න.', + 'admin.roles.permissions.title': 'අවසර', + 'admin.roles.permissions.empty': 'මෙම භූමිකාව කිසිදු අවසරයක් ලබා නොදේ.', + 'admin.roles.permissions.error': 'අවසර පූරණය කළ නොහැකි විය. නැවත උත්සාහ කරන්න.', + 'admin.agents.list.title': 'නියෝජිතයින්', + 'admin.agents.list.columns.name': 'නම', + 'admin.agents.list.columns.type': 'නියෝජිත වර්ගය', + 'admin.agents.list.columns.client_id': 'සේවාලාභී හැඳුනුම්පත', + 'admin.agents.list.empty': 'නියෝජිතයින් හමු නොවීය.', + 'admin.agents.list.error': 'නියෝජිතයින් පූරණය කළ නොහැකි විය. නැවත උත්සාහ කරන්න.', + 'admin.agents.create.title': 'නියෝජිතයෙකු ලියාපදිංචි කරන්න', + 'admin.agents.create.submit': 'නියෝජිතයා ලියාපදිංචි කරන්න', + 'admin.agents.create.fields.name': 'නම', + 'admin.agents.create.fields.type': 'නියෝජිත වර්ගය', + 'admin.agents.create.fields.description': 'විස්තරය', + 'admin.agents.create.error': 'නියෝජිතයා ලියාපදිංචි කළ නොහැකි විය. නැවත උත්සාහ කරන්න.', + 'admin.agents.details.overview': 'දළ විශ්ලේෂණය', + 'admin.agents.details.type': 'නියෝජිත වර්ගය', + 'admin.agents.details.owner': 'හිමිකරු', + 'admin.agents.details.no_owner': 'හිමිකරුවෙකු නැත', + 'admin.agents.details.groups': 'කණ්ඩායම්', + 'admin.agents.details.no_groups': 'කිසිදු කණ්ඩායමක සාමාජිකයෙකු නොවේ.', + 'admin.agents.details.roles': 'භූමිකා', + 'admin.agents.details.no_roles': 'භූමිකා පවරා නැත.', + 'admin.agents.details.error': 'මෙම නියෝජිතයා පූරණය කළ නොහැකි විය. නැවත උත්සාහ කරන්න.', + 'admin.agents.details.delete.action': 'නියෝජිතයා මකන්න', + 'admin.agents.details.delete.confirm': '{name} මකන්නද?', + 'admin.agents.details.delete.error': 'නියෝජිතයා මැකිය නොහැකි විය. නැවත උත්සාහ කරන්න.', }; const metadata: I18nMetadata = { diff --git a/packages/javascript/src/i18n/translations/ta-IN.ts b/packages/javascript/src/i18n/translations/ta-IN.ts index ad539b1d..81fcb739 100644 --- a/packages/javascript/src/i18n/translations/ta-IN.ts +++ b/packages/javascript/src/i18n/translations/ta-IN.ts @@ -206,6 +206,129 @@ const translations: I18nTranslations = { 'errors.signup.components.not.available': 'பதிவு படிவம் இப்போது கிடைக்கவில்லை. பின்னர் மீண்டும் முயற்சிக்கவும்.', 'errors.signin.components.not.available': 'உள்நுழைவு படிவம் இப்போது கிடைக்கவில்லை. பின்னர் மீண்டும் முயற்சிக்கவும்.', 'errors.signin.timeout': 'படிநிலையை முடிக்க அனுமதிக்கப்பட்ட நேரம் காலாவதியானது.', + /* Admin */ + 'admin.common.loading': 'ஏற்றுகிறது…', + 'attribution.secured_by': 'பாதுகாப்பு வழங்குநர்', + 'attribution.powered_by': 'இயக்குவது', + 'admin.common.actions.cancel': 'ரத்துசெய்', + 'admin.common.actions.confirm': 'உறுதிப்படுத்து', + 'admin.common.actions.remove': 'அகற்று', + 'admin.common.columns.actions': 'செயல்கள்', + 'admin.common.columns.organization_unit': 'நிறுவன அலகு', + 'admin.common.errors.forbidden': 'இதைச் செய்ய உங்களுக்கு அனுமதி இல்லை.', + 'admin.common.errors.not_found': 'இந்த உருப்படியைக் கண்டறிய முடியவில்லை.', + 'admin.common.errors.no_ou': 'இந்த உருப்படியை உருவாக்கும் முன் ஒரு நிறுவன அலகைத் தேர்ந்தெடுக்கவும்.', + 'admin.common.fields.identity': 'பெயர்', + 'admin.common.fields.identity_type': 'வகை', + 'admin.common.fields.identity_placeholder': 'ஒரு அடையாளத்தைத் தேர்ந்தெடுக்கவும்', + 'admin.common.fields.identity_empty': 'சேர்க்க எதுவும் இல்லை', + 'admin.common.identity_types.user': 'பயனர்', + 'admin.common.identity_types.group': 'குழு', + 'admin.common.identity_types.agent': 'முகவர்', + 'admin.common.identity_types.app': 'பயன்பாடு', + 'admin.common.pagination.label': 'பக்க வரிசை', + 'admin.common.pagination.previous': 'முந்தைய', + 'admin.common.pagination.next': 'அடுத்து', + 'admin.common.pagination.range': '{total} இல் {start} முதல் {end} வரை', + 'admin.common.search.attribute': 'இதன்படி தேடு', + 'admin.common.search.submit': 'தேடு', + 'admin.common.search.clear': 'தேடலை அழி', + 'admin.common.search.placeholder': 'சரியான {attribute}', + 'admin.common.search.no_results': '"{value}" க்குப் பொருத்தங்கள் இல்லை. தேடல் முழு மதிப்புடன் பொருந்தும்.', + 'admin.common.filters.type': 'ஒதுக்கப்பட்டவர் வகை', + 'admin.common.filters.all_types': 'அனைத்து வகைகளும்', + 'admin.users.list.search.username': 'பயனர்பெயர்', + 'admin.users.list.search.email': 'மின்னஞ்சல்', + 'admin.agents.list.search.name': 'பெயர்', + 'admin.users.list.title': 'பயனர்கள்', + 'admin.users.list.columns.name': 'பெயர்', + 'admin.users.list.columns.type': 'பயனர் வகை', + 'admin.users.list.empty': 'பயனர்கள் எவரும் இல்லை.', + 'admin.users.list.error': 'பயனர்களை ஏற்ற முடியவில்லை. மீண்டும் முயற்சிக்கவும்.', + 'admin.users.create.title': 'பயனரைச் சேர்', + 'admin.users.create.submit': 'பயனரை உருவாக்கு', + 'admin.users.create.error': 'பயனரை உருவாக்க முடியவில்லை. மீண்டும் முயற்சிக்கவும்.', + 'admin.users.create.fields.type': 'பயனர் வகை', + 'admin.users.create.fields.username': 'பயனர்பெயர்', + 'admin.users.create.fields.email': 'மின்னஞ்சல்', + 'admin.users.create.fields.given_name': 'முதல் பெயர்', + 'admin.users.create.fields.family_name': 'கடைசி பெயர்', + 'admin.users.create.fields.password': 'கடவுச்சொல்', + 'admin.users.details.subtitle': 'பயனர் வகை: {type}', + 'admin.users.details.attributes': 'பண்புகள்', + 'admin.users.details.no_attributes': 'பண்புகள் இல்லை.', + 'admin.users.details.groups': 'குழுக்கள்', + 'admin.users.details.no_groups': 'எந்தக் குழுவிலும் உறுப்பினர் இல்லை.', + 'admin.users.details.error': 'இந்தப் பயனரை ஏற்ற முடியவில்லை. மீண்டும் முயற்சிக்கவும்.', + 'admin.users.details.delete.action': 'பயனரை நீக்கு', + 'admin.users.details.delete.confirm': '{name} ஐ நீக்கவா?', + 'admin.users.details.delete.error': 'பயனரை நீக்க முடியவில்லை. மீண்டும் முயற்சிக்கவும்.', + 'admin.users.details.edit.action': 'திருத்து', + 'admin.users.details.edit.save': 'மாற்றங்களைச் சேமி', + 'admin.users.details.edit.error': 'பயனரைச் சேமிக்க முடியவில்லை. மீண்டும் முயற்சிக்கவும்.', + 'admin.groups.list.title': 'குழுக்கள்', + 'admin.groups.list.columns.name': 'பெயர்', + 'admin.groups.list.columns.description': 'விளக்கம்', + 'admin.groups.list.empty': 'குழுக்கள் எதுவும் இல்லை.', + 'admin.groups.list.error': 'குழுக்களை ஏற்ற முடியவில்லை. மீண்டும் முயற்சிக்கவும்.', + 'admin.groups.create.title': 'குழுவை உருவாக்கு', + 'admin.groups.create.submit': 'குழுவை உருவாக்கு', + 'admin.groups.create.fields.name': 'பெயர்', + 'admin.groups.create.fields.description': 'விளக்கம்', + 'admin.groups.create.error': 'குழுவை உருவாக்க முடியவில்லை. மீண்டும் முயற்சிக்கவும்.', + 'admin.groups.members.title': 'உறுப்பினர்கள்', + 'admin.groups.members.add': 'உறுப்பினரைச் சேர்', + 'admin.groups.members.columns.member': 'உறுப்பினர்', + 'admin.groups.members.columns.type': 'வகை', + 'admin.groups.members.empty': 'இந்தக் குழுவில் உறுப்பினர்கள் இல்லை.', + 'admin.groups.members.remove_label': '{name} ஐ குழுவிலிருந்து அகற்று', + 'admin.groups.members.error': 'உறுப்பினர்களை ஏற்ற முடியவில்லை. மீண்டும் முயற்சிக்கவும்.', + 'admin.groups.members.update_error': 'உறுப்பினர்களைப் புதுப்பிக்க முடியவில்லை. மீண்டும் முயற்சிக்கவும்.', + 'admin.roles.list.title': 'பங்குகள்', + 'admin.roles.list.columns.name': 'பெயர்', + 'admin.roles.list.columns.description': 'விளக்கம்', + 'admin.roles.list.empty': 'பங்குகள் எதுவும் இல்லை.', + 'admin.roles.list.error': 'பங்குகளை ஏற்ற முடியவில்லை. மீண்டும் முயற்சிக்கவும்.', + 'admin.roles.create.title': 'பங்கை உருவாக்கு', + 'admin.roles.create.submit': 'பங்கை உருவாக்கு', + 'admin.roles.create.fields.name': 'பெயர்', + 'admin.roles.create.fields.description': 'விளக்கம்', + 'admin.roles.create.error': 'பங்கை உருவாக்க முடியவில்லை. மீண்டும் முயற்சிக்கவும்.', + 'admin.roles.assignments.title': 'ஒதுக்கப்பட்டவர்கள்', + 'admin.roles.assignments.add': 'பங்கை ஒதுக்கு', + 'admin.roles.assignments.columns.assignee': 'பெறுநர்', + 'admin.roles.assignments.columns.type': 'வகை', + 'admin.roles.assignments.empty': 'இந்தப் பங்கு யாருக்கும் ஒதுக்கப்படவில்லை.', + 'admin.roles.assignments.remove_label': '{name} இடமிருந்து பங்கை அகற்று', + 'admin.roles.assignments.error': 'ஒதுக்கீடுகளை ஏற்ற முடியவில்லை. மீண்டும் முயற்சிக்கவும்.', + 'admin.roles.assignments.update_error': 'ஒதுக்கீடுகளைப் புதுப்பிக்க முடியவில்லை. மீண்டும் முயற்சிக்கவும்.', + 'admin.roles.permissions.title': 'அனுமதிகள்', + 'admin.roles.permissions.empty': 'இந்தப் பங்கு எந்த அனுமதியையும் வழங்கவில்லை.', + 'admin.roles.permissions.error': 'அனுமதிகளை ஏற்ற முடியவில்லை. மீண்டும் முயற்சிக்கவும்.', + 'admin.agents.list.title': 'முகவர்கள்', + 'admin.agents.list.columns.name': 'பெயர்', + 'admin.agents.list.columns.type': 'முகவர் வகை', + 'admin.agents.list.columns.client_id': 'கிளையன்ட் ஐடி', + 'admin.agents.list.empty': 'முகவர்கள் எவரும் இல்லை.', + 'admin.agents.list.error': 'முகவர்களை ஏற்ற முடியவில்லை. மீண்டும் முயற்சிக்கவும்.', + 'admin.agents.create.title': 'முகவரைப் பதிவு செய்', + 'admin.agents.create.submit': 'முகவரைப் பதிவு செய்', + 'admin.agents.create.fields.name': 'பெயர்', + 'admin.agents.create.fields.type': 'முகவர் வகை', + 'admin.agents.create.fields.description': 'விளக்கம்', + 'admin.agents.create.error': 'முகவரைப் பதிவு செய்ய முடியவில்லை. மீண்டும் முயற்சிக்கவும்.', + 'admin.agents.details.overview': 'மேலோட்டம்', + 'admin.agents.details.type': 'முகவர் வகை', + 'admin.agents.details.owner': 'உரிமையாளர்', + 'admin.agents.details.no_owner': 'உரிமையாளர் இல்லை', + 'admin.agents.details.groups': 'குழுக்கள்', + 'admin.agents.details.no_groups': 'எந்தக் குழுவிலும் உறுப்பினர் இல்லை.', + 'admin.agents.details.roles': 'பங்குகள்', + 'admin.agents.details.no_roles': 'பங்குகள் எதுவும் ஒதுக்கப்படவில்லை.', + 'admin.agents.details.error': 'இந்த முகவரை ஏற்ற முடியவில்லை. மீண்டும் முயற்சிக்கவும்.', + 'admin.agents.details.delete.action': 'முகவரை நீக்கு', + 'admin.agents.details.delete.confirm': '{name} ஐ நீக்கவா?', + 'admin.agents.details.delete.error': 'முகவரை நீக்க முடியவில்லை. மீண்டும் முயற்சிக்கவும்.', }; const metadata: I18nMetadata = { diff --git a/packages/javascript/src/i18n/translations/te-IN.ts b/packages/javascript/src/i18n/translations/te-IN.ts index ec41eed2..9e2f3867 100644 --- a/packages/javascript/src/i18n/translations/te-IN.ts +++ b/packages/javascript/src/i18n/translations/te-IN.ts @@ -206,6 +206,129 @@ const translations: I18nTranslations = { 'errors.signin.components.not.available': 'సైన్ ఇన్ ఫారం ప్రస్తుతం అందుబాటులో లేదు. దయచేసి తరువాత మళ్లీ ప్రయత్నించండి.', 'errors.signin.timeout': 'దశను పూర్తి చేయడానికి అనుమతించబడిన సమయం ముగిసింది.', + /* Admin */ + 'admin.common.loading': 'లోడ్ అవుతోంది…', + 'attribution.secured_by': 'భద్రత అందించేది', + 'attribution.powered_by': 'ఆధారితం', + 'admin.common.actions.cancel': 'రద్దు చేయి', + 'admin.common.actions.confirm': 'నిర్ధారించు', + 'admin.common.actions.remove': 'తొలగించు', + 'admin.common.columns.actions': 'చర్యలు', + 'admin.common.columns.organization_unit': 'సంస్థ యూనిట్', + 'admin.common.errors.forbidden': 'దీన్ని చేయడానికి మీకు అనుమతి లేదు.', + 'admin.common.errors.not_found': 'ఈ అంశం కనుగొనబడలేదు.', + 'admin.common.errors.no_ou': 'ఈ అంశాన్ని సృష్టించే ముందు ఒక సంస్థ యూనిట్‌ను ఎంచుకోండి.', + 'admin.common.fields.identity': 'పేరు', + 'admin.common.fields.identity_type': 'రకం', + 'admin.common.fields.identity_placeholder': 'ఒక గుర్తింపును ఎంచుకోండి', + 'admin.common.fields.identity_empty': 'జోడించడానికి ఏమీ లేదు', + 'admin.common.identity_types.user': 'వినియోగదారు', + 'admin.common.identity_types.group': 'సమూహం', + 'admin.common.identity_types.agent': 'ఏజెంట్', + 'admin.common.identity_types.app': 'అప్లికేషన్', + 'admin.common.pagination.label': 'పేజీలు', + 'admin.common.pagination.previous': 'మునుపటి', + 'admin.common.pagination.next': 'తదుపరి', + 'admin.common.pagination.range': '{total} లో {start} నుండి {end}', + 'admin.common.search.attribute': 'దీని ద్వారా వెతకండి', + 'admin.common.search.submit': 'వెతకండి', + 'admin.common.search.clear': 'శోధనను క్లియర్ చేయండి', + 'admin.common.search.placeholder': 'ఖచ్చితమైన {attribute}', + 'admin.common.search.no_results': '"{value}" కి సరిపోలికలు లేవు. శోధన మొత్తం విలువతో సరిపోలుతుంది.', + 'admin.common.filters.type': 'కేటాయించబడిన వారి రకం', + 'admin.common.filters.all_types': 'అన్ని రకాలు', + 'admin.users.list.search.username': 'వినియోగదారు పేరు', + 'admin.users.list.search.email': 'ఇమెయిల్', + 'admin.agents.list.search.name': 'పేరు', + 'admin.users.list.title': 'వినియోగదారులు', + 'admin.users.list.columns.name': 'పేరు', + 'admin.users.list.columns.type': 'వినియోగదారు రకం', + 'admin.users.list.empty': 'వినియోగదారులు ఎవరూ కనుగొనబడలేదు.', + 'admin.users.list.error': 'వినియోగదారులను లోడ్ చేయలేకపోయాము. దయచేసి మళ్లీ ప్రయత్నించండి.', + 'admin.users.create.title': 'వినియోగదారుని జోడించు', + 'admin.users.create.submit': 'వినియోగదారుని సృష్టించు', + 'admin.users.create.error': 'వినియోగదారుని సృష్టించలేకపోయాము. దయచేసి మళ్లీ ప్రయత్నించండి.', + 'admin.users.create.fields.type': 'వినియోగదారు రకం', + 'admin.users.create.fields.username': 'వినియోగదారు పేరు', + 'admin.users.create.fields.email': 'ఇమెయిల్', + 'admin.users.create.fields.given_name': 'మొదటి పేరు', + 'admin.users.create.fields.family_name': 'చివరి పేరు', + 'admin.users.create.fields.password': 'పాస్‌వర్డ్', + 'admin.users.details.subtitle': 'వినియోగదారు రకం: {type}', + 'admin.users.details.attributes': 'లక్షణాలు', + 'admin.users.details.no_attributes': 'లక్షణాలు లేవు.', + 'admin.users.details.groups': 'సమూహాలు', + 'admin.users.details.no_groups': 'ఏ సమూహంలోనూ సభ్యులు కాదు.', + 'admin.users.details.error': 'ఈ వినియోగదారుని లోడ్ చేయలేకపోయాము. దయచేసి మళ్లీ ప్రయత్నించండి.', + 'admin.users.details.delete.action': 'వినియోగదారుని తొలగించు', + 'admin.users.details.delete.confirm': '{name} ను తొలగించాలా?', + 'admin.users.details.delete.error': 'వినియోగదారుని తొలగించలేకపోయాము. దయచేసి మళ్లీ ప్రయత్నించండి.', + 'admin.users.details.edit.action': 'సవరించు', + 'admin.users.details.edit.save': 'మార్పులను సేవ్ చేయి', + 'admin.users.details.edit.error': 'వినియోగదారుని సేవ్ చేయలేకపోయాము. దయచేసి మళ్లీ ప్రయత్నించండి.', + 'admin.groups.list.title': 'సమూహాలు', + 'admin.groups.list.columns.name': 'పేరు', + 'admin.groups.list.columns.description': 'వివరణ', + 'admin.groups.list.empty': 'సమూహాలు ఏవీ కనుగొనబడలేదు.', + 'admin.groups.list.error': 'సమూహాలను లోడ్ చేయలేకపోయాము. దయచేసి మళ్లీ ప్రయత్నించండి.', + 'admin.groups.create.title': 'సమూహాన్ని సృష్టించు', + 'admin.groups.create.submit': 'సమూహాన్ని సృష్టించు', + 'admin.groups.create.fields.name': 'పేరు', + 'admin.groups.create.fields.description': 'వివరణ', + 'admin.groups.create.error': 'సమూహాన్ని సృష్టించలేకపోయాము. దయచేసి మళ్లీ ప్రయత్నించండి.', + 'admin.groups.members.title': 'సభ్యులు', + 'admin.groups.members.add': 'సభ్యుడిని జోడించు', + 'admin.groups.members.columns.member': 'సభ్యుడు', + 'admin.groups.members.columns.type': 'రకం', + 'admin.groups.members.empty': 'ఈ సమూహంలో సభ్యులు లేరు.', + 'admin.groups.members.remove_label': '{name} ను సమూహం నుండి తొలగించు', + 'admin.groups.members.error': 'సభ్యులను లోడ్ చేయలేకపోయాము. దయచేసి మళ్లీ ప్రయత్నించండి.', + 'admin.groups.members.update_error': 'సభ్యులను నవీకరించలేకపోయాము. దయచేసి మళ్లీ ప్రయత్నించండి.', + 'admin.roles.list.title': 'పాత్రలు', + 'admin.roles.list.columns.name': 'పేరు', + 'admin.roles.list.columns.description': 'వివరణ', + 'admin.roles.list.empty': 'పాత్రలు ఏవీ కనుగొనబడలేదు.', + 'admin.roles.list.error': 'పాత్రలను లోడ్ చేయలేకపోయాము. దయచేసి మళ్లీ ప్రయత్నించండి.', + 'admin.roles.create.title': 'పాత్రను సృష్టించు', + 'admin.roles.create.submit': 'పాత్రను సృష్టించు', + 'admin.roles.create.fields.name': 'పేరు', + 'admin.roles.create.fields.description': 'వివరణ', + 'admin.roles.create.error': 'పాత్రను సృష్టించలేకపోయాము. దయచేసి మళ్లీ ప్రయత్నించండి.', + 'admin.roles.assignments.title': 'కేటాయించినది', + 'admin.roles.assignments.add': 'పాత్రను కేటాయించు', + 'admin.roles.assignments.columns.assignee': 'స్వీకర్త', + 'admin.roles.assignments.columns.type': 'రకం', + 'admin.roles.assignments.empty': 'ఈ పాత్ర ఎవరికీ కేటాయించబడలేదు.', + 'admin.roles.assignments.remove_label': '{name} నుండి పాత్రను తొలగించు', + 'admin.roles.assignments.error': 'కేటాయింపులను లోడ్ చేయలేకపోయాము. దయచేసి మళ్లీ ప్రయత్నించండి.', + 'admin.roles.assignments.update_error': 'కేటాయింపులను నవీకరించలేకపోయాము. దయచేసి మళ్లీ ప్రయత్నించండి.', + 'admin.roles.permissions.title': 'అనుమతులు', + 'admin.roles.permissions.empty': 'ఈ పాత్ర ఎటువంటి అనుమతులను ఇవ్వదు.', + 'admin.roles.permissions.error': 'అనుమతులను లోడ్ చేయలేకపోయాము. దయచేసి మళ్లీ ప్రయత్నించండి.', + 'admin.agents.list.title': 'ఏజెంట్లు', + 'admin.agents.list.columns.name': 'పేరు', + 'admin.agents.list.columns.type': 'ఏజెంట్ రకం', + 'admin.agents.list.columns.client_id': 'క్లయింట్ ID', + 'admin.agents.list.empty': 'ఏజెంట్లు ఎవరూ కనుగొనబడలేదు.', + 'admin.agents.list.error': 'ఏజెంట్లను లోడ్ చేయలేకపోయాము. దయచేసి మళ్లీ ప్రయత్నించండి.', + 'admin.agents.create.title': 'ఏజెంట్‌ను నమోదు చేయి', + 'admin.agents.create.submit': 'ఏజెంట్‌ను నమోదు చేయి', + 'admin.agents.create.fields.name': 'పేరు', + 'admin.agents.create.fields.type': 'ఏజెంట్ రకం', + 'admin.agents.create.fields.description': 'వివరణ', + 'admin.agents.create.error': 'ఏజెంట్‌ను నమోదు చేయలేకపోయాము. దయచేసి మళ్లీ ప్రయత్నించండి.', + 'admin.agents.details.overview': 'అవలోకనం', + 'admin.agents.details.type': 'ఏజెంట్ రకం', + 'admin.agents.details.owner': 'యజమాని', + 'admin.agents.details.no_owner': 'యజమాని లేరు', + 'admin.agents.details.groups': 'సమూహాలు', + 'admin.agents.details.no_groups': 'ఏ సమూహంలోనూ సభ్యులు కాదు.', + 'admin.agents.details.roles': 'పాత్రలు', + 'admin.agents.details.no_roles': 'పాత్రలు ఏవీ కేటాయించబడలేదు.', + 'admin.agents.details.error': 'ఈ ఏజెంట్‌ను లోడ్ చేయలేకపోయాము. దయచేసి మళ్లీ ప్రయత్నించండి.', + 'admin.agents.details.delete.action': 'ఏజెంట్‌ను తొలగించు', + 'admin.agents.details.delete.confirm': '{name} ను తొలగించాలా?', + 'admin.agents.details.delete.error': 'ఏజెంట్‌ను తొలగించలేకపోయాము. దయచేసి మళ్లీ ప్రయత్నించండి.', }; const metadata: I18nMetadata = { diff --git a/packages/javascript/src/index.ts b/packages/javascript/src/index.ts index c093def9..dff740bf 100644 --- a/packages/javascript/src/index.ts +++ b/packages/javascript/src/index.ts @@ -3,62 +3,102 @@ export {IsomorphicCrypto} from './IsomorphicCrypto'; -export {default as executeEmbeddedSignInFlow} from './api/executeEmbeddedSignInFlow'; -export {default as executeEmbeddedSignUpFlow} from './api/executeEmbeddedSignUpFlow'; -export {default as executeEmbeddedRecoveryFlow} from './api/executeEmbeddedRecoveryFlow'; -export {default as executeEmbeddedUserOnboardingFlow} from './api/executeEmbeddedUserOnboardingFlow'; -export type {EmbeddedUserOnboardingFlowResponse} from './api/executeEmbeddedUserOnboardingFlow'; -export {default as getFlowMeta} from './api/getFlowMeta'; -export {default as getOrganizationUnitChildren} from './api/getOrganizationUnitChildren'; +export {default as executeEmbeddedSignInFlow} from './api/auth/executeEmbeddedSignInFlow'; +export {default as executeEmbeddedSignUpFlow} from './api/auth/executeEmbeddedSignUpFlow'; +export {default as executeEmbeddedRecoveryFlow} from './api/auth/executeEmbeddedRecoveryFlow'; +export {default as executeEmbeddedUserOnboardingFlow} from './api/auth/executeEmbeddedUserOnboardingFlow'; +export type {EmbeddedUserOnboardingFlowResponse} from './api/auth/executeEmbeddedUserOnboardingFlow'; +export {default as getFlowMeta} from './api/auth/getFlowMeta'; +export {default as getOrganizationUnitChildren} from './api/management/getOrganizationUnitChildren'; export type { GetOrganizationUnitChildrenConfig, OrganizationUnit, OrganizationUnitListResponse, -} from './api/getOrganizationUnitChildren'; -export {default as getUserInfo} from './api/getUserInfo'; -export {default as getUsersMe} from './api/getUsersMe'; -export type {GetUsersMeConfig} from './api/getUsersMe'; -export {default as getUsersMeMeta} from './api/getUsersMeMeta'; -export type {GetUsersMeMetaConfig, UsersMeMetaResponse, AttributeSchema} from './api/getUsersMeMeta'; -export {default as updateMeCredentials} from './api/updateMeCredentials'; -export type {UpdateMeCredentialsConfig} from './api/updateMeCredentials'; -export {default as updateMeProfile} from './api/updateMeProfile'; -export type {UpdateMeProfileConfig} from './api/updateMeProfile'; +} from './api/management/getOrganizationUnitChildren'; +export {default as getUserInfo} from './api/auth/getUserInfo'; +export {default as getUsersMe} from './api/account/getUsersMe'; +export type {GetUsersMeConfig} from './api/account/getUsersMe'; +export {default as getUsersMeMeta} from './api/account/getUsersMeMeta'; +export type {GetUsersMeMetaConfig, UsersMeMetaResponse, AttributeSchema} from './api/account/getUsersMeMeta'; +export {default as updateMeCredentials} from './api/account/updateMeCredentials'; +export type {UpdateMeCredentialsConfig} from './api/account/updateMeCredentials'; +export {default as updateMeProfile} from './api/account/updateMeProfile'; +export type {UpdateMeProfileConfig} from './api/account/updateMeProfile'; -export {default as getApplications} from './api/applications/getApplications'; -export type {GetApplicationsConfig} from './api/applications/getApplications'; -export {default as getApplication} from './api/applications/getApplication'; -export type {GetApplicationConfig} from './api/applications/getApplication'; -export {default as createApplication} from './api/applications/createApplication'; -export type {CreateApplicationConfig} from './api/applications/createApplication'; -export {default as updateApplication} from './api/applications/updateApplication'; -export type {UpdateApplicationConfig} from './api/applications/updateApplication'; -export {default as deleteApplication} from './api/applications/deleteApplication'; -export type {DeleteApplicationConfig} from './api/applications/deleteApplication'; -export {default as getUsers} from './api/users/getUsers'; -export type {GetUsersConfig} from './api/users/getUsers'; -export {default as getFlows} from './api/flows/getFlows'; -export type {GetFlowsConfig} from './api/flows/getFlows'; -export {default as getUserTypes} from './api/user-types/getUserTypes'; -export type {GetUserTypesConfig} from './api/user-types/getUserTypes'; -export {default as getUser} from './api/users/getUser'; -export type {GetUserConfig} from './api/users/getUser'; -export {default as createUser} from './api/users/createUser'; -export type {CreateUserConfig} from './api/users/createUser'; -export {default as updateUser} from './api/users/updateUser'; -export type {UpdateUserConfig} from './api/users/updateUser'; -export {default as deleteUser} from './api/users/deleteUser'; -export type {DeleteUserConfig} from './api/users/deleteUser'; -export {default as getAgents} from './api/agents/getAgents'; -export type {GetAgentsConfig} from './api/agents/getAgents'; -export {default as getAgent} from './api/agents/getAgent'; -export type {GetAgentConfig} from './api/agents/getAgent'; -export {default as createAgent} from './api/agents/createAgent'; -export type {CreateAgentConfig} from './api/agents/createAgent'; -export {default as updateAgent} from './api/agents/updateAgent'; -export type {UpdateAgentConfig} from './api/agents/updateAgent'; -export {default as deleteAgent} from './api/agents/deleteAgent'; -export type {DeleteAgentConfig} from './api/agents/deleteAgent'; +export {default as getApplications} from './api/management/applications/getApplications'; +export type {GetApplicationsConfig} from './api/management/applications/getApplications'; +export {default as getApplication} from './api/management/applications/getApplication'; +export type {GetApplicationConfig} from './api/management/applications/getApplication'; +export {default as createApplication} from './api/management/applications/createApplication'; +export type {CreateApplicationConfig} from './api/management/applications/createApplication'; +export {default as updateApplication} from './api/management/applications/updateApplication'; +export type {UpdateApplicationConfig} from './api/management/applications/updateApplication'; +export {default as deleteApplication} from './api/management/applications/deleteApplication'; +export type {DeleteApplicationConfig} from './api/management/applications/deleteApplication'; +export {default as getUsers} from './api/management/users/getUsers'; +export type {GetUsersConfig} from './api/management/users/getUsers'; +export {default as getFlows} from './api/auth/flows/getFlows'; +export type {GetFlowsConfig} from './api/auth/flows/getFlows'; +export {default as getUserTypes} from './api/management/user-types/getUserTypes'; +export type {GetUserTypesConfig} from './api/management/user-types/getUserTypes'; +export {default as getUser} from './api/management/users/getUser'; +export type {GetUserConfig} from './api/management/users/getUser'; +export {default as createUser} from './api/management/users/createUser'; +export type {CreateUserConfig} from './api/management/users/createUser'; +export {default as updateUser} from './api/management/users/updateUser'; +export type {UpdateUserConfig} from './api/management/users/updateUser'; +export {default as deleteUser} from './api/management/users/deleteUser'; +export type {DeleteUserConfig} from './api/management/users/deleteUser'; +export {default as getAgents} from './api/management/agents/getAgents'; +export type {GetAgentsConfig} from './api/management/agents/getAgents'; +export {default as getAgent} from './api/management/agents/getAgent'; +export type {GetAgentConfig} from './api/management/agents/getAgent'; +export {default as createAgent} from './api/management/agents/createAgent'; +export type {CreateAgentConfig} from './api/management/agents/createAgent'; +export {default as updateAgent} from './api/management/agents/updateAgent'; +export type {UpdateAgentConfig} from './api/management/agents/updateAgent'; +export {default as deleteAgent} from './api/management/agents/deleteAgent'; +export type {DeleteAgentConfig} from './api/management/agents/deleteAgent'; +export {default as getRoles} from './api/management/roles/getRoles'; +export type {GetRolesConfig} from './api/management/roles/getRoles'; +export {default as getRole} from './api/management/roles/getRole'; +export type {GetRoleConfig} from './api/management/roles/getRole'; +export {default as createRole} from './api/management/roles/createRole'; +export type {CreateRoleConfig} from './api/management/roles/createRole'; +export {default as updateRole} from './api/management/roles/updateRole'; +export type {UpdateRoleConfig} from './api/management/roles/updateRole'; +export {default as deleteRole} from './api/management/roles/deleteRole'; +export type {DeleteRoleConfig} from './api/management/roles/deleteRole'; +export {default as getRoleAssignments} from './api/management/roles/getRoleAssignments'; +export type {GetRoleAssignmentsConfig} from './api/management/roles/getRoleAssignments'; +export {default as addRoleAssignments} from './api/management/roles/addRoleAssignments'; +export type {AddRoleAssignmentsConfig} from './api/management/roles/addRoleAssignments'; +export {default as removeRoleAssignments} from './api/management/roles/removeRoleAssignments'; +export type {RemoveRoleAssignmentsConfig} from './api/management/roles/removeRoleAssignments'; +export {default as getGroups} from './api/management/groups/getGroups'; +export type {GetGroupsConfig} from './api/management/groups/getGroups'; +export {default as getGroup} from './api/management/groups/getGroup'; +export type {GetGroupConfig} from './api/management/groups/getGroup'; +export {default as createGroup} from './api/management/groups/createGroup'; +export type {CreateGroupConfig} from './api/management/groups/createGroup'; +export {default as updateGroup} from './api/management/groups/updateGroup'; +export type {UpdateGroupConfig} from './api/management/groups/updateGroup'; +export {default as deleteGroup} from './api/management/groups/deleteGroup'; +export type {DeleteGroupConfig} from './api/management/groups/deleteGroup'; +export {default as getGroupMembers} from './api/management/groups/getGroupMembers'; +export type {GetGroupMembersConfig} from './api/management/groups/getGroupMembers'; +export {default as addGroupMembers} from './api/management/groups/addGroupMembers'; +export type {AddGroupMembersConfig} from './api/management/groups/addGroupMembers'; +export {default as removeGroupMembers} from './api/management/groups/removeGroupMembers'; +export type {RemoveGroupMembersConfig} from './api/management/groups/removeGroupMembers'; +export {default as getUserGroups} from './api/management/users/getUserGroups'; +export type {GetUserGroupsConfig} from './api/management/users/getUserGroups'; +export {default as getAgentGroups} from './api/management/agents/getAgentGroups'; +export type {GetAgentGroupsConfig} from './api/management/agents/getAgentGroups'; +export {default as getAgentRoles} from './api/management/agents/getAgentRoles'; +export type {GetAgentRolesConfig} from './api/management/agents/getAgentRoles'; +export {default as getAgentTypes} from './api/management/agents/getAgentTypes'; +export type {GetAgentTypesConfig} from './api/management/agents/getAgentTypes'; export {default as ApplicationNativeAuthenticationConstants} from './constants/ApplicationNativeAuthenticationConstants'; export {default as TokenConstants} from './constants/TokenConstants'; @@ -70,6 +110,8 @@ export {default as ConsentConstants} from './constants/ConsentConstants'; export {default as ApplicationQueryKeys} from './constants/ApplicationQueryKeys'; export {default as UserQueryKeys} from './constants/UserQueryKeys'; export {default as AgentQueryKeys} from './constants/AgentQueryKeys'; +export {default as RoleQueryKeys} from './constants/RoleQueryKeys'; +export {default as GroupQueryKeys} from './constants/GroupQueryKeys'; export {default as UserSelectConstants} from './constants/UserSelectConstants'; export {default as AuthFlowSelectConstants} from './constants/AuthFlowSelectConstants'; export {default as UserTypeSelectConstants} from './constants/UserTypeSelectConstants'; @@ -246,10 +288,36 @@ export type { AgentInboundAuthConfig, AgentListResponse, AgentLoginConsentConfig, + AgentRoleListResponse, + AgentType, + AgentTypeListResponse, BasicAgent, CreateAgentRequest, UpdateAgentRequest, } from './models/agent-resource'; +export type { + CreateRoleRequest, + Role, + RoleAssigneeType, + RoleAssignment, + RoleAssignmentInput, + RoleAssignmentListResponse, + RoleListResponse, + RoleResourcePermissions, + RoleSummary, + UpdateRoleRequest, +} from './models/role'; +export type { + CreateGroupRequest, + Group, + GroupListResponse, + GroupMember, + GroupMemberListResponse, + GroupMemberType, + MembershipGroup, + MembershipGroupListResponse, + UpdateGroupRequest, +} from './models/group'; export type {AuthCodeResponse} from './models/auth-code-response'; export type {Crypto, JWKInterface} from './models/crypto'; export type {OAuthResponseMode} from './models/oauth-response'; @@ -274,6 +342,15 @@ export {default as createTheme, DEFAULT_THEME} from './theme/createTheme'; export type {ThemeColors, ThemeConfig, Theme, ThemeMode, ThemeDetection} from './theme/types'; export {default as AuthenticationHelper} from './utils/AuthenticationHelper'; +export {default as ThunderIDWordmark} from './constants/ThunderIDWordmark'; +export type {ThunderIDWordmarkPath} from './constants/ThunderIDWordmark'; +export {default as getTokenPermissions} from './utils/getTokenPermissions'; +export {default as createResourceInvalidator, fallbackResourceInvalidator} from './utils/createResourceInvalidator'; +export type {ResourceInvalidator, ResourceQueryKey} from './utils/createResourceInvalidator'; +export {default as toEqualityFilter} from './utils/toEqualityFilter'; +export {default as toManagementErrorMessage} from './utils/toManagementErrorMessage'; +export {default as resolveManagedIdentityName} from './utils/resolveManagedIdentityName'; +export {default as hasPermission} from './utils/hasPermission'; export {default as arrayBufferToBase64url} from './utils/arrayBufferToBase64url'; export {default as base64urlToArrayBuffer} from './utils/base64urlToArrayBuffer'; export {default as bem} from './utils/bem'; diff --git a/packages/javascript/src/models/agent-resource.ts b/packages/javascript/src/models/agent-resource.ts index 388d8c0a..f1a7c1e2 100644 --- a/packages/javascript/src/models/agent-resource.ts +++ b/packages/javascript/src/models/agent-resource.ts @@ -103,3 +103,35 @@ export interface UpdateAgentRequest { registrationFlowId?: string; type?: string; } + +/** + * A page of the role names assigned to an agent. + */ +export interface AgentRoleListResponse { + count: number; + roles: string[]; + startIndex: number; + totalResults: number; +} + +/** + * A kind of agent, which defines the attributes its agents carry. + */ +export interface AgentType { + allowSelfRegistration?: boolean; + displayName: string; + handle: string; + id: string; + ouHandle?: string; + ouId: string; +} + +/** + * A page of agent types. + */ +export interface AgentTypeListResponse { + count: number; + startIndex: number; + totalResults: number; + types: AgentType[]; +} diff --git a/packages/javascript/src/models/config.ts b/packages/javascript/src/models/config.ts index 943984f5..341d6639 100644 --- a/packages/javascript/src/models/config.ts +++ b/packages/javascript/src/models/config.ts @@ -242,7 +242,7 @@ export interface BaseConfig extends WithPreferences, WithExtensions * `{baseUrl}/oauth2/token/.well-known/openid-configuration`. Individual overrides take * precedence over values resolved from the discovery document. * - **Resource-server endpoints** (`flowExecute`, `flowMeta`, `usersMe`, `usersMeCredentials`, and the - * management collections `applications`, `users`, `userTypes`, `flows`, `agents`) — + * management collections `applications`, `users`, `userTypes`, `flows`, `agents`, `agentTypes`, `roles`, `groups`) — * by default derived by concatenating `baseUrl` with a fixed path (e.g. `{baseUrl}/flow/execute`). * These do not participate in OIDC discovery. * @@ -275,6 +275,11 @@ export interface BaseConfig extends WithPreferences, WithExtensions * If not provided, defaults to `{baseUrl}/agents`. */ agents?: string; + /** + * The agent types collection URL used by the agent type listing operation. + * If not provided, defaults to `{baseUrl}/agent-types`. + */ + agentTypes?: string; /** * The applications collection URL used by the application management operations. * A single application is addressed as `{applications}/{id}`. @@ -307,6 +312,12 @@ export interface BaseConfig extends WithPreferences, WithExtensions * If not provided, defaults to `{baseUrl}/flows`. */ flows?: string; + /** + * The groups collection URL used by the group management operations. + * A single group is addressed as `{groups}/{id}`, and its members as `{groups}/{id}/members`. + * If not provided, defaults to `{baseUrl}/groups`. + */ + groups?: string; /** * The introspection endpoint URL. * If not provided, resolved from the well-known discovery document. @@ -327,6 +338,12 @@ export interface BaseConfig extends WithPreferences, WithExtensions * If not provided, resolved from the well-known discovery document. */ userInfo?: string; + /** + * The roles collection URL used by the role management operations. + * A single role is addressed as `{roles}/{id}`, and its assignments as `{roles}/{id}/assignments`. + * If not provided, defaults to `{baseUrl}/roles`. + */ + roles?: string; /** * The users collection URL used by the user management operations. * A single user is addressed as `{users}/{id}`. diff --git a/packages/javascript/src/models/group.ts b/packages/javascript/src/models/group.ts new file mode 100644 index 00000000..2f88428a --- /dev/null +++ b/packages/javascript/src/models/group.ts @@ -0,0 +1,95 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ApiPaginationLink} from './api'; + +/** + * The kinds of identity a group can hold. + */ +export type GroupMemberType = 'user' | 'group' | 'agent'; + +/** + * A member of a group. + */ +export interface GroupMember { + /** + * Display value of the member. Present when the list was requested with `include=display`. + */ + display?: string; + id: string; + type: GroupMemberType; +} + +/** + * A group of users, agents and other groups. + */ +export interface Group { + description?: string; + id: string; + members?: GroupMember[]; + name: string; + ouHandle?: string; + ouId: string; +} + +/** + * A page of groups. + */ +export interface GroupListResponse { + count: number; + groups: Group[]; + links?: ApiPaginationLink[]; + startIndex: number; + totalResults: number; +} + +/** + * A page of group members. + */ +export interface GroupMemberListResponse { + count: number; + links?: ApiPaginationLink[]; + members: GroupMember[]; + startIndex: number; + totalResults: number; +} + +/** + * The payload used to create a group. + */ +export interface CreateGroupRequest { + description?: string; + members?: Pick[]; + name: string; + ouId: string; +} + +/** + * The payload used to update a group. + */ +export interface UpdateGroupRequest { + description?: string; + name: string; + ouId: string; +} + +/** + * A group an identity belongs to, as listed from the identity's side (`/users/{id}/groups`, + * `/agents/{id}/groups`). + */ +export interface MembershipGroup { + id: string; + name: string; + ouId: string; +} + +/** + * A page of the groups an identity belongs to. + */ +export interface MembershipGroupListResponse { + count: number; + groups: MembershipGroup[]; + links?: ApiPaginationLink[]; + startIndex: number; + totalResults: number; +} diff --git a/packages/javascript/src/models/role.ts b/packages/javascript/src/models/role.ts new file mode 100644 index 00000000..c7ef5573 --- /dev/null +++ b/packages/javascript/src/models/role.ts @@ -0,0 +1,99 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ApiPaginationLink} from './api'; + +/** + * The kinds of identity a role can be assigned to. + */ +export type RoleAssigneeType = 'user' | 'group' | 'app' | 'agent'; + +/** + * The permissions a role grants on one resource server. + */ +export interface RoleResourcePermissions { + permissions: string[]; + resourceServerId: string; +} + +/** + * An identity a role is assigned to. + */ +export interface RoleAssignment { + /** + * Display value of the assignee. Present when the list was requested with `include=display`. + */ + display?: string; + id: string; + type: RoleAssigneeType; +} + +/** + * An assignee reference sent when adding or removing role assignments. + */ +export interface RoleAssignmentInput { + id: string; + type: RoleAssigneeType; +} + +/** + * The summary of a role returned in list responses. + */ +export interface RoleSummary { + description?: string; + id: string; + isReadOnly?: boolean; + name: string; + ouHandle?: string; + ouId: string; +} + +/** + * A role with the permissions it grants. + */ +export interface Role extends RoleSummary { + permissions: RoleResourcePermissions[]; +} + +/** + * A page of roles. + */ +export interface RoleListResponse { + count: number; + links?: ApiPaginationLink[]; + roles: RoleSummary[]; + startIndex: number; + totalResults: number; +} + +/** + * A page of role assignments. + */ +export interface RoleAssignmentListResponse { + assignments: RoleAssignment[]; + count: number; + links?: ApiPaginationLink[]; + startIndex: number; + totalResults: number; +} + +/** + * The payload used to create a role. + */ +export interface CreateRoleRequest { + assignments?: RoleAssignmentInput[]; + description?: string; + name: string; + ouId: string; + permissions?: RoleResourcePermissions[]; +} + +/** + * The payload used to update a role. + */ +export interface UpdateRoleRequest { + description?: string; + name: string; + ouId: string; + permissions: RoleResourcePermissions[]; +} diff --git a/packages/react/src/utils/__tests__/createResourceInvalidator.test.ts b/packages/javascript/src/utils/__tests__/createResourceInvalidator.test.ts similarity index 100% rename from packages/react/src/utils/__tests__/createResourceInvalidator.test.ts rename to packages/javascript/src/utils/__tests__/createResourceInvalidator.test.ts diff --git a/packages/javascript/src/utils/__tests__/managementPresentation.test.ts b/packages/javascript/src/utils/__tests__/managementPresentation.test.ts new file mode 100644 index 00000000..f172688c --- /dev/null +++ b/packages/javascript/src/utils/__tests__/managementPresentation.test.ts @@ -0,0 +1,44 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {describe, expect, it} from 'vitest'; +import ThunderIDAPIError from '../../errors/ThunderIDAPIError'; +import resolveManagedIdentityName from '../resolveManagedIdentityName'; +import toManagementErrorMessage from '../toManagementErrorMessage'; + +const t = (key: string): string => `t:${key}`; + +const apiError = (statusCode: number): ThunderIDAPIError => + new ThunderIDAPIError('Server text that must not be shown', 'test-code', 'javascript', statusCode); + +describe('toManagementErrorMessage', () => { + it('returns null when there is no error', () => { + expect(toManagementErrorMessage(t, null, 'admin.users.list.error')).toBeNull(); + }); + + it('names a missing permission and a missing resource on their own', () => { + expect(toManagementErrorMessage(t, apiError(403), 'admin.users.list.error')).toBe( + 't:admin.common.errors.forbidden', + ); + expect(toManagementErrorMessage(t, apiError(404), 'admin.users.list.error')).toBe( + 't:admin.common.errors.not_found', + ); + }); + + it('falls back to the operation message and never the server text', () => { + expect(toManagementErrorMessage(t, apiError(500), 'admin.users.list.error')).toBe('t:admin.users.list.error'); + expect(toManagementErrorMessage(t, new Error('boom'), 'admin.users.list.error')).toBe('t:admin.users.list.error'); + }); +}); + +describe('resolveManagedIdentityName', () => { + it('prefers the server display value, then name attributes, then the identifier', () => { + expect(resolveManagedIdentityName({display: 'Aiko Tanaka', id: 'u-1'})).toBe('Aiko Tanaka'); + expect(resolveManagedIdentityName({id: 'a-1', name: 'Deploy bot'})).toBe('Deploy bot'); + expect(resolveManagedIdentityName({attributes: {family_name: 'Rivera', given_name: 'Sam'}, id: 'u-2'})).toBe( + 'Sam Rivera', + ); + expect(resolveManagedIdentityName({attributes: {username: 'priya'}, id: 'u-3'})).toBe('priya'); + expect(resolveManagedIdentityName({id: 'u-4'})).toBe('u-4'); + }); +}); diff --git a/packages/javascript/src/utils/__tests__/permissions.test.ts b/packages/javascript/src/utils/__tests__/permissions.test.ts new file mode 100644 index 00000000..f386b30a --- /dev/null +++ b/packages/javascript/src/utils/__tests__/permissions.test.ts @@ -0,0 +1,59 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {describe, expect, it} from 'vitest'; +import getTokenPermissions from '../getTokenPermissions'; +import hasPermission from '../hasPermission'; + +const token = (payload: Record): string => { + // A JWT carries UTF-8 JSON, so encode the bytes rather than the UTF-16 code units. + const encode = (value: unknown): string => + btoa(String.fromCharCode(...new TextEncoder().encode(JSON.stringify(value)))) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, ''); + + return `${encode({alg: 'none'})}.${encode(payload)}.signature`; +}; + +describe('getTokenPermissions', () => { + it('reads a space separated scope claim', () => { + expect(getTokenPermissions(token({scope: 'openid system:user system:group:view'}))).toEqual([ + 'openid', + 'system:user', + 'system:group:view', + ]); + }); + + it('reads a scopes list claim, ignoring entries that are not strings', () => { + expect(getTokenPermissions(token({scopes: ['system:agent', 7]}))).toEqual(['system:agent']); + }); + + it('decodes non ASCII claims', () => { + expect(getTokenPermissions(token({name: 'Zoë', scope: 'system'}))).toEqual(['system']); + }); + + it('returns nothing for a token it cannot read', () => { + expect(getTokenPermissions(undefined)).toEqual([]); + expect(getTokenPermissions('opaque-token')).toEqual([]); + expect(getTokenPermissions('a.not-json.c')).toEqual([]); + expect(getTokenPermissions(token({sub: 'u-1'}))).toEqual([]); + }); +}); + +describe('hasPermission', () => { + it('lets a permission cover everything under it', () => { + expect(hasPermission(['system'], 'system:user:view')).toBe(true); + expect(hasPermission(['system:user'], 'system:user:view')).toBe(true); + expect(hasPermission(['system:user:view'], 'system:user:view')).toBe(true); + }); + + it('does not let a permission cover its parent or a sibling with a shared prefix', () => { + expect(hasPermission(['system:user:view'], 'system:user')).toBe(false); + expect(hasPermission(['system:user'], 'system:usertype:view')).toBe(false); + }); + + it('always covers an empty requirement', () => { + expect(hasPermission([], '')).toBe(true); + }); +}); diff --git a/packages/javascript/src/utils/__tests__/resolveChangeCredentialPolicy.test.ts b/packages/javascript/src/utils/__tests__/resolveChangeCredentialPolicy.test.ts index 3b372d5b..63a18bbf 100644 --- a/packages/javascript/src/utils/__tests__/resolveChangeCredentialPolicy.test.ts +++ b/packages/javascript/src/utils/__tests__/resolveChangeCredentialPolicy.test.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import {describe, expect, it} from 'vitest'; -import {AttributeSchema} from '../../api/getUsersMeMeta'; +import {AttributeSchema} from '../../api/account/getUsersMeMeta'; import resolveChangeCredentialPolicy from '../resolveChangeCredentialPolicy'; const schemaWith = (name: string, regex?: string): Record => diff --git a/packages/javascript/src/utils/__tests__/resolveResourceEndpoint.test.ts b/packages/javascript/src/utils/__tests__/resolveResourceEndpoint.test.ts index b714b4e3..30375afc 100644 --- a/packages/javascript/src/utils/__tests__/resolveResourceEndpoint.test.ts +++ b/packages/javascript/src/utils/__tests__/resolveResourceEndpoint.test.ts @@ -55,11 +55,14 @@ describe('resolveResourceEndpoint', (): void => { it('exposes the resource endpoint keys for filtering OIDC metadata', (): void => { expect([...RESOURCE_ENDPOINT_KEYS].sort()).toEqual([ + 'agentTypes', 'agents', 'applications', 'flowExecute', 'flowMeta', 'flows', + 'groups', + 'roles', 'userTypes', 'users', 'usersMe', diff --git a/packages/javascript/src/utils/__tests__/supportsCredential.test.ts b/packages/javascript/src/utils/__tests__/supportsCredential.test.ts index 8cfeebe9..476402e0 100644 --- a/packages/javascript/src/utils/__tests__/supportsCredential.test.ts +++ b/packages/javascript/src/utils/__tests__/supportsCredential.test.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import {describe, expect, it} from 'vitest'; -import {AttributeSchema} from '../../api/getUsersMeMeta'; +import {AttributeSchema} from '../../api/account/getUsersMeMeta'; import supportsCredential from '../supportsCredential'; describe('supportsCredential', (): void => { diff --git a/packages/javascript/src/utils/__tests__/toEqualityFilter.test.ts b/packages/javascript/src/utils/__tests__/toEqualityFilter.test.ts new file mode 100644 index 00000000..8e9b1902 --- /dev/null +++ b/packages/javascript/src/utils/__tests__/toEqualityFilter.test.ts @@ -0,0 +1,23 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {describe, expect, it} from 'vitest'; +import toEqualityFilter from '../toEqualityFilter'; + +describe('toEqualityFilter', () => { + it('builds an eq expression from a trimmed value', () => { + expect(toEqualityFilter('username', ' ada ')).toBe('username eq "ada"'); + expect(toEqualityFilter('address.city', 'Mountain View')).toBe('address.city eq "Mountain View"'); + }); + + it('drops double quotes, which the expression cannot carry', () => { + expect(toEqualityFilter('name', 'Say "hi"')).toBe('name eq "Say hi"'); + }); + + it('returns undefined for a blank value or an invalid attribute', () => { + expect(toEqualityFilter('username', ' ')).toBeUndefined(); + expect(toEqualityFilter('username', undefined)).toBeUndefined(); + expect(toEqualityFilter('user name', 'ada')).toBeUndefined(); + expect(toEqualityFilter('username) or (1', 'ada')).toBeUndefined(); + }); +}); diff --git a/packages/react/src/utils/createResourceInvalidator.ts b/packages/javascript/src/utils/createResourceInvalidator.ts similarity index 87% rename from packages/react/src/utils/createResourceInvalidator.ts rename to packages/javascript/src/utils/createResourceInvalidator.ts index 338f22d6..ea76d611 100644 --- a/packages/react/src/utils/createResourceInvalidator.ts +++ b/packages/javascript/src/utils/createResourceInvalidator.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 /** - * A key identifying a resource query, e.g. `['applications', {limit: 10}]` or `['application', id]`. + * A key identifying a resource query, e.g. `['users', {limit: 10}]` or `['user', id]`. */ export type ResourceQueryKey = readonly unknown[]; @@ -50,8 +50,8 @@ const createResourceInvalidator = (): ResourceInvalidator => { }; /** - * Used when the context does not supply an invalidator, for example when an integration renders - * its own `ThunderIDContext` value instead of using `ThunderIDProvider`. + * Used when a framework context does not supply an invalidator, for example when an integration + * renders its own context value instead of using the framework's provider. */ export const fallbackResourceInvalidator: ResourceInvalidator = createResourceInvalidator(); diff --git a/packages/javascript/src/utils/getTokenPermissions.ts b/packages/javascript/src/utils/getTokenPermissions.ts new file mode 100644 index 00000000..a1bb8656 --- /dev/null +++ b/packages/javascript/src/utils/getTokenPermissions.ts @@ -0,0 +1,51 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +/** + * Reads the permissions an access token grants from its `scope` claim (a space separated string) + * or `scopes` claim (a list), the two forms the ThunderID server accepts. + * + * The token is decoded, not verified: use the result only to decide what to show. The server still + * checks every request. + * + * @param accessToken - A JWT access token. + * @returns The granted permissions, or an empty list when the token is not a readable JWT. + * + * @example + * ```typescript + * const permissions = getTokenPermissions(await getAccessToken()); + * ``` + */ +const getTokenPermissions = (accessToken: string | undefined): string[] => { + const payloadPart: string | undefined = accessToken?.split('.')[1]; + + if (!payloadPart) { + return []; + } + + try { + const base64: string = payloadPart.replace(/-/g, '+').replace(/_/g, '/'); + const padded: string = base64.padEnd(base64.length + ((4 - (base64.length % 4)) % 4), '='); + const payload: Record = JSON.parse( + decodeURIComponent( + Array.from(atob(padded), (char: string) => `%${char.charCodeAt(0).toString(16).padStart(2, '0')}`).join(''), + ), + ) as Record; + + const {scope, scopes} = payload; + + if (Array.isArray(scopes)) { + return scopes.filter((entry: unknown): entry is string => typeof entry === 'string'); + } + + if (typeof scope === 'string') { + return scope.split(' ').filter((entry: string) => entry.length > 0); + } + + return []; + } catch { + return []; + } +}; + +export default getTokenPermissions; diff --git a/packages/javascript/src/utils/hasPermission.ts b/packages/javascript/src/utils/hasPermission.ts new file mode 100644 index 00000000..7786b095 --- /dev/null +++ b/packages/javascript/src/utils/hasPermission.ts @@ -0,0 +1,22 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +/** + * Checks whether granted permissions cover a required one, the way the ThunderID server does: + * permissions are `:` separated paths, and a permission covers itself and everything under it, so + * `system:user` covers `system:user:view`. + * + * @param granted - The permissions the caller holds, for example from {@link getTokenPermissions}. + * @param required - The permission to check. An empty string is always covered. + * @returns `true` when one of the granted permissions covers the required one. + * + * @example + * ```typescript + * hasPermission(['system:user'], 'system:user:view'); // true + * ``` + */ +const hasPermission = (granted: readonly string[], required: string): boolean => + required === '' || + granted.some((permission: string) => permission === required || required.startsWith(`${permission}:`)); + +export default hasPermission; diff --git a/packages/javascript/src/utils/requestManagementResource.ts b/packages/javascript/src/utils/requestManagementResource.ts index c140f929..0c197517 100644 --- a/packages/javascript/src/utils/requestManagementResource.ts +++ b/packages/javascript/src/utils/requestManagementResource.ts @@ -33,6 +33,11 @@ export interface ManagementResourceRequest { * Query parameters. `undefined` values are omitted. */ query?: Record; + /** + * Path appended after the resource identifier, for sub-resources such as `members` or + * `assignments/add`. Requires `id`. + */ + subPath?: string; } /** @@ -44,7 +49,7 @@ export interface ManagementResourceRequest { */ const requestManagementResource = async ( {baseUrl, url, fetcher, ...requestConfig}: ManagementRequestConfig, - {body, collection, failureMessage, id, method, operation, query}: ManagementResourceRequest, + {body, collection, failureMessage, id, method, operation, query, subPath}: ManagementResourceRequest, ): Promise => { try { // eslint-disable-next-line no-new @@ -70,7 +75,8 @@ const requestManagementResource = async ( } const collectionUrl: string = (url ?? `${baseUrl}/${collection}`).replace(/\/+$/, ''); - const resourceUrl: string = id === undefined ? collectionUrl : `${collectionUrl}/${encodeURIComponent(id)}`; + const itemUrl: string = id === undefined ? collectionUrl : `${collectionUrl}/${encodeURIComponent(id)}`; + const resourceUrl: string = id !== undefined && subPath ? `${itemUrl}/${subPath}` : itemUrl; const searchParams: URLSearchParams = new URLSearchParams(); Object.entries(query ?? {}).forEach(([key, value]: [string, string | number | undefined]) => { diff --git a/packages/javascript/src/utils/resolveChangeCredentialPolicy.ts b/packages/javascript/src/utils/resolveChangeCredentialPolicy.ts index 710baa2d..d4bca7b9 100644 --- a/packages/javascript/src/utils/resolveChangeCredentialPolicy.ts +++ b/packages/javascript/src/utils/resolveChangeCredentialPolicy.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import {PasswordPolicy} from './evaluatePasswordPolicy'; -import {AttributeSchema} from '../api/getUsersMeMeta'; +import {AttributeSchema} from '../api/account/getUsersMeMeta'; /** * Resolves the rules a change-credential form should enforce for the given credential. diff --git a/packages/javascript/src/utils/resolveManagedIdentityName.ts b/packages/javascript/src/utils/resolveManagedIdentityName.ts new file mode 100644 index 00000000..e0ccb224 --- /dev/null +++ b/packages/javascript/src/utils/resolveManagedIdentityName.ts @@ -0,0 +1,35 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +/** + * Picks the label to show for a managed identity: the server's `display` value, then common name + * attributes, then the identifier. + * + * @param identity - A user, agent or member record. + * @returns The label to show. + */ +const resolveManagedIdentityName = (identity: { + attributes?: Record; + display?: string; + id: string; + name?: string; +}): string => { + if (identity.display) { + return identity.display; + } + + const attributes: Record = identity.attributes ?? {}; + const fullName: string = [attributes['given_name'], attributes['family_name']] + .filter((part: unknown) => typeof part === 'string' && part.length > 0) + .join(' '); + + return ( + identity.name ?? + (typeof attributes['name'] === 'string' ? attributes['name'] : undefined) ?? + (fullName || undefined) ?? + (typeof attributes['username'] === 'string' ? attributes['username'] : undefined) ?? + identity.id + ); +}; + +export default resolveManagedIdentityName; diff --git a/packages/javascript/src/utils/resolveResourceEndpoint.ts b/packages/javascript/src/utils/resolveResourceEndpoint.ts index 41b8403f..8e6c7d93 100644 --- a/packages/javascript/src/utils/resolveResourceEndpoint.ts +++ b/packages/javascript/src/utils/resolveResourceEndpoint.ts @@ -14,10 +14,13 @@ import {BaseConfig} from '../models/config'; */ export type ResourceEndpointKey = | 'agents' + | 'agentTypes' | 'applications' | 'flowExecute' | 'flowMeta' | 'flows' + | 'groups' + | 'roles' | 'users' | 'usersMe' | 'usersMeCredentials' @@ -30,10 +33,13 @@ export type ResourceEndpointKey = */ export const RESOURCE_ENDPOINT_KEYS: readonly ResourceEndpointKey[] = [ 'agents', + 'agentTypes', 'applications', 'flowExecute', 'flowMeta', 'flows', + 'groups', + 'roles', 'users', 'usersMe', 'usersMeCredentials', diff --git a/packages/javascript/src/utils/supportsCredential.ts b/packages/javascript/src/utils/supportsCredential.ts index 961d4f31..210525ac 100644 --- a/packages/javascript/src/utils/supportsCredential.ts +++ b/packages/javascript/src/utils/supportsCredential.ts @@ -1,7 +1,7 @@ // Copyright 2026 The ThunderID Authors // SPDX-License-Identifier: Apache-2.0 -import {AttributeSchema} from '../api/getUsersMeMeta'; +import {AttributeSchema} from '../api/account/getUsersMeMeta'; /** * Whether the signed-in user's type allows them to set the given credential for themselves. diff --git a/packages/javascript/src/utils/toEqualityFilter.ts b/packages/javascript/src/utils/toEqualityFilter.ts new file mode 100644 index 00000000..2fdba9e6 --- /dev/null +++ b/packages/javascript/src/utils/toEqualityFilter.ts @@ -0,0 +1,30 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +const ATTRIBUTE_PATTERN: RegExp = /^\w+(?:\.\w+)*$/; + +/** + * Builds the filter expression the management list endpoints accept, `attribute eq "value"`, for + * example to search users by username. The server matches the whole value exactly. + * + * @param attribute - The attribute to match, such as `username` or `address.city`. + * @param value - The value to look for. Surrounding whitespace is ignored, and double quotes, which + * the expression cannot carry, are removed. + * @returns The expression, or `undefined` when the value is blank or the attribute name is not valid. + * + * @example + * ```typescript + * getUsers({baseUrl, filter: toEqualityFilter('username', 'ada')}); // username eq "ada" + * ``` + */ +const toEqualityFilter = (attribute: string, value: string | undefined): string | undefined => { + const cleaned: string = (value ?? '').replace(/"/g, '').trim(); + + if (!cleaned || !ATTRIBUTE_PATTERN.test(attribute)) { + return undefined; + } + + return `${attribute} eq "${cleaned}"`; +}; + +export default toEqualityFilter; diff --git a/packages/javascript/src/utils/toManagementErrorMessage.ts b/packages/javascript/src/utils/toManagementErrorMessage.ts new file mode 100644 index 00000000..37596433 --- /dev/null +++ b/packages/javascript/src/utils/toManagementErrorMessage.ts @@ -0,0 +1,38 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import ThunderIDAPIError from '../errors/ThunderIDAPIError'; + +/** + * Turns a failed management request into a message for admin UI. A missing permission and a + * missing resource get their own messages; anything else gets the operation's fallback. The + * server's own error text is never shown. + * + * @param t - The translation function. + * @param error - The error a management operation reported, if any. + * @param fallbackKey - Translation key of the message for any other failure. + * @returns The translated message, or `null` when there is no error. + */ +const toManagementErrorMessage = ( + t: (key: string) => string, + error: Error | null | undefined, + fallbackKey: string, +): string | null => { + if (!error) { + return null; + } + + if (error instanceof ThunderIDAPIError) { + if (error.statusCode === 403) { + return t('admin.common.errors.forbidden'); + } + + if (error.statusCode === 404) { + return t('admin.common.errors.not_found'); + } + } + + return t(fallbackKey); +}; + +export default toManagementErrorMessage; diff --git a/packages/nextjs/src/client/__tests__/fetchAuthFlowsFromServer.test.ts b/packages/nextjs/src/client/__tests__/fetchAuthFlowsFromServer.test.ts index 75d5642c..8c140648 100644 --- a/packages/nextjs/src/client/__tests__/fetchAuthFlowsFromServer.test.ts +++ b/packages/nextjs/src/client/__tests__/fetchAuthFlowsFromServer.test.ts @@ -3,10 +3,10 @@ import {ThunderIDError} from '@thunderid/node'; import {beforeEach, describe, expect, it, vi} from 'vitest'; -import getAuthFlowsAction from '../../server/actions/getAuthFlowsAction'; +import getAuthFlowsAction from '../../server/actions/auth/getAuthFlowsAction'; import fetchAuthFlowsFromServer from '../fetchAuthFlowsFromServer'; -vi.mock('../../server/actions/getAuthFlowsAction', () => ({default: vi.fn()})); +vi.mock('../../server/actions/auth/getAuthFlowsAction', () => ({default: vi.fn()})); const request = {component: {} as never, limit: 30, offset: 60, signal: new AbortController().signal}; diff --git a/packages/nextjs/src/client/__tests__/fetchFlowMetaFromServer.test.ts b/packages/nextjs/src/client/__tests__/fetchFlowMetaFromServer.test.ts index 4fca05c0..065b23ca 100644 --- a/packages/nextjs/src/client/__tests__/fetchFlowMetaFromServer.test.ts +++ b/packages/nextjs/src/client/__tests__/fetchFlowMetaFromServer.test.ts @@ -3,10 +3,10 @@ import {ThunderIDAPIError, ThunderIDRuntimeError} from '@thunderid/node'; import {beforeEach, describe, expect, it, vi} from 'vitest'; -import getFlowMetaAction from '../../server/actions/getFlowMetaAction'; +import getFlowMetaAction from '../../server/actions/auth/getFlowMetaAction'; import fetchFlowMetaFromServer from '../fetchFlowMetaFromServer'; -vi.mock('../../server/actions/getFlowMetaAction', () => ({default: vi.fn()})); +vi.mock('../../server/actions/auth/getFlowMetaAction', () => ({default: vi.fn()})); describe('fetchFlowMetaFromServer', () => { beforeEach(() => { diff --git a/packages/nextjs/src/client/__tests__/fetchManagementFromServer.test.ts b/packages/nextjs/src/client/__tests__/fetchManagementFromServer.test.ts new file mode 100644 index 00000000..0d7e87d4 --- /dev/null +++ b/packages/nextjs/src/client/__tests__/fetchManagementFromServer.test.ts @@ -0,0 +1,73 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ThunderIDAPIError, getGroups} from '@thunderid/node'; +import {beforeEach, describe, expect, it, vi} from 'vitest'; +import managementRequestAction from '../../server/actions/management/managementRequestAction'; +import fetchManagementFromServer from '../fetchManagementFromServer'; + +vi.mock('../../server/actions/management/managementRequestAction', () => ({default: vi.fn()})); + +describe('fetchManagementFromServer', () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + it('forwards only the URL, method and body: no headers or signal cross to the server', async () => { + vi.mocked(managementRequestAction).mockResolvedValue({body: '{}', status: 200, statusText: 'OK'}); + + await fetchManagementFromServer('https://id.example.com/groups', { + body: '{"name":"Admins"}', + headers: {Authorization: 'Bearer from-the-browser'}, + method: 'POST', + signal: new AbortController().signal, + }); + + expect(managementRequestAction).toHaveBeenCalledWith({ + body: '{"name":"Admins"}', + method: 'POST', + url: 'https://id.example.com/groups', + }); + }); + + it('serves a core API function end to end', async () => { + vi.mocked(managementRequestAction).mockResolvedValue({ + body: JSON.stringify({count: 1, groups: [{id: 'g-1', name: 'Admins'}], startIndex: 0, totalResults: 1}), + status: 200, + statusText: 'OK', + }); + + const page = await getGroups({baseUrl: 'https://id.example.com', fetcher: fetchManagementFromServer, limit: 10}); + + expect(page.groups).toEqual([{id: 'g-1', name: 'Admins'}]); + const [[request]] = vi.mocked(managementRequestAction).mock.calls; + + expect(request.method).toBe('GET'); + expect(request.url).toMatch(/^https:\/\/id\.example\.com\/groups\?.*limit=10/); + }); + + it('keeps a 403 distinct from a 404 for the core API function', async () => { + vi.mocked(managementRequestAction).mockResolvedValueOnce({ + body: 'Management request failed (HTTP 403).', + status: 403, + statusText: 'Forbidden', + }); + vi.mocked(managementRequestAction).mockResolvedValueOnce({ + body: 'Management request failed (HTTP 404).', + status: 404, + statusText: 'Not Found', + }); + + const forbidden = await getGroups({baseUrl: 'https://id.example.com', fetcher: fetchManagementFromServer}).catch( + (error: unknown) => error, + ); + const missing = await getGroups({baseUrl: 'https://id.example.com', fetcher: fetchManagementFromServer}).catch( + (error: unknown) => error, + ); + + expect(forbidden).toBeInstanceOf(ThunderIDAPIError); + expect(forbidden).toMatchObject({statusCode: 403}); + expect((forbidden as ThunderIDAPIError).code).toContain('ForbiddenError'); + expect((missing as ThunderIDAPIError).code).toContain('NotFoundError'); + }); +}); diff --git a/packages/nextjs/src/client/__tests__/fetchUserTypesFromServer.test.ts b/packages/nextjs/src/client/__tests__/fetchUserTypesFromServer.test.ts index cbdba8be..4f8dfc03 100644 --- a/packages/nextjs/src/client/__tests__/fetchUserTypesFromServer.test.ts +++ b/packages/nextjs/src/client/__tests__/fetchUserTypesFromServer.test.ts @@ -3,10 +3,10 @@ import {ThunderIDError} from '@thunderid/node'; import {beforeEach, describe, expect, it, vi} from 'vitest'; -import getUserTypesAction from '../../server/actions/getUserTypesAction'; +import getUserTypesAction from '../../server/actions/management/getUserTypesAction'; import fetchUserTypesFromServer from '../fetchUserTypesFromServer'; -vi.mock('../../server/actions/getUserTypesAction', () => ({default: vi.fn()})); +vi.mock('../../server/actions/management/getUserTypesAction', () => ({default: vi.fn()})); const request = {component: {} as never, limit: 30, offset: 60, signal: new AbortController().signal}; diff --git a/packages/nextjs/src/client/__tests__/fetchUsersFromServer.test.ts b/packages/nextjs/src/client/__tests__/fetchUsersFromServer.test.ts index 6e5bcbd2..083065e3 100644 --- a/packages/nextjs/src/client/__tests__/fetchUsersFromServer.test.ts +++ b/packages/nextjs/src/client/__tests__/fetchUsersFromServer.test.ts @@ -3,10 +3,10 @@ import {ThunderIDAPIError, ThunderIDError, ThunderIDRuntimeError} from '@thunderid/node'; import {beforeEach, describe, expect, it, vi} from 'vitest'; -import getUsersAction from '../../server/actions/getUsersAction'; +import getUsersAction from '../../server/actions/management/getUsersAction'; import fetchUsersFromServer from '../fetchUsersFromServer'; -vi.mock('../../server/actions/getUsersAction', () => ({default: vi.fn()})); +vi.mock('../../server/actions/management/getUsersAction', () => ({default: vi.fn()})); const request = {component: {} as never, limit: 30, offset: 60, signal: new AbortController().signal}; diff --git a/packages/nextjs/src/client/admin.ts b/packages/nextjs/src/client/admin.ts new file mode 100644 index 00000000..2691cecc --- /dev/null +++ b/packages/nextjs/src/client/admin.ts @@ -0,0 +1,103 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +'use client'; + +// The admin (management) surface of `@thunderid/react` that needs no Next.js-specific wiring: the +// unstyled `Base*` admin components, which take their data as props, and the management hooks, which +// reach the management API through the `http.fetcher` that `ThunderIDProvider` installs (a server +// action holding the access token). The styled admin components have their own wrappers under +// `components/admin`, and `useGrantedPermissions` has a Next.js implementation. + +export { + type AdminListRenderProps, + BaseUserList, + type BaseUserListProps, + type BaseUserListRenderProps, + BaseUserCreate, + type BaseUserCreateProps, + type BaseUserCreateRenderProps, + BaseUserDetails, + type BaseUserDetailsProps, + type BaseUserDetailsRenderProps, + BaseGroupList, + type BaseGroupListProps, + type BaseGroupListRenderProps, + BaseGroupCreate, + type BaseGroupCreateProps, + type BaseGroupCreateRenderProps, + BaseGroupMembers, + type BaseGroupMembersProps, + type BaseGroupMembersRenderProps, + BaseRoleList, + type BaseRoleListProps, + type BaseRoleListRenderProps, + BaseRoleCreate, + type BaseRoleCreateProps, + type BaseRoleCreateRenderProps, + BaseRoleAssignments, + type BaseRoleAssignmentsProps, + type BaseRoleAssignmentsRenderProps, + BaseRolePermissions, + type BaseRolePermissionsProps, + type BaseRolePermissionsRenderProps, + BaseAgentList, + type BaseAgentListProps, + type BaseAgentListRenderProps, + BaseAgentCreate, + type BaseAgentCreateProps, + type BaseAgentCreateRenderProps, + BaseAgentDetails, + type BaseAgentDetailsProps, + type BaseAgentDetailsRenderProps, +} from '@thunderid/react'; + +export { + useGetUsers, + useGetUser, + useCreateUser, + useUpdateUser, + useDeleteUser, + useGetUserGroups, + useGetUserTypes, + useGetGroups, + useGetGroup, + useCreateGroup, + useUpdateGroup, + useDeleteGroup, + useGetGroupMembers, + useAddGroupMembers, + useRemoveGroupMembers, + useGetRoles, + useGetRole, + useCreateRole, + useUpdateRole, + useDeleteRole, + useGetRoleAssignments, + useAddRoleAssignments, + useRemoveRoleAssignments, + useGetAgents, + useGetAgent, + useCreateAgent, + useUpdateAgent, + useDeleteAgent, + useGetAgentGroups, + useGetAgentRoles, + useGetAgentTypes, + type ResourceQueryOptions, + type ResourceQueryResult, + type ResourceMutationOptions, + type ResourceMutationResult, + type UpdateUserVariables, + type UseGetGroupsParams, + type UpdateGroupVariables, + type UseGetGroupMembersParams, + type GroupMembersVariables, + type UseGetRolesParams, + type UpdateRoleVariables, + type UseGetRoleAssignmentsParams, + type RoleAssignmentsVariables, + type UseGetAgentsParams, + type UpdateAgentVariables, + type GrantedPermissions, +} from '@thunderid/react'; diff --git a/packages/nextjs/src/client/components/presentation/ChangeCredential/ChangeCredential.tsx b/packages/nextjs/src/client/components/account/ChangeCredential/ChangeCredential.tsx similarity index 98% rename from packages/nextjs/src/client/components/presentation/ChangeCredential/ChangeCredential.tsx rename to packages/nextjs/src/client/components/account/ChangeCredential/ChangeCredential.tsx index f73b43b5..0b5f0eb1 100644 --- a/packages/nextjs/src/client/components/presentation/ChangeCredential/ChangeCredential.tsx +++ b/packages/nextjs/src/client/components/account/ChangeCredential/ChangeCredential.tsx @@ -12,10 +12,10 @@ import { useUser, } from '@thunderid/react'; import {FC, ReactElement, useMemo, useState} from 'react'; -import getSessionId from '../../../../server/actions/getSessionId'; import updateUserCredentialsAction, { UpdateUserCredentialsActionResult, -} from '../../../../server/actions/updateUserCredentialsAction'; +} from '../../../../server/actions/account/updateUserCredentialsAction'; +import getSessionId from '../../../../server/actions/getSessionId'; import useThunderID from '../../../contexts/ThunderID/useThunderID'; /** diff --git a/packages/nextjs/src/client/components/presentation/User/User.tsx b/packages/nextjs/src/client/components/account/User/User.tsx similarity index 100% rename from packages/nextjs/src/client/components/presentation/User/User.tsx rename to packages/nextjs/src/client/components/account/User/User.tsx diff --git a/packages/nextjs/src/client/components/presentation/UserAvatar/UserAvatar.tsx b/packages/nextjs/src/client/components/account/UserAvatar/UserAvatar.tsx similarity index 100% rename from packages/nextjs/src/client/components/presentation/UserAvatar/UserAvatar.tsx rename to packages/nextjs/src/client/components/account/UserAvatar/UserAvatar.tsx diff --git a/packages/nextjs/src/client/components/presentation/UserDropdown/UserDropdown.tsx b/packages/nextjs/src/client/components/account/UserDropdown/UserDropdown.tsx similarity index 100% rename from packages/nextjs/src/client/components/presentation/UserDropdown/UserDropdown.tsx rename to packages/nextjs/src/client/components/account/UserDropdown/UserDropdown.tsx diff --git a/packages/nextjs/src/client/components/presentation/UserProfile/UserProfile.tsx b/packages/nextjs/src/client/components/account/UserProfile/UserProfile.tsx similarity index 100% rename from packages/nextjs/src/client/components/presentation/UserProfile/UserProfile.tsx rename to packages/nextjs/src/client/components/account/UserProfile/UserProfile.tsx diff --git a/packages/nextjs/src/client/components/admin/AgentCreate/AgentCreate.tsx b/packages/nextjs/src/client/components/admin/AgentCreate/AgentCreate.tsx new file mode 100644 index 00000000..58798165 --- /dev/null +++ b/packages/nextjs/src/client/components/admin/AgentCreate/AgentCreate.tsx @@ -0,0 +1,26 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +'use client'; + +import {AgentCreate as ReactAgentCreate, AgentCreateProps as ReactAgentCreateProps} from '@thunderid/react'; +import {FC, ReactElement} from 'react'; + +export type AgentCreateProps = ReactAgentCreateProps; + +/** + * Creates an agent. + * + * Management requests go through a server action with the signed-in user's server-held session, so + * the access token never reaches the browser. Whether a request succeeds is the server's decision. + * + * @example + * ```tsx + * router.push(`/admin/agents/${agent.id}`)} /> + * ``` + */ +const AgentCreate: FC = (props: AgentCreateProps): ReactElement => ; + +AgentCreate.displayName = 'AgentCreate'; + +export default AgentCreate; diff --git a/packages/nextjs/src/client/components/admin/AgentDetails/AgentDetails.tsx b/packages/nextjs/src/client/components/admin/AgentDetails/AgentDetails.tsx new file mode 100644 index 00000000..00db37fb --- /dev/null +++ b/packages/nextjs/src/client/components/admin/AgentDetails/AgentDetails.tsx @@ -0,0 +1,28 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +'use client'; + +import {AgentDetails as ReactAgentDetails, AgentDetailsProps as ReactAgentDetailsProps} from '@thunderid/react'; +import {FC, ReactElement} from 'react'; + +export type AgentDetailsProps = ReactAgentDetailsProps; + +/** + * Shows an agent's details, groups and roles, and optionally deletes the agent. + * + * Management requests go through a server action with the signed-in user's server-held session, so + * the access token never reaches the browser. Whether a request succeeds is the server's decision. + * + * @example + * ```tsx + * router.push('/admin/agents')} /> + * ``` + */ +const AgentDetails: FC = (props: AgentDetailsProps): ReactElement => ( + +); + +AgentDetails.displayName = 'AgentDetails'; + +export default AgentDetails; diff --git a/packages/nextjs/src/client/components/admin/AgentList/AgentList.tsx b/packages/nextjs/src/client/components/admin/AgentList/AgentList.tsx new file mode 100644 index 00000000..1f03758a --- /dev/null +++ b/packages/nextjs/src/client/components/admin/AgentList/AgentList.tsx @@ -0,0 +1,26 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +'use client'; + +import {AgentList as ReactAgentList, AgentListProps as ReactAgentListProps} from '@thunderid/react'; +import {FC, ReactElement} from 'react'; + +export type AgentListProps = ReactAgentListProps; + +/** + * Lists agents, a page at a time. + * + * Management requests go through a server action with the signed-in user's server-held session, so + * the access token never reaches the browser. Whether a request succeeds is the server's decision. + * + * @example + * ```tsx + * router.push(`/admin/agents/${agent.id}`)} /> + * ``` + */ +const AgentList: FC = (props: AgentListProps): ReactElement => ; + +AgentList.displayName = 'AgentList'; + +export default AgentList; diff --git a/packages/nextjs/src/client/components/admin/GroupCreate/GroupCreate.tsx b/packages/nextjs/src/client/components/admin/GroupCreate/GroupCreate.tsx new file mode 100644 index 00000000..ae6e0b69 --- /dev/null +++ b/packages/nextjs/src/client/components/admin/GroupCreate/GroupCreate.tsx @@ -0,0 +1,26 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +'use client'; + +import {GroupCreate as ReactGroupCreate, GroupCreateProps as ReactGroupCreateProps} from '@thunderid/react'; +import {FC, ReactElement} from 'react'; + +export type GroupCreateProps = ReactGroupCreateProps; + +/** + * Creates a group. + * + * Management requests go through a server action with the signed-in user's server-held session, so + * the access token never reaches the browser. Whether a request succeeds is the server's decision. + * + * @example + * ```tsx + * router.push(`/admin/groups/${group.id}`)} /> + * ``` + */ +const GroupCreate: FC = (props: GroupCreateProps): ReactElement => ; + +GroupCreate.displayName = 'GroupCreate'; + +export default GroupCreate; diff --git a/packages/nextjs/src/client/components/admin/GroupList/GroupList.tsx b/packages/nextjs/src/client/components/admin/GroupList/GroupList.tsx new file mode 100644 index 00000000..37d78920 --- /dev/null +++ b/packages/nextjs/src/client/components/admin/GroupList/GroupList.tsx @@ -0,0 +1,26 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +'use client'; + +import {GroupList as ReactGroupList, GroupListProps as ReactGroupListProps} from '@thunderid/react'; +import {FC, ReactElement} from 'react'; + +export type GroupListProps = ReactGroupListProps; + +/** + * Lists groups, a page at a time. + * + * Management requests go through a server action with the signed-in user's server-held session, so + * the access token never reaches the browser. Whether a request succeeds is the server's decision. + * + * @example + * ```tsx + * router.push(`/admin/groups/${group.id}`)} /> + * ``` + */ +const GroupList: FC = (props: GroupListProps): ReactElement => ; + +GroupList.displayName = 'GroupList'; + +export default GroupList; diff --git a/packages/nextjs/src/client/components/admin/GroupMembers/GroupMembers.tsx b/packages/nextjs/src/client/components/admin/GroupMembers/GroupMembers.tsx new file mode 100644 index 00000000..7faf61f6 --- /dev/null +++ b/packages/nextjs/src/client/components/admin/GroupMembers/GroupMembers.tsx @@ -0,0 +1,28 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +'use client'; + +import {GroupMembers as ReactGroupMembers, GroupMembersProps as ReactGroupMembersProps} from '@thunderid/react'; +import {FC, ReactElement} from 'react'; + +export type GroupMembersProps = ReactGroupMembersProps; + +/** + * Lists a group's members and adds or removes them. + * + * Management requests go through a server action with the signed-in user's server-held session, so + * the access token never reaches the browser. Whether a request succeeds is the server's decision. + * + * @example + * ```tsx + * + * ``` + */ +const GroupMembers: FC = (props: GroupMembersProps): ReactElement => ( + +); + +GroupMembers.displayName = 'GroupMembers'; + +export default GroupMembers; diff --git a/packages/nextjs/src/client/components/admin/RoleAssignments/RoleAssignments.tsx b/packages/nextjs/src/client/components/admin/RoleAssignments/RoleAssignments.tsx new file mode 100644 index 00000000..a69e07b8 --- /dev/null +++ b/packages/nextjs/src/client/components/admin/RoleAssignments/RoleAssignments.tsx @@ -0,0 +1,31 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +'use client'; + +import { + RoleAssignments as ReactRoleAssignments, + RoleAssignmentsProps as ReactRoleAssignmentsProps, +} from '@thunderid/react'; +import {FC, ReactElement} from 'react'; + +export type RoleAssignmentsProps = ReactRoleAssignmentsProps; + +/** + * Lists a role's assignments and assigns or unassigns users, groups and agents. + * + * Management requests go through a server action with the signed-in user's server-held session, so + * the access token never reaches the browser. Whether a request succeeds is the server's decision. + * + * @example + * ```tsx + * + * ``` + */ +const RoleAssignments: FC = (props: RoleAssignmentsProps): ReactElement => ( + +); + +RoleAssignments.displayName = 'RoleAssignments'; + +export default RoleAssignments; diff --git a/packages/nextjs/src/client/components/admin/RoleCreate/RoleCreate.tsx b/packages/nextjs/src/client/components/admin/RoleCreate/RoleCreate.tsx new file mode 100644 index 00000000..777bf854 --- /dev/null +++ b/packages/nextjs/src/client/components/admin/RoleCreate/RoleCreate.tsx @@ -0,0 +1,26 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +'use client'; + +import {RoleCreate as ReactRoleCreate, RoleCreateProps as ReactRoleCreateProps} from '@thunderid/react'; +import {FC, ReactElement} from 'react'; + +export type RoleCreateProps = ReactRoleCreateProps; + +/** + * Creates a role. + * + * Management requests go through a server action with the signed-in user's server-held session, so + * the access token never reaches the browser. Whether a request succeeds is the server's decision. + * + * @example + * ```tsx + * router.push(`/admin/roles/${role.id}`)} /> + * ``` + */ +const RoleCreate: FC = (props: RoleCreateProps): ReactElement => ; + +RoleCreate.displayName = 'RoleCreate'; + +export default RoleCreate; diff --git a/packages/nextjs/src/client/components/admin/RoleList/RoleList.tsx b/packages/nextjs/src/client/components/admin/RoleList/RoleList.tsx new file mode 100644 index 00000000..e01b8563 --- /dev/null +++ b/packages/nextjs/src/client/components/admin/RoleList/RoleList.tsx @@ -0,0 +1,26 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +'use client'; + +import {RoleList as ReactRoleList, RoleListProps as ReactRoleListProps} from '@thunderid/react'; +import {FC, ReactElement} from 'react'; + +export type RoleListProps = ReactRoleListProps; + +/** + * Lists roles, a page at a time. + * + * Management requests go through a server action with the signed-in user's server-held session, so + * the access token never reaches the browser. Whether a request succeeds is the server's decision. + * + * @example + * ```tsx + * router.push(`/admin/roles/${role.id}`)} /> + * ``` + */ +const RoleList: FC = (props: RoleListProps): ReactElement => ; + +RoleList.displayName = 'RoleList'; + +export default RoleList; diff --git a/packages/nextjs/src/client/components/admin/RolePermissions/RolePermissions.tsx b/packages/nextjs/src/client/components/admin/RolePermissions/RolePermissions.tsx new file mode 100644 index 00000000..d18c3680 --- /dev/null +++ b/packages/nextjs/src/client/components/admin/RolePermissions/RolePermissions.tsx @@ -0,0 +1,31 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +'use client'; + +import { + RolePermissions as ReactRolePermissions, + RolePermissionsProps as ReactRolePermissionsProps, +} from '@thunderid/react'; +import {FC, ReactElement} from 'react'; + +export type RolePermissionsProps = ReactRolePermissionsProps; + +/** + * Shows the permissions a role grants. + * + * Management requests go through a server action with the signed-in user's server-held session, so + * the access token never reaches the browser. Whether a request succeeds is the server's decision. + * + * @example + * ```tsx + * + * ``` + */ +const RolePermissions: FC = (props: RolePermissionsProps): ReactElement => ( + +); + +RolePermissions.displayName = 'RolePermissions'; + +export default RolePermissions; diff --git a/packages/nextjs/src/client/components/admin/UserCreate/UserCreate.tsx b/packages/nextjs/src/client/components/admin/UserCreate/UserCreate.tsx new file mode 100644 index 00000000..3a80e988 --- /dev/null +++ b/packages/nextjs/src/client/components/admin/UserCreate/UserCreate.tsx @@ -0,0 +1,26 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +'use client'; + +import {UserCreate as ReactUserCreate, UserCreateProps as ReactUserCreateProps} from '@thunderid/react'; +import {FC, ReactElement} from 'react'; + +export type UserCreateProps = ReactUserCreateProps; + +/** + * Creates a user, or invites one with `mode="invite"`. + * + * Management requests go through a server action with the signed-in user's server-held session, so + * the access token never reaches the browser. Whether a request succeeds is the server's decision. + * + * @example + * ```tsx + * router.push(`/admin/users/${user.id}`)} /> + * ``` + */ +const UserCreate: FC = (props: UserCreateProps): ReactElement => ; + +UserCreate.displayName = 'UserCreate'; + +export default UserCreate; diff --git a/packages/nextjs/src/client/components/admin/UserDetails/UserDetails.tsx b/packages/nextjs/src/client/components/admin/UserDetails/UserDetails.tsx new file mode 100644 index 00000000..bbefc3b3 --- /dev/null +++ b/packages/nextjs/src/client/components/admin/UserDetails/UserDetails.tsx @@ -0,0 +1,26 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +'use client'; + +import {UserDetails as ReactUserDetails, UserDetailsProps as ReactUserDetailsProps} from '@thunderid/react'; +import {FC, ReactElement} from 'react'; + +export type UserDetailsProps = ReactUserDetailsProps; + +/** + * Shows a user's attributes and groups, and optionally edits or deletes the user. + * + * Management requests go through a server action with the signed-in user's server-held session, so + * the access token never reaches the browser. Whether a request succeeds is the server's decision. + * + * @example + * ```tsx + * router.push('/admin/users')} /> + * ``` + */ +const UserDetails: FC = (props: UserDetailsProps): ReactElement => ; + +UserDetails.displayName = 'UserDetails'; + +export default UserDetails; diff --git a/packages/nextjs/src/client/components/admin/UserList/UserList.tsx b/packages/nextjs/src/client/components/admin/UserList/UserList.tsx new file mode 100644 index 00000000..d282fcb6 --- /dev/null +++ b/packages/nextjs/src/client/components/admin/UserList/UserList.tsx @@ -0,0 +1,26 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +'use client'; + +import {UserList as ReactUserList, UserListProps as ReactUserListProps} from '@thunderid/react'; +import {FC, ReactElement} from 'react'; + +export type UserListProps = ReactUserListProps; + +/** + * Lists the users the signed-in user is allowed to see, a page at a time. + * + * Management requests go through a server action with the signed-in user's server-held session, so + * the access token never reaches the browser. Whether a request succeeds is the server's decision. + * + * @example + * ```tsx + * router.push(`/admin/users/${user.id}`)} /> + * ``` + */ +const UserList: FC = (props: UserListProps): ReactElement => ; + +UserList.displayName = 'UserList'; + +export default UserList; diff --git a/packages/nextjs/src/client/components/presentation/SignIn/SignIn.tsx b/packages/nextjs/src/client/components/auth/SignIn/SignIn.tsx similarity index 100% rename from packages/nextjs/src/client/components/presentation/SignIn/SignIn.tsx rename to packages/nextjs/src/client/components/auth/SignIn/SignIn.tsx diff --git a/packages/nextjs/src/client/components/presentation/SignUp/SignUp.tsx b/packages/nextjs/src/client/components/auth/SignUp/SignUp.tsx similarity index 100% rename from packages/nextjs/src/client/components/presentation/SignUp/SignUp.tsx rename to packages/nextjs/src/client/components/auth/SignUp/SignUp.tsx diff --git a/packages/nextjs/src/client/components/actions/SignInButton/SignInButton.tsx b/packages/nextjs/src/client/components/auth/buttons/SignInButton/SignInButton.tsx similarity index 97% rename from packages/nextjs/src/client/components/actions/SignInButton/SignInButton.tsx rename to packages/nextjs/src/client/components/auth/buttons/SignInButton/SignInButton.tsx index 8e51658f..8fbc0001 100644 --- a/packages/nextjs/src/client/components/actions/SignInButton/SignInButton.tsx +++ b/packages/nextjs/src/client/components/auth/buttons/SignInButton/SignInButton.tsx @@ -8,7 +8,7 @@ import {BaseSignInButton, BaseSignInButtonProps, useTranslation} from '@thunderi import {AppRouterInstance} from 'next/dist/shared/lib/app-router-context.shared-runtime'; import {useRouter} from 'next/navigation'; import {forwardRef, ForwardRefExoticComponent, ReactElement, Ref, RefAttributes, MouseEvent, useState} from 'react'; -import useThunderID from '../../../contexts/ThunderID/useThunderID'; +import useThunderID from '../../../../contexts/ThunderID/useThunderID'; /** * Props interface of {@link SignInButton} diff --git a/packages/nextjs/src/client/components/actions/SignOutButton/SignOutButton.tsx b/packages/nextjs/src/client/components/auth/buttons/SignOutButton/SignOutButton.tsx similarity index 94% rename from packages/nextjs/src/client/components/actions/SignOutButton/SignOutButton.tsx rename to packages/nextjs/src/client/components/auth/buttons/SignOutButton/SignOutButton.tsx index 7117b614..938e05c4 100644 --- a/packages/nextjs/src/client/components/actions/SignOutButton/SignOutButton.tsx +++ b/packages/nextjs/src/client/components/auth/buttons/SignOutButton/SignOutButton.tsx @@ -5,8 +5,8 @@ import {BaseSignOutButton, BaseSignOutButtonProps, useTranslation} from '@thunderid/react'; import {forwardRef, ForwardRefExoticComponent, ReactElement, Ref, RefAttributes, useState, MouseEvent} from 'react'; -import logger from '../../../../utils/logger'; -import useThunderID from '../../../contexts/ThunderID/useThunderID'; +import logger from '../../../../../utils/logger'; +import useThunderID from '../../../../contexts/ThunderID/useThunderID'; /** * Interface for SignInButton component props. diff --git a/packages/nextjs/src/client/components/actions/SignUpButton/SignUpButton.tsx b/packages/nextjs/src/client/components/auth/buttons/SignUpButton/SignUpButton.tsx similarity index 97% rename from packages/nextjs/src/client/components/actions/SignUpButton/SignUpButton.tsx rename to packages/nextjs/src/client/components/auth/buttons/SignUpButton/SignUpButton.tsx index ee1d459e..57553406 100644 --- a/packages/nextjs/src/client/components/actions/SignUpButton/SignUpButton.tsx +++ b/packages/nextjs/src/client/components/auth/buttons/SignUpButton/SignUpButton.tsx @@ -8,7 +8,7 @@ import {BaseSignUpButton, BaseSignUpButtonProps, useTranslation} from '@thunderi import {AppRouterInstance} from 'next/dist/shared/lib/app-router-context.shared-runtime'; import {useRouter} from 'next/navigation'; import {forwardRef, ForwardRefExoticComponent, MouseEvent, ReactElement, Ref, RefAttributes, useState} from 'react'; -import useThunderID from '../../../contexts/ThunderID/useThunderID'; +import useThunderID from '../../../../contexts/ThunderID/useThunderID'; /** * Props interface of {@link SignUpButton} diff --git a/packages/react/src/components/control/Loading.tsx b/packages/nextjs/src/client/components/auth/guards/Loading/Loading.tsx similarity index 92% rename from packages/react/src/components/control/Loading.tsx rename to packages/nextjs/src/client/components/auth/guards/Loading/Loading.tsx index 2db648a3..e2e217c9 100644 --- a/packages/react/src/components/control/Loading.tsx +++ b/packages/nextjs/src/client/components/auth/guards/Loading/Loading.tsx @@ -1,8 +1,10 @@ // Copyright 2025 The ThunderID Authors // SPDX-License-Identifier: Apache-2.0 +'use client'; + import {FC, PropsWithChildren, ReactNode} from 'react'; -import useThunderID from '../../contexts/ThunderID/useThunderID'; +import useThunderID from '../../../../contexts/ThunderID/useThunderID'; /** * Props for the Loading component. diff --git a/packages/nextjs/src/client/components/control/SignedIn/SignedIn.tsx b/packages/nextjs/src/client/components/auth/guards/SignedIn/SignedIn.tsx similarity index 92% rename from packages/nextjs/src/client/components/control/SignedIn/SignedIn.tsx rename to packages/nextjs/src/client/components/auth/guards/SignedIn/SignedIn.tsx index 0c9380e8..960786f1 100644 --- a/packages/nextjs/src/client/components/control/SignedIn/SignedIn.tsx +++ b/packages/nextjs/src/client/components/auth/guards/SignedIn/SignedIn.tsx @@ -4,7 +4,7 @@ 'use client'; import {FC, PropsWithChildren, ReactNode} from 'react'; -import useThunderID from '../../../contexts/ThunderID/useThunderID'; +import useThunderID from '../../../../contexts/ThunderID/useThunderID'; /** * Props interface of {@link SignedIn} diff --git a/packages/nextjs/src/client/components/control/SignedOut/SignedOut.tsx b/packages/nextjs/src/client/components/auth/guards/SignedOut/SignedOut.tsx similarity index 92% rename from packages/nextjs/src/client/components/control/SignedOut/SignedOut.tsx rename to packages/nextjs/src/client/components/auth/guards/SignedOut/SignedOut.tsx index c2f02cb0..cc878e8b 100644 --- a/packages/nextjs/src/client/components/control/SignedOut/SignedOut.tsx +++ b/packages/nextjs/src/client/components/auth/guards/SignedOut/SignedOut.tsx @@ -4,7 +4,7 @@ 'use client'; import {FC, PropsWithChildren, ReactNode} from 'react'; -import useThunderID from '../../../contexts/ThunderID/useThunderID'; +import useThunderID from '../../../../contexts/ThunderID/useThunderID'; /** * Props interface of {@link SignedOut} diff --git a/packages/nextjs/src/client/components/presentation/AuthFlowSelect/AuthFlowSelect.tsx b/packages/nextjs/src/client/components/primitives/AuthFlowSelect/AuthFlowSelect.tsx similarity index 100% rename from packages/nextjs/src/client/components/presentation/AuthFlowSelect/AuthFlowSelect.tsx rename to packages/nextjs/src/client/components/primitives/AuthFlowSelect/AuthFlowSelect.tsx diff --git a/packages/nextjs/src/client/components/presentation/UserSelect/UserSelect.tsx b/packages/nextjs/src/client/components/primitives/UserSelect/UserSelect.tsx similarity index 100% rename from packages/nextjs/src/client/components/presentation/UserSelect/UserSelect.tsx rename to packages/nextjs/src/client/components/primitives/UserSelect/UserSelect.tsx diff --git a/packages/nextjs/src/client/components/presentation/UserTypeSelect/UserTypeSelect.tsx b/packages/nextjs/src/client/components/primitives/UserTypeSelect/UserTypeSelect.tsx similarity index 100% rename from packages/nextjs/src/client/components/presentation/UserTypeSelect/UserTypeSelect.tsx rename to packages/nextjs/src/client/components/primitives/UserTypeSelect/UserTypeSelect.tsx diff --git a/packages/nextjs/src/client/contexts/ThunderID/ThunderIDProvider.tsx b/packages/nextjs/src/client/contexts/ThunderID/ThunderIDProvider.tsx index c0a93430..d51fb936 100644 --- a/packages/nextjs/src/client/contexts/ThunderID/ThunderIDProvider.tsx +++ b/packages/nextjs/src/client/contexts/ThunderID/ThunderIDProvider.tsx @@ -32,6 +32,7 @@ import ThunderIDContext, {ThunderIDContextProps} from './ThunderIDContext'; import {RefreshResult} from '../../../server/actions/refreshToken'; import logger from '../../../utils/logger'; import fetchFlowMetaFromServer from '../../fetchFlowMetaFromServer'; +import fetchManagementFromServer from '../../fetchManagementFromServer'; /** * Props interface of {@link ThunderIDClientProvider} @@ -67,6 +68,7 @@ export type ThunderIDClientProviderProps = Partial> = ({ baseUrl, children, + endpoints, signIn, clearSession, refreshToken, @@ -337,12 +339,18 @@ const ThunderIDClientProvider: FC Promise.resolve(null), http: { + // The admin components and hooks send management requests through a server action that adds + // the server-held access token, so the token never reaches the browser. + fetcher: fetchManagementFromServer, request: unsupported('http.request'), requestAll: unsupported('http.requestAll'), }, @@ -371,6 +379,7 @@ const ThunderIDClientProvider: FC => { + const result: ManagementRequestActionResult = await managementRequestAction({ + ...(typeof config?.body === 'string' ? {body: config.body} : {}), + method: config?.method ?? 'GET', + url, + }); + + const {body, status, statusText} = result; + + return { + json: () => Promise.resolve(body ? (JSON.parse(body) as unknown) : undefined), + ok: status >= 200 && status < 300, + status, + statusText, + text: () => Promise.resolve(body), + } as Response; +}; + +export default fetchManagementFromServer; diff --git a/packages/nextjs/src/client/fetchUserTypesFromServer.ts b/packages/nextjs/src/client/fetchUserTypesFromServer.ts index fadc718b..0851091b 100644 --- a/packages/nextjs/src/client/fetchUserTypesFromServer.ts +++ b/packages/nextjs/src/client/fetchUserTypesFromServer.ts @@ -3,7 +3,7 @@ import type {FetchUserTypes, SelectPage, SelectPageRequest} from '@thunderid/node'; import loadPageFromAction from './loadPageFromAction'; -import getUserTypesAction from '../server/actions/getUserTypesAction'; +import getUserTypesAction from '../server/actions/management/getUserTypesAction'; /** * The loader behind `UserTypeSelect`: loads user types through {@link getUserTypesAction} with the signed-in diff --git a/packages/nextjs/src/client/fetchUsersFromServer.ts b/packages/nextjs/src/client/fetchUsersFromServer.ts index b9b49920..5ee194cb 100644 --- a/packages/nextjs/src/client/fetchUsersFromServer.ts +++ b/packages/nextjs/src/client/fetchUsersFromServer.ts @@ -3,7 +3,7 @@ import type {FetchUsers, SelectPage, SelectPageRequest} from '@thunderid/node'; import loadPageFromAction from './loadPageFromAction'; -import getUsersAction from '../server/actions/getUsersAction'; +import getUsersAction from '../server/actions/management/getUsersAction'; /** * The loader behind `UserSelect`: loads users through {@link getUsersAction} with the signed-in user's server-held session. diff --git a/packages/nextjs/src/client/hooks/management/useGrantedPermissions.ts b/packages/nextjs/src/client/hooks/management/useGrantedPermissions.ts new file mode 100644 index 00000000..9cc4e62e --- /dev/null +++ b/packages/nextjs/src/client/hooks/management/useGrantedPermissions.ts @@ -0,0 +1,60 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +'use client'; + +import {hasPermission} from '@thunderid/node'; +import type {GrantedPermissions} from '@thunderid/react'; +import {useCallback, useEffect, useMemo, useState} from 'react'; +import getGrantedPermissionsAction from '../../../server/actions/management/getGrantedPermissionsAction'; +import useThunderID from '../../contexts/ThunderID/useThunderID'; + +/** + * Reads the permissions the signed-in user's access token grants, for example to hide a page the + * user cannot use. The token stays on the server: only the list of permissions is sent to the + * browser, through a server action. Same shape as `useGrantedPermissions` in `@thunderid/react`. + * + * @returns The granted permissions and a `can` check. + * + * @example + * ```tsx + * const {can} = useGrantedPermissions(); + * return can('system:group:view') ? : null; + * ``` + */ +const useGrantedPermissions = (): GrantedPermissions => { + const {isSignedIn} = useThunderID(); + const [granted, setGranted] = useState(null); + + useEffect(() => { + if (!isSignedIn) { + return undefined; + } + + let active = true; + + void getGrantedPermissionsAction() + .catch((): string[] => []) + .then((tokenPermissions: string[]) => { + if (active) { + setGranted(tokenPermissions); + } + }); + + return (): void => { + active = false; + }; + }, [isSignedIn]); + + const permissions: string[] = useMemo(() => (isSignedIn ? (granted ?? []) : []), [granted, isSignedIn]); + const isLoading: boolean = !!isSignedIn && granted === null; + + const can: (required: string) => boolean = useCallback( + (required: string): boolean => hasPermission(permissions, required), + [permissions], + ); + + return {can, isLoading, permissions}; +}; + +export default useGrantedPermissions; diff --git a/packages/nextjs/src/client/index.ts b/packages/nextjs/src/client/index.ts index 79f112d6..59432593 100644 --- a/packages/nextjs/src/client/index.ts +++ b/packages/nextjs/src/client/index.ts @@ -3,47 +3,92 @@ export {default as useThunderID} from './contexts/ThunderID/useThunderID'; -export {default as SignedIn} from './components/control/SignedIn/SignedIn'; -export type {SignedInProps} from './components/control/SignedIn/SignedIn'; +export {default as SignedIn} from './components/auth/guards/SignedIn/SignedIn'; +export type {SignedInProps} from './components/auth/guards/SignedIn/SignedIn'; -export {default as SignedOut} from './components/control/SignedOut/SignedOut'; -export type {SignedOutProps} from './components/control/SignedOut/SignedOut'; +export {default as SignedOut} from './components/auth/guards/SignedOut/SignedOut'; +export type {SignedOutProps} from './components/auth/guards/SignedOut/SignedOut'; -export {default as SignInButton} from './components/actions/SignInButton/SignInButton'; -export type {SignInButtonProps} from './components/actions/SignInButton/SignInButton'; +export {default as SignInButton} from './components/auth/buttons/SignInButton/SignInButton'; +export type {SignInButtonProps} from './components/auth/buttons/SignInButton/SignInButton'; -export {default as SignUpButton} from './components/actions/SignUpButton/SignUpButton'; -export type {SignUpButtonProps} from './components/actions/SignUpButton/SignUpButton'; +export {default as SignUpButton} from './components/auth/buttons/SignUpButton/SignUpButton'; +export type {SignUpButtonProps} from './components/auth/buttons/SignUpButton/SignUpButton'; -export {default as SignIn} from './components/presentation/SignIn/SignIn'; -export type {SignInProps} from './components/presentation/SignIn/SignIn'; +export {default as SignIn} from './components/auth/SignIn/SignIn'; +export type {SignInProps} from './components/auth/SignIn/SignIn'; -export {default as SignOutButton} from './components/actions/SignOutButton/SignOutButton'; -export type {SignOutButtonProps} from './components/actions/SignOutButton/SignOutButton'; +export {default as SignOutButton} from './components/auth/buttons/SignOutButton/SignOutButton'; +export type {SignOutButtonProps} from './components/auth/buttons/SignOutButton/SignOutButton'; -export {default as User} from './components/presentation/User/User'; -export type {UserProps} from './components/presentation/User/User'; +export {default as User} from './components/account/User/User'; +export type {UserProps} from './components/account/User/User'; -export {default as SignUp} from './components/presentation/SignUp/SignUp'; -export type {SignUpProps} from './components/presentation/SignUp/SignUp'; +export {default as SignUp} from './components/auth/SignUp/SignUp'; +export type {SignUpProps} from './components/auth/SignUp/SignUp'; -export {default as UserDropdown} from './components/presentation/UserDropdown/UserDropdown'; -export type {UserDropdownProps} from './components/presentation/UserDropdown/UserDropdown'; +export {default as UserDropdown} from './components/account/UserDropdown/UserDropdown'; +export type {UserDropdownProps} from './components/account/UserDropdown/UserDropdown'; -export {default as UserAvatar} from './components/presentation/UserAvatar/UserAvatar'; -export type {UserAvatarProps} from './components/presentation/UserAvatar/UserAvatar'; +export {default as UserAvatar} from './components/account/UserAvatar/UserAvatar'; +export type {UserAvatarProps} from './components/account/UserAvatar/UserAvatar'; -export {default as UserProfile} from './components/presentation/UserProfile/UserProfile'; -export type {UserProfileProps} from './components/presentation/UserProfile/UserProfile'; +export {default as UserProfile} from './components/account/UserProfile/UserProfile'; +export type {UserProfileProps} from './components/account/UserProfile/UserProfile'; -export {default as ChangeCredential} from './components/presentation/ChangeCredential/ChangeCredential'; -export type {ChangeCredentialProps} from './components/presentation/ChangeCredential/ChangeCredential'; +export {default as ChangeCredential} from './components/account/ChangeCredential/ChangeCredential'; +export type {ChangeCredentialProps} from './components/account/ChangeCredential/ChangeCredential'; -export {default as UserSelect} from './components/presentation/UserSelect/UserSelect'; -export type {UserSelectProps} from './components/presentation/UserSelect/UserSelect'; +export {default as UserSelect} from './components/primitives/UserSelect/UserSelect'; +export type {UserSelectProps} from './components/primitives/UserSelect/UserSelect'; -export {default as AuthFlowSelect} from './components/presentation/AuthFlowSelect/AuthFlowSelect'; -export type {AuthFlowSelectProps} from './components/presentation/AuthFlowSelect/AuthFlowSelect'; +export {default as AuthFlowSelect} from './components/primitives/AuthFlowSelect/AuthFlowSelect'; +export type {AuthFlowSelectProps} from './components/primitives/AuthFlowSelect/AuthFlowSelect'; -export {default as UserTypeSelect} from './components/presentation/UserTypeSelect/UserTypeSelect'; -export type {UserTypeSelectProps} from './components/presentation/UserTypeSelect/UserTypeSelect'; +export {default as UserTypeSelect} from './components/primitives/UserTypeSelect/UserTypeSelect'; +export type {UserTypeSelectProps} from './components/primitives/UserTypeSelect/UserTypeSelect'; + +// Admin (management) components. Their requests run through a server action with the server-held session. + +export {default as UserList} from './components/admin/UserList/UserList'; +export type {UserListProps} from './components/admin/UserList/UserList'; + +export {default as UserCreate} from './components/admin/UserCreate/UserCreate'; +export type {UserCreateProps} from './components/admin/UserCreate/UserCreate'; + +export {default as UserDetails} from './components/admin/UserDetails/UserDetails'; +export type {UserDetailsProps} from './components/admin/UserDetails/UserDetails'; + +export {default as GroupList} from './components/admin/GroupList/GroupList'; +export type {GroupListProps} from './components/admin/GroupList/GroupList'; + +export {default as GroupCreate} from './components/admin/GroupCreate/GroupCreate'; +export type {GroupCreateProps} from './components/admin/GroupCreate/GroupCreate'; + +export {default as GroupMembers} from './components/admin/GroupMembers/GroupMembers'; +export type {GroupMembersProps} from './components/admin/GroupMembers/GroupMembers'; + +export {default as RoleList} from './components/admin/RoleList/RoleList'; +export type {RoleListProps} from './components/admin/RoleList/RoleList'; + +export {default as RoleCreate} from './components/admin/RoleCreate/RoleCreate'; +export type {RoleCreateProps} from './components/admin/RoleCreate/RoleCreate'; + +export {default as RoleAssignments} from './components/admin/RoleAssignments/RoleAssignments'; +export type {RoleAssignmentsProps} from './components/admin/RoleAssignments/RoleAssignments'; + +export {default as RolePermissions} from './components/admin/RolePermissions/RolePermissions'; +export type {RolePermissionsProps} from './components/admin/RolePermissions/RolePermissions'; + +export {default as AgentList} from './components/admin/AgentList/AgentList'; +export type {AgentListProps} from './components/admin/AgentList/AgentList'; + +export {default as AgentCreate} from './components/admin/AgentCreate/AgentCreate'; +export type {AgentCreateProps} from './components/admin/AgentCreate/AgentCreate'; + +export {default as AgentDetails} from './components/admin/AgentDetails/AgentDetails'; +export type {AgentDetailsProps} from './components/admin/AgentDetails/AgentDetails'; + +export {default as useGrantedPermissions} from './hooks/management/useGrantedPermissions'; + +export * from './admin'; diff --git a/packages/nextjs/src/server/ThunderIDProvider.tsx b/packages/nextjs/src/server/ThunderIDProvider.tsx index 65221527..e803c6d9 100644 --- a/packages/nextjs/src/server/ThunderIDProvider.tsx +++ b/packages/nextjs/src/server/ThunderIDProvider.tsx @@ -17,23 +17,24 @@ import { } from '@thunderid/node'; import {ThunderIDProviderProps} from '@thunderid/react'; import {FC, PropsWithChildren, ReactElement} from 'react'; +import getUserAction from './actions/account/getUserAction'; +import getUserProfileAction from './actions/account/getUserProfileAction'; +import getUserSchemaAction from './actions/account/getUserSchemaAction'; +import updateUserProfileAction from './actions/account/updateUserProfileAction'; +import handleOAuthCallbackAction from './actions/auth/handleOAuthCallbackAction'; +import signInAction from './actions/auth/signInAction'; +import signOutAction from './actions/auth/signOutAction'; +import signUpAction from './actions/auth/signUpAction'; import clearSession from './actions/clearSession'; import getSessionId from './actions/getSessionId'; import getSessionPayload from './actions/getSessionPayload'; -import getUserAction from './actions/getUserAction'; -import getUserProfileAction from './actions/getUserProfileAction'; -import getUserSchemaAction from './actions/getUserSchemaAction'; -import handleOAuthCallbackAction from './actions/handleOAuthCallbackAction'; import isSignedIn from './actions/isSignedIn'; import refreshToken from './actions/refreshToken'; -import signInAction from './actions/signInAction'; -import signOutAction from './actions/signOutAction'; -import signUpAction from './actions/signUpAction'; -import updateUserProfileAction from './actions/updateUserProfileAction'; import getClient from './getClient'; import ThunderIDClientProvider from '../client/contexts/ThunderID/ThunderIDProvider.js'; import {ThunderIDNextConfig} from '../models/config'; import logger from '../utils/logger'; +import {pickManagementEndpoints} from '../utils/managementCollections'; import {SessionTokenPayload} from '../utils/SessionManager'; /** @@ -203,6 +204,7 @@ const ThunderIDServerProvider: FC ({default: vi.fn()})); -vi.mock('../../getClient', () => ({default: vi.fn()})); +vi.mock('../../getSessionId', () => ({default: vi.fn()})); +vi.mock('../../../getClient', () => ({default: vi.fn()})); const mockGetFlows = vi.fn(); diff --git a/packages/nextjs/src/server/actions/__tests__/getFlowMetaAction.test.ts b/packages/nextjs/src/server/actions/auth/__tests__/getFlowMetaAction.test.ts similarity index 96% rename from packages/nextjs/src/server/actions/__tests__/getFlowMetaAction.test.ts rename to packages/nextjs/src/server/actions/auth/__tests__/getFlowMetaAction.test.ts index af8b643e..34969826 100644 --- a/packages/nextjs/src/server/actions/__tests__/getFlowMetaAction.test.ts +++ b/packages/nextjs/src/server/actions/auth/__tests__/getFlowMetaAction.test.ts @@ -3,10 +3,10 @@ import {getFlowMeta, ThunderIDAPIError} from '@thunderid/node'; import {beforeEach, describe, expect, it, vi} from 'vitest'; -import getClient from '../../getClient'; +import getClient from '../../../getClient'; import getFlowMetaAction from '../getFlowMetaAction'; -vi.mock('../../getClient', () => ({default: vi.fn()})); +vi.mock('../../../getClient', () => ({default: vi.fn()})); vi.mock('@thunderid/node', async (importOriginal: () => Promise>) => ({ ...(await importOriginal()), getFlowMeta: vi.fn(), diff --git a/packages/nextjs/src/server/actions/__tests__/handleOAuthCallbackAction.test.ts b/packages/nextjs/src/server/actions/auth/__tests__/handleOAuthCallbackAction.test.ts similarity index 92% rename from packages/nextjs/src/server/actions/__tests__/handleOAuthCallbackAction.test.ts rename to packages/nextjs/src/server/actions/auth/__tests__/handleOAuthCallbackAction.test.ts index 1511fb7e..c1acb56f 100644 --- a/packages/nextjs/src/server/actions/__tests__/handleOAuthCallbackAction.test.ts +++ b/packages/nextjs/src/server/actions/auth/__tests__/handleOAuthCallbackAction.test.ts @@ -3,19 +3,19 @@ import {cookies} from 'next/headers'; import {afterEach, beforeEach, describe, expect, it, Mock, vi} from 'vitest'; -import SessionManager from '../../../utils/SessionManager'; -import getClient from '../../getClient'; +import SessionManager from '../../../../utils/SessionManager'; +import getClient from '../../../getClient'; import handleOAuthCallbackAction from '../handleOAuthCallbackAction'; vi.mock('next/headers', () => ({ cookies: vi.fn(), })); -vi.mock('../../getClient', () => ({ +vi.mock('../../../getClient', () => ({ default: vi.fn(), })); -vi.mock('../../../utils/SessionManager', () => ({ +vi.mock('../../../../utils/SessionManager', () => ({ default: { createSessionToken: vi.fn(), getSessionCookieName: vi.fn(), @@ -26,7 +26,7 @@ vi.mock('../../../utils/SessionManager', () => ({ }, })); -vi.mock('../../../utils/chunkedCookie', () => ({ +vi.mock('../../../../utils/chunkedCookie', () => ({ setChunkedCookie: vi.fn(), })); diff --git a/packages/nextjs/src/server/actions/getAuthFlowsAction.ts b/packages/nextjs/src/server/actions/auth/getAuthFlowsAction.ts similarity index 93% rename from packages/nextjs/src/server/actions/getAuthFlowsAction.ts rename to packages/nextjs/src/server/actions/auth/getAuthFlowsAction.ts index 276b83e7..fbf5ceb5 100644 --- a/packages/nextjs/src/server/actions/getAuthFlowsAction.ts +++ b/packages/nextjs/src/server/actions/auth/getAuthFlowsAction.ts @@ -4,7 +4,7 @@ 'use server'; import {AuthFlowSelectConstants, capPageSize, toAuthFlowSelectPage} from '@thunderid/node'; -import runSelectPageAction, {SelectPageActionResult} from './runSelectPageAction'; +import runSelectPageAction, {SelectPageActionResult} from '../runSelectPageAction'; /** * Request from the browser. Carries only paging; the session comes from the request cookie and the flow diff --git a/packages/nextjs/src/server/actions/getFlowMetaAction.ts b/packages/nextjs/src/server/actions/auth/getFlowMetaAction.ts similarity index 95% rename from packages/nextjs/src/server/actions/getFlowMetaAction.ts rename to packages/nextjs/src/server/actions/auth/getFlowMetaAction.ts index 99ab53ef..a33c478e 100644 --- a/packages/nextjs/src/server/actions/getFlowMetaAction.ts +++ b/packages/nextjs/src/server/actions/auth/getFlowMetaAction.ts @@ -10,9 +10,9 @@ import { resolveResourceEndpoint, ThunderIDAPIError, } from '@thunderid/node'; -import {ThunderIDNextConfig} from '../../models/config'; -import logger from '../../utils/logger'; -import getClient from '../getClient'; +import {ThunderIDNextConfig} from '../../../models/config'; +import logger from '../../../utils/logger'; +import getClient from '../../getClient'; /** * What {@link getFlowMetaAction} returns: the flow metadata, or a plain failure. It never throws, because a thrown diff --git a/packages/nextjs/src/server/actions/handleOAuthCallbackAction.ts b/packages/nextjs/src/server/actions/auth/handleOAuthCallbackAction.ts similarity index 94% rename from packages/nextjs/src/server/actions/handleOAuthCallbackAction.ts rename to packages/nextjs/src/server/actions/auth/handleOAuthCallbackAction.ts index 08c4bb4d..f5936906 100644 --- a/packages/nextjs/src/server/actions/handleOAuthCallbackAction.ts +++ b/packages/nextjs/src/server/actions/auth/handleOAuthCallbackAction.ts @@ -5,11 +5,11 @@ import {IdToken} from '@thunderid/node'; import {cookies} from 'next/headers'; -import {ThunderIDNextConfig} from '../../models/config'; -import {setChunkedCookie} from '../../utils/chunkedCookie'; -import logger from '../../utils/logger'; -import SessionManager from '../../utils/SessionManager'; -import getClient from '../getClient'; +import {ThunderIDNextConfig} from '../../../models/config'; +import {setChunkedCookie} from '../../../utils/chunkedCookie'; +import logger from '../../../utils/logger'; +import SessionManager from '../../../utils/SessionManager'; +import getClient from '../../getClient'; type RequestCookies = Awaited>; diff --git a/packages/nextjs/src/server/actions/signInAction.ts b/packages/nextjs/src/server/actions/auth/signInAction.ts similarity index 94% rename from packages/nextjs/src/server/actions/signInAction.ts rename to packages/nextjs/src/server/actions/auth/signInAction.ts index 4ab70d5a..739ee61a 100644 --- a/packages/nextjs/src/server/actions/signInAction.ts +++ b/packages/nextjs/src/server/actions/auth/signInAction.ts @@ -11,11 +11,11 @@ import { isEmpty, } from '@thunderid/node'; import {cookies} from 'next/headers'; -import {ThunderIDNextConfig} from '../../models/config'; -import {getChunkedCookie, setChunkedCookie} from '../../utils/chunkedCookie'; -import logger from '../../utils/logger'; -import SessionManager, {SessionTokenPayload} from '../../utils/SessionManager'; -import getClient from '../getClient'; +import {ThunderIDNextConfig} from '../../../models/config'; +import {getChunkedCookie, setChunkedCookie} from '../../../utils/chunkedCookie'; +import logger from '../../../utils/logger'; +import SessionManager, {SessionTokenPayload} from '../../../utils/SessionManager'; +import getClient from '../../getClient'; type RequestCookies = Awaited>; diff --git a/packages/nextjs/src/server/actions/signOutAction.ts b/packages/nextjs/src/server/actions/auth/signOutAction.ts similarity index 87% rename from packages/nextjs/src/server/actions/signOutAction.ts rename to packages/nextjs/src/server/actions/auth/signOutAction.ts index 3381ac41..1446f3e2 100644 --- a/packages/nextjs/src/server/actions/signOutAction.ts +++ b/packages/nextjs/src/server/actions/auth/signOutAction.ts @@ -4,11 +4,11 @@ 'use server'; import {cookies} from 'next/headers'; -import getSessionId from './getSessionId'; -import {deleteChunkedCookie} from '../../utils/chunkedCookie'; -import logger from '../../utils/logger'; -import SessionManager from '../../utils/SessionManager'; -import getClient from '../getClient'; +import {deleteChunkedCookie} from '../../../utils/chunkedCookie'; +import logger from '../../../utils/logger'; +import SessionManager from '../../../utils/SessionManager'; +import getClient from '../../getClient'; +import getSessionId from '../getSessionId'; type RequestCookies = Awaited>; diff --git a/packages/nextjs/src/server/actions/signUpAction.ts b/packages/nextjs/src/server/actions/auth/signUpAction.ts similarity index 93% rename from packages/nextjs/src/server/actions/signUpAction.ts rename to packages/nextjs/src/server/actions/auth/signUpAction.ts index 7dbf79f0..749907cb 100644 --- a/packages/nextjs/src/server/actions/signUpAction.ts +++ b/packages/nextjs/src/server/actions/auth/signUpAction.ts @@ -3,7 +3,7 @@ 'use server'; -import getClient from '../getClient'; +import getClient from '../../getClient'; /** * Server action for initiating the sign-up redirect flow. diff --git a/packages/nextjs/src/server/actions/management/__tests__/getGrantedPermissionsAction.test.ts b/packages/nextjs/src/server/actions/management/__tests__/getGrantedPermissionsAction.test.ts new file mode 100644 index 00000000..836f867f --- /dev/null +++ b/packages/nextjs/src/server/actions/management/__tests__/getGrantedPermissionsAction.test.ts @@ -0,0 +1,49 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {beforeEach, describe, expect, it, vi} from 'vitest'; +import getClient from '../../../getClient'; +import getSessionId from '../../getSessionId'; +import getGrantedPermissionsAction from '../getGrantedPermissionsAction'; + +vi.mock('../../getSessionId', () => ({default: vi.fn()})); +vi.mock('../../../getClient', () => ({default: vi.fn()})); + +const toJwt = (payload: Record): string => + `header.${Buffer.from(JSON.stringify(payload)).toString('base64url')}.signature`; + +const mockGetAccessToken = vi.fn(); + +describe('getGrantedPermissionsAction', () => { + beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(getSessionId).mockResolvedValue('session-1'); + vi.mocked(getClient).mockReturnValue({getAccessToken: mockGetAccessToken} as unknown as ReturnType< + typeof getClient + >); + }); + + it("returns the permissions in the session's access token, not the token", async () => { + const token: string = toJwt({scope: 'openid system:user:view system:group'}); + mockGetAccessToken.mockResolvedValue(token); + + const result: string[] = await getGrantedPermissionsAction(); + + expect(mockGetAccessToken).toHaveBeenCalledWith('session-1'); + expect(result).toEqual(['openid', 'system:user:view', 'system:group']); + expect(JSON.stringify(result)).not.toContain(token); + }); + + it('returns an empty list without a session', async () => { + vi.mocked(getSessionId).mockResolvedValue(undefined); + + await expect(getGrantedPermissionsAction()).resolves.toEqual([]); + expect(mockGetAccessToken).not.toHaveBeenCalled(); + }); + + it('returns an empty list instead of throwing when the token cannot be read', async () => { + mockGetAccessToken.mockRejectedValue(new Error('Failed to get access token.')); + + await expect(getGrantedPermissionsAction()).resolves.toEqual([]); + }); +}); diff --git a/packages/nextjs/src/server/actions/__tests__/getUserTypesAction.test.ts b/packages/nextjs/src/server/actions/management/__tests__/getUserTypesAction.test.ts similarity index 96% rename from packages/nextjs/src/server/actions/__tests__/getUserTypesAction.test.ts rename to packages/nextjs/src/server/actions/management/__tests__/getUserTypesAction.test.ts index 5da6f42d..e343b2dd 100644 --- a/packages/nextjs/src/server/actions/__tests__/getUserTypesAction.test.ts +++ b/packages/nextjs/src/server/actions/management/__tests__/getUserTypesAction.test.ts @@ -3,12 +3,12 @@ import {ThunderIDAPIError} from '@thunderid/node'; import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; -import getClient from '../../getClient'; -import getSessionId from '../getSessionId'; +import getClient from '../../../getClient'; +import getSessionId from '../../getSessionId'; import getUserTypesAction from '../getUserTypesAction'; -vi.mock('../getSessionId', () => ({default: vi.fn()})); -vi.mock('../../getClient', () => ({default: vi.fn()})); +vi.mock('../../getSessionId', () => ({default: vi.fn()})); +vi.mock('../../../getClient', () => ({default: vi.fn()})); const mockGetUserTypes = vi.fn(); diff --git a/packages/nextjs/src/server/actions/__tests__/getUsersAction.test.ts b/packages/nextjs/src/server/actions/management/__tests__/getUsersAction.test.ts similarity index 96% rename from packages/nextjs/src/server/actions/__tests__/getUsersAction.test.ts rename to packages/nextjs/src/server/actions/management/__tests__/getUsersAction.test.ts index 2254c267..1f56fd8e 100644 --- a/packages/nextjs/src/server/actions/__tests__/getUsersAction.test.ts +++ b/packages/nextjs/src/server/actions/management/__tests__/getUsersAction.test.ts @@ -3,12 +3,12 @@ import {ThunderIDAPIError} from '@thunderid/node'; import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; -import getClient from '../../getClient'; -import getSessionId from '../getSessionId'; +import getClient from '../../../getClient'; +import getSessionId from '../../getSessionId'; import getUsersAction from '../getUsersAction'; -vi.mock('../getSessionId', () => ({default: vi.fn()})); -vi.mock('../../getClient', () => ({default: vi.fn()})); +vi.mock('../../getSessionId', () => ({default: vi.fn()})); +vi.mock('../../../getClient', () => ({default: vi.fn()})); const mockGetUsers = vi.fn(); diff --git a/packages/nextjs/src/server/actions/management/__tests__/managementRequestAction.test.ts b/packages/nextjs/src/server/actions/management/__tests__/managementRequestAction.test.ts new file mode 100644 index 00000000..f82a4e89 --- /dev/null +++ b/packages/nextjs/src/server/actions/management/__tests__/managementRequestAction.test.ts @@ -0,0 +1,131 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import getClient from '../../../getClient'; +import getSessionId from '../../getSessionId'; +import managementRequestAction from '../managementRequestAction'; + +vi.mock('../../getSessionId', () => ({default: vi.fn()})); +vi.mock('../../../getClient', () => ({default: vi.fn()})); + +const mockGetAccessToken = vi.fn(); +const mockFetch = vi.fn(); + +const replyWith = (status: number, body: string, statusText = ''): Response => + ({ + ok: status >= 200 && status < 300, + status, + statusText, + text: () => Promise.resolve(body), + }) as Response; + +describe('managementRequestAction', () => { + beforeEach(() => { + vi.resetAllMocks(); + vi.stubGlobal('fetch', mockFetch); + vi.mocked(getSessionId).mockResolvedValue('session-1'); + vi.mocked(getClient).mockReturnValue({ + getAccessToken: mockGetAccessToken, + getConfiguration: () => Promise.resolve({baseUrl: 'https://id.example.com'}), + initialize: vi.fn(), + isInitialized: true, + } as unknown as ReturnType); + mockGetAccessToken.mockResolvedValue('server-held-token'); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('forwards the request with the server-held token and returns the reply', async () => { + mockFetch.mockResolvedValue(replyWith(200, '{"totalResults":0,"users":[]}', 'OK')); + + const result = await managementRequestAction({method: 'GET', url: 'https://id.example.com/users?limit=20'}); + + expect(mockGetAccessToken).toHaveBeenCalledWith('session-1'); + expect(mockFetch).toHaveBeenCalledWith( + 'https://id.example.com/users?limit=20', + expect.objectContaining({ + headers: {Accept: 'application/json', Authorization: 'Bearer server-held-token'}, + method: 'GET', + redirect: 'error', + }), + ); + expect(result).toEqual({body: '{"totalResults":0,"users":[]}', status: 200, statusText: 'OK'}); + }); + + it('sends a JSON body for a write', async () => { + mockFetch.mockResolvedValue(replyWith(201, '{"id":"g-1"}')); + + await managementRequestAction({body: '{"name":"Admins"}', method: 'post', url: 'https://id.example.com/groups'}); + + const [[url, init]] = mockFetch.mock.calls as [[string, RequestInit]]; + + expect(url).toBe('https://id.example.com/groups'); + expect(init).toMatchObject({body: '{"name":"Admins"}', method: 'POST'}); + expect(init.headers).toMatchObject({'Content-Type': 'application/json'}); + }); + + it('refuses without a session and never reaches the management API', async () => { + vi.mocked(getSessionId).mockResolvedValue(undefined); + + const result = await managementRequestAction({url: 'https://id.example.com/users'}); + + expect(result.status).toBe(401); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('never sends the token to a URL outside the configured management collections', async () => { + const result = await managementRequestAction({url: 'https://attacker.example.net/users'}); + + expect(result.status).toBe(400); + expect(mockGetAccessToken).not.toHaveBeenCalled(); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it.each([ + ['a method the management API does not use', {method: 'PATCH', url: 'https://id.example.com/users'}], + ['a body that is not a string', {body: {name: 'x'}, method: 'POST', url: 'https://id.example.com/groups'}], + [ + 'a body that is far too long', + {body: 'x'.repeat(2 * 1024 * 1024), method: 'POST', url: 'https://id.example.com/groups'}, + ], + ['no URL', {}], + ])('refuses %s', async (_label: string, request: unknown) => { + const result = await managementRequestAction(request as never); + + expect(result.status).toBe(400); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('keeps 403 and 404 distinct but does not repeat the reply of the upstream', async () => { + mockFetch.mockResolvedValueOnce(replyWith(403, '403 from internal-proxy-7.corp.local', 'Forbidden')); + mockFetch.mockResolvedValueOnce(replyWith(404, 'not here')); + + const forbidden = await managementRequestAction({url: 'https://id.example.com/users/u-1'}); + const missing = await managementRequestAction({url: 'https://id.example.com/users/u-2'}); + + expect(forbidden).toMatchObject({status: 403, statusText: 'Forbidden'}); + expect(JSON.stringify(forbidden)).not.toContain('internal-proxy-7'); + expect(missing.status).toBe(404); + }); + + it('maps a network failure to a plain result without its text', async () => { + mockFetch.mockRejectedValue(new Error('connect ECONNREFUSED 10.0.0.5:8090')); + + const result = await managementRequestAction({url: 'https://id.example.com/users'}); + + expect(result.status).toBe(502); + expect(JSON.stringify(result)).not.toContain('10.0.0.5'); + }); + + it('returns 401 when the session has no usable access token', async () => { + mockGetAccessToken.mockRejectedValue(new Error('Failed to get access token.')); + + const result = await managementRequestAction({url: 'https://id.example.com/users'}); + + expect(result.status).toBe(401); + expect(mockFetch).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/nextjs/src/server/actions/management/getGrantedPermissionsAction.ts b/packages/nextjs/src/server/actions/management/getGrantedPermissionsAction.ts new file mode 100644 index 00000000..c125f610 --- /dev/null +++ b/packages/nextjs/src/server/actions/management/getGrantedPermissionsAction.ts @@ -0,0 +1,32 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +'use server'; + +import {getTokenPermissions} from '@thunderid/node'; +import getClient from '../../getClient'; +import getSessionId from '../getSessionId'; + +/** + * Reads the permissions the signed-in user's access token grants, on the server, so only the list of + * permissions reaches the browser and the token itself never does. Use the result only to decide what + * to show; the server still checks every request. + * + * Never throws across the server action boundary: without a session or a readable token it returns + * an empty list. + */ +const getGrantedPermissionsAction = async (): Promise => { + const sessionId: string | undefined = await getSessionId(); + + if (!sessionId) { + return []; + } + + try { + return getTokenPermissions(await getClient().getAccessToken(sessionId)); + } catch { + return []; + } +}; + +export default getGrantedPermissionsAction; diff --git a/packages/nextjs/src/server/actions/getUserTypesAction.ts b/packages/nextjs/src/server/actions/management/getUserTypesAction.ts similarity index 92% rename from packages/nextjs/src/server/actions/getUserTypesAction.ts rename to packages/nextjs/src/server/actions/management/getUserTypesAction.ts index 3c506d3d..b0077ea2 100644 --- a/packages/nextjs/src/server/actions/getUserTypesAction.ts +++ b/packages/nextjs/src/server/actions/management/getUserTypesAction.ts @@ -4,7 +4,7 @@ 'use server'; import {capPageSize, toUserTypeSelectPage} from '@thunderid/node'; -import runSelectPageAction, {SelectPageActionResult} from './runSelectPageAction'; +import runSelectPageAction, {SelectPageActionResult} from '../runSelectPageAction'; /** * Request from the browser. Carries only paging; the session comes from the request cookie. diff --git a/packages/nextjs/src/server/actions/getUsersAction.ts b/packages/nextjs/src/server/actions/management/getUsersAction.ts similarity index 92% rename from packages/nextjs/src/server/actions/getUsersAction.ts rename to packages/nextjs/src/server/actions/management/getUsersAction.ts index 3a23fb47..0ee11eab 100644 --- a/packages/nextjs/src/server/actions/getUsersAction.ts +++ b/packages/nextjs/src/server/actions/management/getUsersAction.ts @@ -4,7 +4,7 @@ 'use server'; import {toUserSelectPage} from '@thunderid/node'; -import runSelectPageAction, {SelectPageActionResult} from './runSelectPageAction'; +import runSelectPageAction, {SelectPageActionResult} from '../runSelectPageAction'; /** * Request from the browser. Carries no session ID, token, or URL; the session comes from the request cookie. diff --git a/packages/nextjs/src/server/actions/management/managementRequestAction.ts b/packages/nextjs/src/server/actions/management/managementRequestAction.ts new file mode 100644 index 00000000..f4f50481 --- /dev/null +++ b/packages/nextjs/src/server/actions/management/managementRequestAction.ts @@ -0,0 +1,126 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +'use server'; + +import {getManagementCollectionUrls, matchManagementUrl} from '@thunderid/node'; +import {ThunderIDNextConfig} from '../../../models/config'; +import getClient from '../../getClient'; +import getSessionId from '../getSessionId'; + +/** + * Request from the browser: one management API request as the core API function built it. It carries + * no token or session; the session comes from the request cookie, and the URL must address one of + * the configured management collections. + */ +export interface ManagementRequestActionRequest { + /** + * JSON request body, already serialized. + */ + body?: string; + method?: string; + url: string; +} + +/** + * The upstream reply, as plain data that can cross the server action boundary. A failure carries a + * message built on the server and the status, never the text of the upstream reply. + */ +export interface ManagementRequestActionResult { + body: string; + status: number; + statusText: string; +} + +const ALLOWED_METHODS: readonly string[] = ['DELETE', 'GET', 'POST', 'PUT']; + +/** Large enough for any management payload, small enough that the action cannot be used to stream data. */ +const MAX_BODY_LENGTH: number = 1024 * 1024; + +const failure = (status: number): ManagementRequestActionResult => ({ + body: `Management request failed (HTTP ${status}).`, + status, + statusText: '', +}); + +/** + * Performs one management API request with the signed-in user's server-held session, so the access + * token never reaches the browser. Used as the `http.fetcher` of the admin components and hooks. + * + * A server action is a public endpoint, so everything the caller sends is checked here rather than + * trusted from its TypeScript type: the method must be one the management API uses, the body must be + * a bounded string, and the URL must address a configured management collection on the configured + * host, so the bearer token is only ever sent where the SDK would have sent it. + * + * Never throws across the server action boundary; failures are mapped to a plain result. + */ +const managementRequestAction = async ( + request: ManagementRequestActionRequest, +): Promise => { + const { + body, + method: rawMethod, + url: rawUrl, + } = (request ?? {}) as Partial>; + const method: string = typeof rawMethod === 'string' ? rawMethod.toUpperCase() : 'GET'; + + if ( + !ALLOWED_METHODS.includes(method) || + (body !== undefined && (typeof body !== 'string' || body.length > MAX_BODY_LENGTH)) + ) { + return failure(400); + } + + const sessionId: string | undefined = await getSessionId(); + + if (!sessionId) { + return failure(401); + } + + try { + const client = getClient(); + + if (!client.isInitialized) { + await client.initialize({} as ThunderIDNextConfig); + } + + // The storage manager may hand the configuration back as a promise. + const config: ThunderIDNextConfig = await Promise.resolve(client.getConfiguration()); + const url: URL | undefined = matchManagementUrl(rawUrl, getManagementCollectionUrls(config)); + + if (!url) { + return failure(400); + } + + let accessToken: string; + + try { + accessToken = await client.getAccessToken(sessionId); + } catch { + return failure(401); + } + + const response: Response = await fetch(url.toString(), { + headers: { + Accept: 'application/json', + Authorization: `Bearer ${accessToken}`, + ...(body !== undefined ? {'Content-Type': 'application/json'} : {}), + }, + method, + // A redirect could carry the request to a host the URL check never saw. + redirect: 'error', + ...(body !== undefined ? {body} : {}), + }); + + if (!response.ok) { + return {...failure(response.status), statusText: response.statusText ?? ''}; + } + + return {body: await response.text(), status: response.status, statusText: response.statusText ?? ''}; + } catch { + // Not logged: a management operation reports failure to its caller and leaves logging to the application. + return failure(502); + } +}; + +export default managementRequestAction; diff --git a/packages/nextjs/src/utils/__tests__/managementCollections.test.ts b/packages/nextjs/src/utils/__tests__/managementCollections.test.ts new file mode 100644 index 00000000..96bb055b --- /dev/null +++ b/packages/nextjs/src/utils/__tests__/managementCollections.test.ts @@ -0,0 +1,22 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {describe, expect, it} from 'vitest'; +import {pickManagementEndpoints} from '../managementCollections'; + +describe('pickManagementEndpoints', () => { + it('keeps only the management collection overrides', () => { + expect( + pickManagementEndpoints({ + authorization: 'https://id.example.com/oauth2/authorize', + groups: 'https://mgmt.example.com/groups', + users: 'https://mgmt.example.com/users', + } as never), + ).toEqual({groups: 'https://mgmt.example.com/groups', users: 'https://mgmt.example.com/users'}); + }); + + it('returns undefined when there is nothing to pass on', () => { + expect(pickManagementEndpoints(undefined)).toBeUndefined(); + expect(pickManagementEndpoints({authorization: 'https://id.example.com/authorize'} as never)).toBeUndefined(); + }); +}); diff --git a/packages/nextjs/src/utils/managementCollections.ts b/packages/nextjs/src/utils/managementCollections.ts new file mode 100644 index 00000000..72d4631d --- /dev/null +++ b/packages/nextjs/src/utils/managementCollections.ts @@ -0,0 +1,30 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {MANAGEMENT_COLLECTION_KEYS} from '@thunderid/node'; +import type {ManagementCollectionKey} from '@thunderid/node'; +import type {ThunderIDNextConfig} from '../models/config'; + +/** + * Picks the management collection overrides out of `config.endpoints`, so the browser builds the + * same URLs the server will accept, without receiving the other endpoint overrides. + */ +export const pickManagementEndpoints = ( + endpoints: ThunderIDNextConfig['endpoints'] | undefined, +): Partial> | undefined => { + if (!endpoints) { + return undefined; + } + + const picked: Partial> = {}; + + MANAGEMENT_COLLECTION_KEYS.forEach((key: ManagementCollectionKey) => { + const value: unknown = (endpoints as Record)[key]; + + if (typeof value === 'string' && value) { + picked[key] = value; + } + }); + + return Object.keys(picked).length > 0 ? picked : undefined; +}; diff --git a/packages/node/src/ThunderIDNodeClient.ts b/packages/node/src/ThunderIDNodeClient.ts index dbfbdb28..2bc8ad7e 100644 --- a/packages/node/src/ThunderIDNodeClient.ts +++ b/packages/node/src/ThunderIDNodeClient.ts @@ -23,13 +23,16 @@ import { ThunderIDAuthException, ThunderIDJavaScriptClient, TokenExchangeRequestConfig, + ThunderIDRuntimeError, TokenResponse, updateMeCredentials, User, } from '@thunderid/javascript'; import AuthURLCallback from './models/AuthURLCallback'; import {ThunderIDNodeConfig} from './models/config'; +import {ManagementResourceKey, ThunderIDNodeManagementClient} from './models/management'; import MemoryCacheStore from './stores/MemoryCacheStore'; +import createManagementClient from './utils/createManagementClient'; import NodeCryptoUtils from './utils/NodeCryptoUtils'; import SessionIndex, {IndexedSessions, SessionBinding} from './utils/SessionIndex'; @@ -457,6 +460,67 @@ class ThunderIDNodeClient e }; } + /** + * Returns the management operations (users, groups, roles, agents, and their sub-resources) bound + * to the session identified by `sessionId`. + * + * Every request is authorized with that session's access token, read at the time of each call; the + * token is never copied onto the returned object or exposed to the caller. Whether a call succeeds + * is the server's decision: the token needs the permissions the server requires for that resource. + * + * The collection URL comes from, in order: the `url` passed to the call, then `endpoints.` + * in the configuration, then `{baseUrl}/{collection}`. + * + * @param sessionId - The session whose access token authorizes the requests. + * @returns The session-bound management client. + * @throws {ThunderIDRuntimeError} `NODE-MANAGEMENT-RC-NF01`, from an operation, when the session + * has no access token. Raised before any request is sent. + * @throws {ThunderIDAPIError} From an operation, when the server rejects the request. HTTP 403 and + * 404 surface as `-ForbiddenError-001` and `-NotFoundError-001`. + * + * @example + * ```typescript + * const admin = client.management(sessionId); + * const {users, totalResults} = await admin.users.list({limit: 20}); + * await admin.groups.addMembers(groupId, [{id: users[0].id, type: 'user'}]); + * ``` + */ + public management(sessionId?: string): ThunderIDNodeManagementClient { + return createManagementClient((key: ManagementResourceKey, explicitUrl?: string) => + this.sessionManagementRequestConfig(key, sessionId, explicitUrl), + ); + } + + /** + * The URL inputs and bearer token for a management request made through {@link management}. Unlike + * {@link managementRequestConfig}, a session without an access token is refused here rather than + * sent to the server with an empty credential. + */ + private async sessionManagementRequestConfig( + key: ManagementResourceKey, + sessionId?: string, + explicitUrl?: string, + ): Promise<{baseUrl: string | undefined; headers: {Authorization: string}; url: string | undefined}> { + const configData: AuthClientConfig = await this.getStorageManager().getConfigData(); + const accessToken: string | undefined = await this.getAccessToken(sessionId); + + if (!accessToken) { + throw new ThunderIDRuntimeError( + 'No access token is available for the session. Sign in before calling a management operation.', + 'NODE-MANAGEMENT-RC-NF01', + 'node', + ); + } + + return { + baseUrl: configData?.baseUrl, + headers: { + Authorization: `Bearer ${accessToken}`, + }, + url: resolveResourceEndpoint(key, configData, explicitUrl), + }; + } + /** * Retrieves one page of the user directory on behalf of the session identified by `userId`. * diff --git a/packages/node/src/__tests__/ThunderIDNodeClient.management.test.ts b/packages/node/src/__tests__/ThunderIDNodeClient.management.test.ts new file mode 100644 index 00000000..354a8020 --- /dev/null +++ b/packages/node/src/__tests__/ThunderIDNodeClient.management.test.ts @@ -0,0 +1,322 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ThunderIDAPIError, ThunderIDRuntimeError} from '@thunderid/javascript'; +import {afterEach, beforeEach, describe, expect, it, MockInstance, vi} from 'vitest'; +import {ThunderIDNodeManagementClient} from '../models/management'; +import ThunderIDNodeClient from '../ThunderIDNodeClient'; + +const BASE_URL = 'https://auth.example.com'; +const TOKEN = 'session-access-token'; + +const jsonResponse = (body: unknown, status = 200): Response => + new Response(body === undefined ? null : JSON.stringify(body), { + headers: {'Content-Type': 'application/json'}, + status, + statusText: status === 200 ? 'OK' : 'Error', + }); + +interface Captured { + body: unknown; + headers: Record; + method: string; + url: string; +} + +const lastCall = (fetchMock: ReturnType): Captured => { + const [url, init] = fetchMock.mock.calls.at(-1) as [string, RequestInit]; + + return { + body: init.body === undefined ? undefined : JSON.parse(init.body as string), + headers: init.headers as Record, + method: init.method!, + url, + }; +}; + +const createClient = async (config: Record = {}): Promise => { + const client = new ThunderIDNodeClient(); + await client.initialize({baseUrl: BASE_URL, clientId: 'test-client', ...config}); + + return client; +}; + +describe('ThunderIDNodeClient management()', () => { + let fetchMock: ReturnType; + let client: ThunderIDNodeClient; + let admin: ThunderIDNodeManagementClient; + let getAccessToken: MockInstance<(sessionId?: string) => Promise>; + + beforeEach(async () => { + fetchMock = vi.fn().mockImplementation(() => Promise.resolve(jsonResponse({}))); + vi.stubGlobal('fetch', fetchMock); + client = await createClient(); + getAccessToken = vi.spyOn(client, 'getAccessToken').mockResolvedValue(TOKEN); + admin = client.management('session-1'); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + const cases: { + body?: unknown; + call: () => Promise; + method: string; + name: string; + url: string; + }[] = [ + { + call: () => admin.users.list({filter: 'username eq "a"', limit: 10, offset: 20}), + method: 'GET', + name: 'users.list', + url: `${BASE_URL}/users?filter=username+eq+%22a%22&include=display&limit=10&offset=20`, + }, + { + call: () => admin.users.get('u 1'), + method: 'GET', + name: 'users.get', + url: `${BASE_URL}/users/u%201?include=display`, + }, + { + body: {ouId: 'ou1', type: 'customer'}, + call: () => admin.users.create({ouId: 'ou1', type: 'customer'}), + method: 'POST', + name: 'users.create', + url: `${BASE_URL}/users`, + }, + { + body: {attributes: {email: 'a@example.com'}}, + call: () => admin.users.update('u1', {attributes: {email: 'a@example.com'}} as never), + method: 'PUT', + name: 'users.update', + url: `${BASE_URL}/users/u1`, + }, + {call: () => admin.users.delete('u1'), method: 'DELETE', name: 'users.delete', url: `${BASE_URL}/users/u1`}, + { + call: () => admin.users.listGroups('u1', {limit: 5}), + method: 'GET', + name: 'users.listGroups', + url: `${BASE_URL}/users/u1/groups?limit=5`, + }, + {call: () => admin.userTypes.list(), method: 'GET', name: 'userTypes.list', url: `${BASE_URL}/user-types`}, + { + call: () => admin.groups.list({limit: 2, offset: 4}), + method: 'GET', + name: 'groups.list', + url: `${BASE_URL}/groups?include=display&limit=2&offset=4`, + }, + { + call: () => admin.groups.get('g1'), + method: 'GET', + name: 'groups.get', + url: `${BASE_URL}/groups/g1?include=display`, + }, + { + body: {name: 'Admins', ouId: 'ou1'}, + call: () => admin.groups.create({name: 'Admins', ouId: 'ou1'} as never), + method: 'POST', + name: 'groups.create', + url: `${BASE_URL}/groups`, + }, + { + body: {name: 'Ops'}, + call: () => admin.groups.update('g1', {name: 'Ops'} as never), + method: 'PUT', + name: 'groups.update', + url: `${BASE_URL}/groups/g1`, + }, + {call: () => admin.groups.delete('g1'), method: 'DELETE', name: 'groups.delete', url: `${BASE_URL}/groups/g1`}, + { + call: () => admin.groups.listMembers('g1', {limit: 3}), + method: 'GET', + name: 'groups.listMembers', + url: `${BASE_URL}/groups/g1/members?include=display&limit=3`, + }, + { + body: {members: [{id: 'u1', type: 'user'}]}, + call: () => admin.groups.addMembers('g1', [{id: 'u1', type: 'user'}]), + method: 'POST', + name: 'groups.addMembers', + url: `${BASE_URL}/groups/g1/members/add`, + }, + { + body: {members: [{id: 'a1', type: 'agent'}]}, + call: () => admin.groups.removeMembers('g1', [{id: 'a1', type: 'agent'}]), + method: 'POST', + name: 'groups.removeMembers', + url: `${BASE_URL}/groups/g1/members/remove`, + }, + {call: () => admin.roles.list({limit: 1}), method: 'GET', name: 'roles.list', url: `${BASE_URL}/roles?limit=1`}, + {call: () => admin.roles.get('r1'), method: 'GET', name: 'roles.get', url: `${BASE_URL}/roles/r1`}, + { + body: {name: 'Auditor', ouId: 'ou1'}, + call: () => admin.roles.create({name: 'Auditor', ouId: 'ou1'} as never), + method: 'POST', + name: 'roles.create', + url: `${BASE_URL}/roles`, + }, + { + body: {name: 'Reader'}, + call: () => admin.roles.update('r1', {name: 'Reader'} as never), + method: 'PUT', + name: 'roles.update', + url: `${BASE_URL}/roles/r1`, + }, + {call: () => admin.roles.delete('r1'), method: 'DELETE', name: 'roles.delete', url: `${BASE_URL}/roles/r1`}, + { + call: () => admin.roles.listAssignments('r1', {limit: 10, type: 'group'}), + method: 'GET', + name: 'roles.listAssignments', + url: `${BASE_URL}/roles/r1/assignments?include=display&limit=10&type=group`, + }, + { + body: {assignments: [{id: 'u1', type: 'user'}]}, + call: () => admin.roles.addAssignments('r1', [{id: 'u1', type: 'user'}]), + method: 'POST', + name: 'roles.addAssignments', + url: `${BASE_URL}/roles/r1/assignments/add`, + }, + { + body: {assignments: [{id: 'g1', type: 'group'}]}, + call: () => admin.roles.removeAssignments('r1', [{id: 'g1', type: 'group'}]), + method: 'POST', + name: 'roles.removeAssignments', + url: `${BASE_URL}/roles/r1/assignments/remove`, + }, + { + call: () => admin.agents.list({offset: 10}), + method: 'GET', + name: 'agents.list', + url: `${BASE_URL}/agents?include=display&offset=10`, + }, + { + call: () => admin.agents.get('a1'), + method: 'GET', + name: 'agents.get', + url: `${BASE_URL}/agents/a1?include=display`, + }, + { + body: {name: 'Concierge', ouId: 'ou1', type: 'assistant'}, + call: () => admin.agents.create({name: 'Concierge', ouId: 'ou1', type: 'assistant'} as never), + method: 'POST', + name: 'agents.create', + url: `${BASE_URL}/agents`, + }, + { + body: {name: 'Butler'}, + call: () => admin.agents.update('a1', {name: 'Butler'} as never), + method: 'PUT', + name: 'agents.update', + url: `${BASE_URL}/agents/a1`, + }, + {call: () => admin.agents.delete('a1'), method: 'DELETE', name: 'agents.delete', url: `${BASE_URL}/agents/a1`}, + { + call: () => admin.agents.listGroups('a1'), + method: 'GET', + name: 'agents.listGroups', + url: `${BASE_URL}/agents/a1/groups`, + }, + { + call: () => admin.agents.listRoles('a1', {limit: 7}), + method: 'GET', + name: 'agents.listRoles', + url: `${BASE_URL}/agents/a1/roles?limit=7`, + }, + {call: () => admin.agentTypes.list(), method: 'GET', name: 'agentTypes.list', url: `${BASE_URL}/agent-types`}, + { + call: () => admin.flows.list({flowType: 'AUTHENTICATION'}), + method: 'GET', + name: 'flows.list', + url: `${BASE_URL}/flows?flowType=AUTHENTICATION`, + }, + ]; + + it.each(cases)('$name sends $method $url with the session bearer token', async ({body, call, method, url}) => { + await call(); + + const captured: Captured = lastCall(fetchMock); + + expect(getAccessToken).toHaveBeenCalledWith('session-1'); + expect(captured.url).toBe(url); + expect(captured.method).toBe(method); + expect(captured.headers['Authorization']).toBe(`Bearer ${TOKEN}`); + expect(captured.body).toEqual(body); + }); + + it('reads the access token on every call rather than once per management client', async () => { + await admin.users.list(); + getAccessToken.mockResolvedValue('rotated-token'); + await admin.users.list(); + + expect(lastCall(fetchMock).headers['Authorization']).toBe('Bearer rotated-token'); + }); + + it('returns the parsed server record', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse({id: 'g1', name: 'Admins'})); + + await expect(admin.groups.get('g1')).resolves.toEqual({id: 'g1', name: 'Admins'}); + }); + + it('resolves the collection URL from an explicit url, then endpoints, then baseUrl', async () => { + const overridden = await createClient({endpoints: {roles: 'https://rs.example.com/v2/roles'}}); + vi.spyOn(overridden, 'getAccessToken').mockResolvedValue(TOKEN); + const scoped: ThunderIDNodeManagementClient = overridden.management('session-1'); + + await scoped.roles.get('r1'); + expect(lastCall(fetchMock).url).toBe('https://rs.example.com/v2/roles/r1'); + + await scoped.roles.get('r1', {url: 'https://other.example.com/roles'}); + expect(lastCall(fetchMock).url).toBe('https://other.example.com/roles/r1'); + + await scoped.groups.get('g1'); + expect(lastCall(fetchMock).url).toBe(`${BASE_URL}/groups/g1?include=display`); + }); + + it('uses a per-call fetcher, and the fetcher cannot replace the bearer token', async () => { + const fetcher = vi.fn().mockImplementation(() => Promise.resolve(jsonResponse({}))); + + await admin.users.list({fetcher, headers: {Authorization: 'Bearer forged'}} as never); + + expect(fetchMock).not.toHaveBeenCalled(); + expect(lastCall(fetcher).headers['Authorization']).toBe(`Bearer ${TOKEN}`); + }); + + it('refuses a session without an access token before sending anything', async () => { + getAccessToken.mockResolvedValue(undefined as never); + + const error: unknown = await admin.users.list().catch((e: unknown) => e); + + expect(error).toBeInstanceOf(ThunderIDRuntimeError); + expect((error as ThunderIDRuntimeError).code).toBe('NODE-MANAGEMENT-RC-NF01'); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('refuses a missing resource identifier before reading the token or sending anything', async () => { + const error: unknown = await admin.users.delete(undefined as never).catch((e: unknown) => e); + + expect(error).toBeInstanceOf(ThunderIDAPIError); + expect((error as ThunderIDAPIError).code).toBe('deleteUser-ValidationError-002'); + expect(getAccessToken).not.toHaveBeenCalled(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it.each([ + [403, 'deleteRole-ForbiddenError-001'], + [404, 'deleteRole-NotFoundError-001'], + [500, 'deleteRole-ResponseError-001'], + ])( + 'maps HTTP %i to a ThunderIDAPIError with code %s and keeps the token out of the message', + async (status, code) => { + fetchMock.mockResolvedValueOnce(jsonResponse({message: 'nope'}, status)); + + const error: unknown = await admin.roles.delete('r1').catch((e: unknown) => e); + + expect(error).toBeInstanceOf(ThunderIDAPIError); + expect((error as ThunderIDAPIError).code).toBe(code); + expect((error as ThunderIDAPIError).statusCode).toBe(status); + expect(String((error as Error).message)).not.toContain(TOKEN); + }, + ); +}); diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index 07a59674..d7b7d372 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -24,6 +24,14 @@ export {default as CookieConfig} from './constants/CookieConfig'; // Models export type {ThunderIDNodeConfig, SessionCookieConfig} from './models/config'; export type {default as AuthURLCallback} from './models/AuthURLCallback'; +export type { + ManagementCallOptions, + ManagementGroupMemberInput, + ManagementListOptions, + ManagementRequestConfigResolver, + ManagementResourceKey, + ThunderIDNodeManagementClient, +} from './models/management'; // Stores export {default as MemoryCacheStore} from './stores/MemoryCacheStore'; @@ -37,6 +45,14 @@ export {default as LoggedOutSessions, InMemoryLogoutRecordStore} from './utils/L export type {LogoutRecordStore, PresentedSession} from './utils/LoggedOutSessions'; export type {IndexedSessions, SessionBinding} from './utils/SessionIndex'; export {default as generateSessionId} from './utils/generateSessionId'; +export {default as createManagementClient} from './utils/createManagementClient'; +export { + MANAGEMENT_COLLECTIONS, + MANAGEMENT_COLLECTION_KEYS, + getManagementCollectionUrls, + matchManagementUrl, + type ManagementCollectionKey, +} from './utils/managementCollections'; export {default as getSessionCookieOptions} from './utils/getSessionCookieOptions'; // Re-export everything from the JavaScript SDK diff --git a/packages/node/src/models/management.ts b/packages/node/src/models/management.ts new file mode 100644 index 00000000..aa047246 --- /dev/null +++ b/packages/node/src/models/management.ts @@ -0,0 +1,155 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import { + Agent, + AgentListResponse, + AgentRoleListResponse, + AgentTypeListResponse, + ApiFetcher, + ApiFilteringParams, + CreateAgentRequest, + CreateGroupRequest, + CreateManagedUserRequest, + CreateRoleRequest, + Group, + GroupListResponse, + GroupMember, + GroupMemberListResponse, + ManagedFlowListResponse, + ManagedUser, + ManagedUserListResponse, + ManagedUserTypeListResponse, + ManagementRequestConfig, + MembershipGroupListResponse, + ResourceEndpointKey, + Role, + RoleAssigneeType, + RoleAssignmentInput, + RoleAssignmentListResponse, + RoleListResponse, + UpdateAgentRequest, + UpdateGroupRequest, + UpdateManagedUserRequest, + UpdateRoleRequest, +} from '@thunderid/javascript'; + +/** + * The management resource collections the server-side management client addresses. + */ +export type ManagementResourceKey = Extract< + ResourceEndpointKey, + 'agents' | 'agentTypes' | 'flows' | 'groups' | 'roles' | 'users' | 'userTypes' +>; + +/** + * Per-call options accepted by every management operation. + */ +export interface ManagementCallOptions { + /** + * Transport for this call. Takes precedence over the SDK's default `fetch`. The SDK still sets the + * `Authorization` header; a fetcher cannot be used to drop or replace the session's bearer token. + */ + fetcher?: ApiFetcher; + /** + * Aborts the request. + */ + signal?: AbortSignal; + /** + * Explicit collection URL for this call. Takes precedence over a `config.endpoints` override, which + * takes precedence over `{baseUrl}/{collection}`. A single resource is addressed as this URL followed + * by its identifier. + */ + url?: string; +} + +/** + * Options for a paginated list. + */ +export type ManagementListOptions = ManagementCallOptions & Pick; + +/** + * Member reference accepted when adding or removing group members. + */ +export type ManagementGroupMemberInput = Pick; + +/** + * Resolves the URL inputs and bearer token for one management request. Implemented by + * `ThunderIDNodeClient`, which reads the access token of the bound session on every call. + * + * @param key - The resource collection the request targets. + * @param explicitUrl - A collection URL passed to the call, if any. + */ +export type ManagementRequestConfigResolver = ( + key: ManagementResourceKey, + explicitUrl?: string, +) => Promise & {headers: {Authorization: string}}>; + +/** + * Server-side management operations bound to one session. Every request is authorized with that + * session's access token, read at the time of the call, so the token is never copied, cached, or + * exposed to the caller. + * + * Resources returned here are server records (`ManagedUser`, `Group`, `Role`, `Agent`), never the + * signed-in user. Failures are raised as `ThunderIDAPIError`, with HTTP 403 and 404 carrying the + * `-ForbiddenError-001` and `-NotFoundError-001` codes. + */ +export interface ThunderIDNodeManagementClient { + agentTypes: { + list(options?: ManagementListOptions): Promise; + }; + agents: { + create(payload: CreateAgentRequest, options?: ManagementCallOptions): Promise; + delete(agentId: string, options?: ManagementCallOptions): Promise; + get(agentId: string, options?: ManagementCallOptions): Promise; + list(options?: ManagementCallOptions & ApiFilteringParams): Promise; + listGroups(agentId: string, options?: ManagementListOptions): Promise; + listRoles(agentId: string, options?: ManagementListOptions): Promise; + update(agentId: string, payload: UpdateAgentRequest, options?: ManagementCallOptions): Promise; + }; + flows: { + list(options?: ManagementListOptions & {flowType?: string}): Promise; + }; + groups: { + addMembers(groupId: string, members: ManagementGroupMemberInput[], options?: ManagementCallOptions): Promise; + create(payload: CreateGroupRequest, options?: ManagementCallOptions): Promise; + delete(groupId: string, options?: ManagementCallOptions): Promise; + get(groupId: string, options?: ManagementCallOptions): Promise; + list(options?: ManagementListOptions): Promise; + listMembers(groupId: string, options?: ManagementListOptions): Promise; + removeMembers( + groupId: string, + members: ManagementGroupMemberInput[], + options?: ManagementCallOptions, + ): Promise; + update(groupId: string, payload: UpdateGroupRequest, options?: ManagementCallOptions): Promise; + }; + roles: { + addAssignments(roleId: string, assignments: RoleAssignmentInput[], options?: ManagementCallOptions): Promise; + create(payload: CreateRoleRequest, options?: ManagementCallOptions): Promise; + delete(roleId: string, options?: ManagementCallOptions): Promise; + get(roleId: string, options?: ManagementCallOptions): Promise; + list(options?: ManagementListOptions): Promise; + listAssignments( + roleId: string, + options?: ManagementListOptions & {type?: RoleAssigneeType}, + ): Promise; + removeAssignments( + roleId: string, + assignments: RoleAssignmentInput[], + options?: ManagementCallOptions, + ): Promise; + update(roleId: string, payload: UpdateRoleRequest, options?: ManagementCallOptions): Promise; + }; + userTypes: { + list(options?: ManagementListOptions): Promise; + }; + users: { + create(payload: CreateManagedUserRequest, options?: ManagementCallOptions): Promise; + delete(userId: string, options?: ManagementCallOptions): Promise; + get(userId: string, options?: ManagementCallOptions): Promise; + list(options?: ManagementCallOptions & ApiFilteringParams): Promise; + listGroups(userId: string, options?: ManagementListOptions): Promise; + update(userId: string, payload: UpdateManagedUserRequest, options?: ManagementCallOptions): Promise; + }; +} diff --git a/packages/node/src/utils/__tests__/managementCollections.test.ts b/packages/node/src/utils/__tests__/managementCollections.test.ts new file mode 100644 index 00000000..81090a24 --- /dev/null +++ b/packages/node/src/utils/__tests__/managementCollections.test.ts @@ -0,0 +1,70 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {describe, expect, it} from 'vitest'; +import {getManagementCollectionUrls, matchManagementUrl} from '../managementCollections'; + +const baseConfig = {baseUrl: 'https://id.example.com'}; + +describe('getManagementCollectionUrls', () => { + it('derives every admin collection, and its organization-scoped form, from baseUrl', () => { + const urls: string[] = getManagementCollectionUrls(baseConfig).map((url: URL) => url.toString()); + + expect(urls).toEqual( + expect.arrayContaining([ + 'https://id.example.com/users', + 'https://id.example.com/o/users', + 'https://id.example.com/groups', + 'https://id.example.com/roles', + 'https://id.example.com/agents', + 'https://id.example.com/user-types', + 'https://id.example.com/agent-types', + ]), + ); + }); + + it('uses an endpoints override instead of baseUrl for that collection', () => { + const urls: string[] = getManagementCollectionUrls({ + ...baseConfig, + endpoints: {users: 'https://mgmt.example.com/api/users/'}, + }).map((url: URL) => url.toString()); + + expect(urls).toContain('https://mgmt.example.com/api/users'); + expect(urls).not.toContain('https://id.example.com/users'); + expect(urls).toContain('https://id.example.com/groups'); + }); + + it('returns nothing without a baseUrl or overrides', () => { + expect(getManagementCollectionUrls(undefined)).toEqual([]); + }); +}); + +describe('matchManagementUrl', () => { + const collections: URL[] = getManagementCollectionUrls(baseConfig); + + it.each([ + 'https://id.example.com/users', + 'https://id.example.com/users?limit=20&offset=0', + 'https://id.example.com/users/abc-123', + 'https://id.example.com/groups/g-1/members/add', + 'https://id.example.com/o/roles/r-1/assignments', + ])('allows %s', (target: string) => { + expect(matchManagementUrl(target, collections)).toBeInstanceOf(URL); + }); + + it.each([ + ['another host', 'https://attacker.example.net/users'], + ['a sibling host', 'https://id.example.com.attacker.net/users'], + ['another scheme', 'ftp://id.example.com/users'], + ['a path outside the collections', 'https://id.example.com/oauth2/token'], + ['a collection name prefix', 'https://id.example.com/usersx'], + ['a dot segment escaping the collection', 'https://id.example.com/users/../oauth2/token'], + ['an encoded slash', 'https://id.example.com/users/a%2F..%2Foauth2'], + ['an encoded dot', 'https://id.example.com/users/%2e%2e/oauth2'], + ['credentials in the URL', 'https://user:pass@id.example.com/users'], + ['a relative URL', '/users'], + ['a non-string', 42], + ])('refuses %s', (_label: string, target: unknown) => { + expect(matchManagementUrl(target, collections)).toBeUndefined(); + }); +}); diff --git a/packages/node/src/utils/createManagementClient.ts b/packages/node/src/utils/createManagementClient.ts new file mode 100644 index 00000000..34b72352 --- /dev/null +++ b/packages/node/src/utils/createManagementClient.ts @@ -0,0 +1,206 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import { + addGroupMembers, + addRoleAssignments, + createAgent, + createGroup, + createRole, + createUser, + deleteAgent, + deleteGroup, + deleteRole, + deleteUser, + getAgent, + getAgentGroups, + getAgentRoles, + getAgents, + getAgentTypes, + getFlows, + getGroup, + getGroupMembers, + getGroups, + getRole, + getRoleAssignments, + getRoles, + getUser, + getUserGroups, + getUsers, + getUserTypes, + ManagementRequestConfig, + removeGroupMembers, + removeRoleAssignments, + ThunderIDAPIError, + updateAgent, + updateGroup, + updateRole, + updateUser, +} from '@thunderid/javascript'; +import { + ManagementCallOptions, + ManagementRequestConfigResolver, + ManagementResourceKey, + ThunderIDNodeManagementClient, +} from '../models/management'; + +/** + * Rejects a missing or blank resource identifier before any request is made. The core API functions + * reject a blank string, but an `undefined` identifier would otherwise address the whole collection, + * which for a delete or update is never what the caller meant. Uses the same code the core raises. + */ +const requireId = (id: unknown, operation: string): string => { + if (typeof id !== 'string' || id.trim() === '') { + throw new ThunderIDAPIError( + 'A resource identifier is required.', + `${operation}-ValidationError-002`, + 'node', + 400, + 'The provided resource identifier is empty.', + ); + } + + return id; +}; + +/** + * Builds the server-side management client for one session. + * + * Each operation resolves its request config through `resolve` at call time, so the access token is + * read fresh for every request and never held by the returned object. + * + * @param resolve - Supplies the base URL, collection URL, and `Authorization` header for a request. + * @returns The session-bound management operations. + */ +const createManagementClient = (resolve: ManagementRequestConfigResolver): ThunderIDNodeManagementClient => { + // The resolved config is spread last, so a caller-supplied option can never replace the + // `Authorization` header or the resolved URL. + const config = async ( + key: ManagementResourceKey, + {fetcher, signal, url}: ManagementCallOptions = {}, + ): Promise => ({ + ...(fetcher ? {fetcher} : {}), + ...(signal ? {signal} : {}), + ...(await resolve(key, url)), + }); + + return { + agentTypes: { + list: async ({limit, offset, ...options} = {}) => + getAgentTypes({limit, offset, ...(await config('agentTypes', options))}), + }, + agents: { + create: async (payload, options) => createAgent({payload, ...(await config('agents', options))}), + delete: async (agentId, options) => + deleteAgent({agentId: requireId(agentId, 'deleteAgent'), ...(await config('agents', options))}), + get: async (agentId, options) => + getAgent({agentId: requireId(agentId, 'getAgent'), ...(await config('agents', options))}), + list: async ({filter, limit, offset, ...options} = {}) => + getAgents({filter, limit, offset, ...(await config('agents', options))}), + listGroups: async (agentId, {limit, offset, ...options} = {}) => + getAgentGroups({ + agentId: requireId(agentId, 'getAgentGroups'), + limit, + offset, + ...(await config('agents', options)), + }), + listRoles: async (agentId, {limit, offset, ...options} = {}) => + getAgentRoles({ + agentId: requireId(agentId, 'getAgentRoles'), + limit, + offset, + ...(await config('agents', options)), + }), + update: async (agentId, payload, options) => + updateAgent({agentId: requireId(agentId, 'updateAgent'), payload, ...(await config('agents', options))}), + }, + flows: { + list: async ({flowType, limit, offset, ...options} = {}) => + getFlows({flowType, limit, offset, ...(await config('flows', options))}), + }, + groups: { + addMembers: async (groupId, members, options) => + addGroupMembers({ + groupId: requireId(groupId, 'addGroupMembers'), + members, + ...(await config('groups', options)), + }), + create: async (payload, options) => createGroup({payload, ...(await config('groups', options))}), + delete: async (groupId, options) => + deleteGroup({groupId: requireId(groupId, 'deleteGroup'), ...(await config('groups', options))}), + get: async (groupId, options) => + getGroup({groupId: requireId(groupId, 'getGroup'), ...(await config('groups', options))}), + list: async ({limit, offset, ...options} = {}) => + getGroups({limit, offset, ...(await config('groups', options))}), + listMembers: async (groupId, {limit, offset, ...options} = {}) => + getGroupMembers({ + groupId: requireId(groupId, 'getGroupMembers'), + limit, + offset, + ...(await config('groups', options)), + }), + removeMembers: async (groupId, members, options) => + removeGroupMembers({ + groupId: requireId(groupId, 'removeGroupMembers'), + members, + ...(await config('groups', options)), + }), + update: async (groupId, payload, options) => + updateGroup({groupId: requireId(groupId, 'updateGroup'), payload, ...(await config('groups', options))}), + }, + roles: { + addAssignments: async (roleId, assignments, options) => + addRoleAssignments({ + assignments, + roleId: requireId(roleId, 'addRoleAssignments'), + ...(await config('roles', options)), + }), + create: async (payload, options) => createRole({payload, ...(await config('roles', options))}), + delete: async (roleId, options) => + deleteRole({roleId: requireId(roleId, 'deleteRole'), ...(await config('roles', options))}), + get: async (roleId, options) => + getRole({roleId: requireId(roleId, 'getRole'), ...(await config('roles', options))}), + list: async ({limit, offset, ...options} = {}) => getRoles({limit, offset, ...(await config('roles', options))}), + listAssignments: async (roleId, {limit, offset, type, ...options} = {}) => + getRoleAssignments({ + limit, + offset, + roleId: requireId(roleId, 'getRoleAssignments'), + type, + ...(await config('roles', options)), + }), + removeAssignments: async (roleId, assignments, options) => + removeRoleAssignments({ + assignments, + roleId: requireId(roleId, 'removeRoleAssignments'), + ...(await config('roles', options)), + }), + update: async (roleId, payload, options) => + updateRole({payload, roleId: requireId(roleId, 'updateRole'), ...(await config('roles', options))}), + }, + userTypes: { + list: async ({limit, offset, ...options} = {}) => + getUserTypes({limit, offset, ...(await config('userTypes', options))}), + }, + users: { + create: async (payload, options) => createUser({payload, ...(await config('users', options))}), + delete: async (userId, options) => + deleteUser({userId: requireId(userId, 'deleteUser'), ...(await config('users', options))}), + get: async (userId, options) => + getUser({userId: requireId(userId, 'getUser'), ...(await config('users', options))}), + list: async ({filter, limit, offset, ...options} = {}) => + getUsers({filter, limit, offset, ...(await config('users', options))}), + listGroups: async (userId, {limit, offset, ...options} = {}) => + getUserGroups({ + limit, + offset, + userId: requireId(userId, 'getUserGroups'), + ...(await config('users', options)), + }), + update: async (userId, payload, options) => + updateUser({payload, userId: requireId(userId, 'updateUser'), ...(await config('users', options))}), + }, + }; +}; + +export default createManagementClient; diff --git a/packages/node/src/utils/managementCollections.ts b/packages/node/src/utils/managementCollections.ts new file mode 100644 index 00000000..d1443a64 --- /dev/null +++ b/packages/node/src/utils/managementCollections.ts @@ -0,0 +1,102 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {resolveResourceEndpoint} from '@thunderid/javascript'; +import type {ResourceEndpointConfig, ResourceEndpointKey} from '@thunderid/javascript'; + +// The server side of a management relay: a framework SDK that keeps the access token on the server +// (Next.js, Nuxt) forwards the browser's management requests only to these collections. + +/** + * The management collections a browser may reach through a server-side management relay, keyed by + * their `endpoints` override key, with the path each one has under `baseUrl`. + */ +export const MANAGEMENT_COLLECTIONS: Readonly< + Record, string> +> = { + agentTypes: 'agent-types', + agents: 'agents', + groups: 'groups', + roles: 'roles', + userTypes: 'user-types', + users: 'users', +}; + +export type ManagementCollectionKey = keyof typeof MANAGEMENT_COLLECTIONS; + +export const MANAGEMENT_COLLECTION_KEYS: ManagementCollectionKey[] = Object.keys( + MANAGEMENT_COLLECTIONS, +) as ManagementCollectionKey[]; + +/** + * The collection URLs a management request may target for this configuration: each collection's + * `endpoints` override, or `{baseUrl}/{path}` and the organization-scoped `{baseUrl}/o/{path}`. + */ +export const getManagementCollectionUrls = ( + config: (ResourceEndpointConfig & {baseUrl?: string}) | undefined, +): URL[] => { + const urls: URL[] = []; + const baseUrl: string | undefined = config?.baseUrl?.replace(/\/+$/, ''); + + MANAGEMENT_COLLECTION_KEYS.forEach((key: ManagementCollectionKey) => { + const override: string | undefined = resolveResourceEndpoint(key, config); + const candidates: string[] = override + ? [override] + : baseUrl + ? [`${baseUrl}/${MANAGEMENT_COLLECTIONS[key]}`, `${baseUrl}/o/${MANAGEMENT_COLLECTIONS[key]}`] + : []; + + candidates.forEach((candidate: string) => { + try { + urls.push(new URL(candidate.replace(/\/+$/, ''))); + } catch { + // An unparsable override cannot match any request. + } + }); + }); + + return urls; +}; + +/** + * Whether `target` addresses one of the management collections, or a resource or sub-resource under + * one. The comparison runs on the parsed URL, so dot segments are resolved before it, and an + * encoded slash, backslash or dot, which an upstream could decode into a different path, is refused. + * + * @param target - The absolute URL the browser asked for. + * @param collections - The allowed collection URLs, from {@link getManagementCollectionUrls}. + * @returns The parsed URL when it is allowed, otherwise `undefined`. + */ +export const matchManagementUrl = (target: unknown, collections: URL[]): URL | undefined => { + if (typeof target !== 'string' || target.length > 4096) { + return undefined; + } + + let url: URL; + + try { + url = new URL(target); + } catch { + return undefined; + } + + if ((url.protocol !== 'https:' && url.protocol !== 'http:') || url.username || url.password || url.hash) { + return undefined; + } + + if (/%(2e|2f|5c)/i.test(url.pathname)) { + return undefined; + } + + const allowed: boolean = collections.some((collection: URL) => { + if (collection.origin !== url.origin) { + return false; + } + + const base: string = collection.pathname.replace(/\/+$/, ''); + + return url.pathname === base || url.pathname.startsWith(`${base}/`); + }); + + return allowed ? url : undefined; +}; diff --git a/packages/nuxt/src/module.ts b/packages/nuxt/src/module.ts index 79e4404c..f4c3b0fc 100644 --- a/packages/nuxt/src/module.ts +++ b/packages/nuxt/src/module.ts @@ -15,6 +15,7 @@ import { import type {Nuxt} from '@nuxt/schema'; import {VendorConstants} from '@thunderid/node'; import {createDefu} from 'defu'; +import {MANAGEMENT_COMPONENTS, MANAGEMENT_COMPOSABLES} from './runtime/constants/ManagementAutoImports'; import NuxtAPIRoutes from './runtime/constants/NuxtAPIRoutes'; import type {ThunderIDNuxtConfig, ThunderIDSessionPayload, ThunderIDSSRData} from './runtime/types'; import getEnabledResourcePickers from './runtime/utils/getEnabledResourcePickers'; @@ -113,6 +114,7 @@ export default defineNuxtModule({ baseUrl: publicConfig.baseUrl, clientId: publicConfig.clientId, endpoints: publicConfig.endpoints, + management: userOptions.management === true, platform: publicConfig.platform, preferences: publicConfig.preferences, scopes: publicConfig.scopes, @@ -128,6 +130,7 @@ export default defineNuxtModule({ baseUrl: string; clientId: string; endpoints?: ThunderIDNuxtConfig['endpoints']; + management: boolean; platform?: ThunderIDNuxtConfig['platform']; preferences: ThunderIDNuxtConfig['preferences']; scopes: string | string[]; @@ -268,24 +271,24 @@ export default defineNuxtModule({ // need Nuxt wrappers. // ── Control flow ──────────────────────────────────────────────────────── - addComponent({filePath: resolve('./runtime/components/control/SignedIn'), name: 'SignedIn'}); - addComponent({filePath: resolve('./runtime/components/control/SignedOut'), name: 'SignedOut'}); - addComponent({filePath: resolve('./runtime/components/control/Loading'), name: 'Loading'}); + addComponent({filePath: resolve('./runtime/components/auth/guards/SignedIn'), name: 'SignedIn'}); + addComponent({filePath: resolve('./runtime/components/auth/guards/SignedOut'), name: 'SignedOut'}); + addComponent({filePath: resolve('./runtime/components/auth/guards/Loading'), name: 'Loading'}); // ── Action buttons ─────────────────────────────────────────────────────── - addComponent({filePath: resolve('./runtime/components/actions/SignInButton'), name: 'SignInButton'}); - addComponent({filePath: resolve('./runtime/components/actions/SignOutButton'), name: 'SignOutButton'}); - addComponent({filePath: resolve('./runtime/components/actions/SignUpButton'), name: 'SignUpButton'}); + addComponent({filePath: resolve('./runtime/components/auth/buttons/SignInButton'), name: 'SignInButton'}); + addComponent({filePath: resolve('./runtime/components/auth/buttons/SignOutButton'), name: 'SignOutButton'}); + addComponent({filePath: resolve('./runtime/components/auth/buttons/SignUpButton'), name: 'SignUpButton'}); // ── Embedded auth flows ────────────────────────────────────────────────── addComponent({filePath: resolve('./runtime/components/auth/SignIn'), name: 'SignIn'}); addComponent({filePath: resolve('./runtime/components/auth/SignUp'), name: 'SignUp'}); // ── User ───────────────────────────────────────────────────────────────── - addComponent({filePath: resolve('./runtime/components/user/User'), name: 'User'}); - addComponent({filePath: resolve('./runtime/components/user/UserProfile'), name: 'UserProfile'}); - addComponent({filePath: resolve('./runtime/components/user/UserDropdown'), name: 'UserDropdown'}); - addComponent({filePath: resolve('./runtime/components/user/ChangeCredential'), name: 'ChangeCredential'}); + addComponent({filePath: resolve('./runtime/components/account/User'), name: 'User'}); + addComponent({filePath: resolve('./runtime/components/account/UserProfile'), name: 'UserProfile'}); + addComponent({filePath: resolve('./runtime/components/account/UserDropdown'), name: 'UserDropdown'}); + addComponent({filePath: resolve('./runtime/components/account/ChangeCredential'), name: 'ChangeCredential'}); // ── Management pickers ────────────────────────────────────────────────── // Opt-in: each picker's route lists a management resource with the signed-in user's session, so the route and its @@ -295,6 +298,22 @@ export default defineNuxtModule({ addComponent({filePath: resolve(componentPath), name: component}); }); + // ── Admin (management) ────────────────────────────────────────────────── + // Opt-in: the route performs management requests with the signed-in user's session. The admin + // components and composables come straight from @thunderid/vue — no Nuxt wrappers — since they + // only need the `http.fetcher` the client plugin installs when this is on. + if (userOptions.management === true) { + addServerHandler({ + handler: resolve('./runtime/server/routes/auth/management.post'), + method: 'post', + route: NuxtAPIRoutes.MANAGEMENT, + }); + addImports(MANAGEMENT_COMPOSABLES.map((name: string) => ({from: '@thunderid/vue', name}))); + MANAGEMENT_COMPONENTS.forEach((name: string) => { + addComponent({export: name, filePath: '@thunderid/vue', name}); + }); + } + // ── Auth callback ──────────────────────────────────────────────────────── addComponent({filePath: resolve('./runtime/components/auth/Callback'), name: 'Callback'}); @@ -345,6 +364,7 @@ declare module '@nuxt/schema' { baseUrl: string; clientId: string; endpoints?: ThunderIDNuxtConfig['endpoints']; + management?: boolean; platform?: ThunderIDNuxtConfig['platform']; preferences?: ThunderIDNuxtConfig['preferences']; scopes: string | string[]; diff --git a/packages/nuxt/src/runtime/components/user/ChangeCredential.ts b/packages/nuxt/src/runtime/components/account/ChangeCredential.ts similarity index 100% rename from packages/nuxt/src/runtime/components/user/ChangeCredential.ts rename to packages/nuxt/src/runtime/components/account/ChangeCredential.ts diff --git a/packages/nuxt/src/runtime/components/user/User.ts b/packages/nuxt/src/runtime/components/account/User.ts similarity index 100% rename from packages/nuxt/src/runtime/components/user/User.ts rename to packages/nuxt/src/runtime/components/account/User.ts diff --git a/packages/nuxt/src/runtime/components/user/UserDropdown.ts b/packages/nuxt/src/runtime/components/account/UserDropdown.ts similarity index 100% rename from packages/nuxt/src/runtime/components/user/UserDropdown.ts rename to packages/nuxt/src/runtime/components/account/UserDropdown.ts diff --git a/packages/nuxt/src/runtime/components/user/UserProfile.ts b/packages/nuxt/src/runtime/components/account/UserProfile.ts similarity index 100% rename from packages/nuxt/src/runtime/components/user/UserProfile.ts rename to packages/nuxt/src/runtime/components/account/UserProfile.ts diff --git a/packages/nuxt/src/runtime/components/actions/SignInButton.ts b/packages/nuxt/src/runtime/components/auth/buttons/SignInButton.ts similarity index 100% rename from packages/nuxt/src/runtime/components/actions/SignInButton.ts rename to packages/nuxt/src/runtime/components/auth/buttons/SignInButton.ts diff --git a/packages/nuxt/src/runtime/components/actions/SignOutButton.ts b/packages/nuxt/src/runtime/components/auth/buttons/SignOutButton.ts similarity index 100% rename from packages/nuxt/src/runtime/components/actions/SignOutButton.ts rename to packages/nuxt/src/runtime/components/auth/buttons/SignOutButton.ts diff --git a/packages/nuxt/src/runtime/components/actions/SignUpButton.ts b/packages/nuxt/src/runtime/components/auth/buttons/SignUpButton.ts similarity index 100% rename from packages/nuxt/src/runtime/components/actions/SignUpButton.ts rename to packages/nuxt/src/runtime/components/auth/buttons/SignUpButton.ts diff --git a/packages/nuxt/src/runtime/components/control/Loading.ts b/packages/nuxt/src/runtime/components/auth/guards/Loading.ts similarity index 100% rename from packages/nuxt/src/runtime/components/control/Loading.ts rename to packages/nuxt/src/runtime/components/auth/guards/Loading.ts diff --git a/packages/nuxt/src/runtime/components/control/SignedIn.ts b/packages/nuxt/src/runtime/components/auth/guards/SignedIn.ts similarity index 100% rename from packages/nuxt/src/runtime/components/control/SignedIn.ts rename to packages/nuxt/src/runtime/components/auth/guards/SignedIn.ts diff --git a/packages/nuxt/src/runtime/components/control/SignedOut.ts b/packages/nuxt/src/runtime/components/auth/guards/SignedOut.ts similarity index 100% rename from packages/nuxt/src/runtime/components/control/SignedOut.ts rename to packages/nuxt/src/runtime/components/auth/guards/SignedOut.ts diff --git a/packages/nuxt/src/runtime/components/user/AuthFlowSelect.ts b/packages/nuxt/src/runtime/components/primitives/AuthFlowSelect.ts similarity index 100% rename from packages/nuxt/src/runtime/components/user/AuthFlowSelect.ts rename to packages/nuxt/src/runtime/components/primitives/AuthFlowSelect.ts diff --git a/packages/nuxt/src/runtime/components/user/UserSelect.ts b/packages/nuxt/src/runtime/components/primitives/UserSelect.ts similarity index 100% rename from packages/nuxt/src/runtime/components/user/UserSelect.ts rename to packages/nuxt/src/runtime/components/primitives/UserSelect.ts diff --git a/packages/nuxt/src/runtime/components/user/UserTypeSelect.ts b/packages/nuxt/src/runtime/components/primitives/UserTypeSelect.ts similarity index 100% rename from packages/nuxt/src/runtime/components/user/UserTypeSelect.ts rename to packages/nuxt/src/runtime/components/primitives/UserTypeSelect.ts diff --git a/packages/nuxt/src/runtime/constants/ManagementAutoImports.ts b/packages/nuxt/src/runtime/constants/ManagementAutoImports.ts new file mode 100644 index 00000000..f742b23a --- /dev/null +++ b/packages/nuxt/src/runtime/constants/ManagementAutoImports.ts @@ -0,0 +1,76 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +/** + * The admin (management) components of `@thunderid/vue` that the module auto-imports when + * `management` is on. The containers fetch through the management composables, so they need no Nuxt + * wrapper: the module installs the `http.fetcher` they use. The `Base*` variants take their data as + * props. + */ +export const MANAGEMENT_COMPONENTS: readonly string[] = [ + 'UserList', + 'UserCreate', + 'UserDetails', + 'GroupList', + 'GroupCreate', + 'GroupMembers', + 'RoleList', + 'RoleCreate', + 'RoleAssignments', + 'RolePermissions', + 'AgentList', + 'AgentCreate', + 'AgentDetails', + 'BaseUserList', + 'BaseUserCreate', + 'BaseUserDetails', + 'BaseGroupList', + 'BaseGroupCreate', + 'BaseGroupMembers', + 'BaseRoleList', + 'BaseRoleCreate', + 'BaseRoleAssignments', + 'BaseRolePermissions', + 'BaseAgentList', + 'BaseAgentCreate', + 'BaseAgentDetails', +]; + +/** + * The management composables of `@thunderid/vue` that the module auto-imports when `management` is + * on. Same names as the `@thunderid/react` hooks. + */ +export const MANAGEMENT_COMPOSABLES: readonly string[] = [ + 'useGetUsers', + 'useGetUser', + 'useCreateUser', + 'useUpdateUser', + 'useDeleteUser', + 'useGetUserGroups', + 'useGetUserTypes', + 'useGetGroups', + 'useGetGroup', + 'useCreateGroup', + 'useUpdateGroup', + 'useDeleteGroup', + 'useGetGroupMembers', + 'useAddGroupMembers', + 'useRemoveGroupMembers', + 'useGetRoles', + 'useGetRole', + 'useCreateRole', + 'useUpdateRole', + 'useDeleteRole', + 'useGetRoleAssignments', + 'useAddRoleAssignments', + 'useRemoveRoleAssignments', + 'useGetAgents', + 'useGetAgent', + 'useCreateAgent', + 'useUpdateAgent', + 'useDeleteAgent', + 'useGetAgentGroups', + 'useGetAgentRoles', + 'useGetAgentTypes', + 'useGrantedPermissions', +]; diff --git a/packages/nuxt/src/runtime/constants/NuxtAPIRoutes.ts b/packages/nuxt/src/runtime/constants/NuxtAPIRoutes.ts index 65054163..0561c963 100644 --- a/packages/nuxt/src/runtime/constants/NuxtAPIRoutes.ts +++ b/packages/nuxt/src/runtime/constants/NuxtAPIRoutes.ts @@ -20,6 +20,7 @@ const NuxtAPIRoutes: { AUTH_FLOWS: string; BACKCHANNEL_LOGOUT: string; CALLBACK: string; + MANAGEMENT: string; META: string; SESSION: string; SIGN_IN: string; @@ -38,6 +39,8 @@ const NuxtAPIRoutes: { BACKCHANNEL_LOGOUT: '/api/auth/backchannel-logout', /** Resolves the OAuth callback and completes sign-in. */ CALLBACK: '/api/auth/callback', + /** Performs (POST) one management API request with the signed-in session. Served only when `management` is on. */ + MANAGEMENT: '/api/auth/management', /** Serves flow metadata (design config + i18n bundle). */ META: '/api/auth/meta', /** Returns the current server-verified session. */ diff --git a/packages/nuxt/src/runtime/constants/ResourcePickers.ts b/packages/nuxt/src/runtime/constants/ResourcePickers.ts index 467d8665..365f66c5 100644 --- a/packages/nuxt/src/runtime/constants/ResourcePickers.ts +++ b/packages/nuxt/src/runtime/constants/ResourcePickers.ts @@ -20,19 +20,19 @@ export interface ResourcePicker { const ResourcePickers: Record<'authFlows' | 'userTypes' | 'users', ResourcePicker> = { authFlows: { component: 'AuthFlowSelect', - componentPath: './runtime/components/user/AuthFlowSelect', + componentPath: './runtime/components/primitives/AuthFlowSelect', handlerPath: './runtime/server/routes/auth/select/auth-flows.get', route: NuxtAPIRoutes.AUTH_FLOWS, }, userTypes: { component: 'UserTypeSelect', - componentPath: './runtime/components/user/UserTypeSelect', + componentPath: './runtime/components/primitives/UserTypeSelect', handlerPath: './runtime/server/routes/auth/select/user-types.get', route: NuxtAPIRoutes.USER_TYPES, }, users: { component: 'UserSelect', - componentPath: './runtime/components/user/UserSelect', + componentPath: './runtime/components/primitives/UserSelect', handlerPath: './runtime/server/routes/auth/user/users.get', route: NuxtAPIRoutes.USERS, }, diff --git a/packages/nuxt/src/runtime/plugins/thunderid.ts b/packages/nuxt/src/runtime/plugins/thunderid.ts index 9b3333c9..b6298955 100644 --- a/packages/nuxt/src/runtime/plugins/thunderid.ts +++ b/packages/nuxt/src/runtime/plugins/thunderid.ts @@ -10,7 +10,8 @@ import {computed} from 'vue'; import type {ComputedRef, Ref} from 'vue'; import ThunderIDRoot from '../components/ThunderIDRoot'; import NuxtAPIRoutes from '../constants/NuxtAPIRoutes'; -import type {ThunderIDAuthState, ThunderIDSSRData} from '../types'; +import type {ThunderIDAuthState, ThunderIDNuxtConfig, ThunderIDSSRData} from '../types'; +import fetchManagementFromServer from '../utils/fetchManagementFromServer'; import {getAuthStateKey, getFlowMetaStateKey, getUserProfileStateKey, getUserSchemaStateKey} from '../utils/stateKeys'; import type {NuxtApp} from '#app'; import {defineNuxtPlugin, useState, useRequestEvent, useRuntimeConfig, navigateTo} from '#app'; @@ -41,6 +42,8 @@ export default defineNuxtPlugin((nuxtApp: NuxtApp) => { applicationId?: string; baseUrl: string; clientId: string; + endpoints?: ThunderIDNuxtConfig['endpoints']; + management?: boolean; organizationHandle?: string; scopes: string | string[]; signInUrl?: string; @@ -52,6 +55,8 @@ export default defineNuxtPlugin((nuxtApp: NuxtApp) => { applicationId?: string; baseUrl: string; clientId: string; + endpoints?: ThunderIDNuxtConfig['endpoints']; + management?: boolean; organizationHandle?: string; scopes: string | string[]; signInUrl?: string; @@ -213,11 +218,19 @@ export default defineNuxtPlugin((nuxtApp: NuxtApp) => { baseUrl: publicConfig.baseUrl, clearSession: noop, clientId: publicConfig.clientId, + // Lets the management composables build the URLs the management route accepts. + endpoints: publicConfig.endpoints, exchangeToken: noop, getAccessToken, getDecodedIdToken: noop, getIdToken: noop, - http: {request: noop, requestAll: noop}, + http: { + // With `management` on, the management composables reach the management API through a server + // route that adds the server-held access token. + ...(publicConfig.management ? {fetcher: fetchManagementFromServer} : {}), + request: noop, + requestAll: noop, + }, instanceId: 0, isInitialized, isLoading, diff --git a/packages/nuxt/src/runtime/server/ThunderIDNuxtClient.ts b/packages/nuxt/src/runtime/server/ThunderIDNuxtClient.ts index 0ea55ebf..5f9644eb 100644 --- a/packages/nuxt/src/runtime/server/ThunderIDNuxtClient.ts +++ b/packages/nuxt/src/runtime/server/ThunderIDNuxtClient.ts @@ -248,7 +248,7 @@ class ThunderIDNuxtClient extends ThunderIDNodeClient * (the `PATCH /api/auth/user/credentials` Nitro route) needs the real `ThunderIDAPIError` * instance, status code included, to map a failure onto the right form field. */ - async updateUserCredentials(payload: Record, sessionId?: string): Promise { + override async updateUserCredentials(payload: Record, sessionId?: string): Promise { const configData: AuthClientConfig = await this.getStorageManager().getConfigData(); const baseUrl: string | undefined = configData?.baseUrl; diff --git a/packages/nuxt/src/runtime/server/routes/auth/management.post.ts b/packages/nuxt/src/runtime/server/routes/auth/management.post.ts new file mode 100644 index 00000000..28b97239 --- /dev/null +++ b/packages/nuxt/src/runtime/server/routes/auth/management.post.ts @@ -0,0 +1,88 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {getManagementCollectionUrls, matchManagementUrl} from '@thunderid/node'; +import {defineEventHandler, readBody} from 'h3'; +import type {H3Event} from 'h3'; +import type {ThunderIDManagementRequest, ThunderIDManagementResult} from '../../../types'; +import {getValidAccessToken} from '../../utils/token-refresh'; +import {useRuntimeConfig} from '#imports'; + +const ALLOWED_METHODS: readonly string[] = ['DELETE', 'GET', 'POST', 'PUT']; + +/** Large enough for any management payload, small enough that the route cannot be used to stream data. */ +const MAX_BODY_LENGTH: number = 1024 * 1024; + +const failure = (status: number): ThunderIDManagementResult => ({ + body: `Management request failed (HTTP ${status}).`, + status, + statusText: '', +}); + +/** + * POST /api/auth/management + * + * Performs one management API request with the signed-in user's server-held session. It is the + * `http.fetcher` of the management composables of `@thunderid/vue` when `management` is on. + * + * Body: `{url, method?, body?}`, as the core API function built the request. No session ID or token + * is accepted from the browser. The method must be one the management API uses, the body a bounded + * string, and the URL must address a configured management collection on the configured host, so the + * bearer token is only ever sent where the SDK would have sent it. + * + * Response: always `200` with `{body, status, statusText}`, carrying the upstream status. A failure + * carries a message built on the server, never the text of the upstream reply. + */ +export default defineEventHandler(async (event: H3Event): Promise => { + const request: Partial> = + ((await readBody(event).catch((): undefined => undefined)) as + | Partial> + | undefined) ?? {}; + const {body, method: rawMethod, url: rawUrl} = request; + const method: string = typeof rawMethod === 'string' ? rawMethod.toUpperCase() : 'GET'; + + if ( + !ALLOWED_METHODS.includes(method) || + (body !== undefined && (typeof body !== 'string' || body.length > MAX_BODY_LENGTH)) + ) { + return failure(400); + } + + const {baseUrl, endpoints} = useRuntimeConfig(event).public.thunderid; + const url: URL | undefined = matchManagementUrl(rawUrl, getManagementCollectionUrls({baseUrl, endpoints})); + + if (!url) { + return failure(400); + } + + let accessToken: string; + + try { + accessToken = await getValidAccessToken(event); + } catch { + return failure(401); + } + + try { + const response: Response = await fetch(url.toString(), { + headers: { + Accept: 'application/json', + Authorization: `Bearer ${accessToken}`, + ...(body !== undefined ? {'Content-Type': 'application/json'} : {}), + }, + method, + // A redirect could carry the request to a host the URL check never saw. + redirect: 'error', + ...(typeof body === 'string' ? {body} : {}), + }); + + if (!response.ok) { + return {...failure(response.status), statusText: response.statusText ?? ''}; + } + + return {body: await response.text(), status: response.status, statusText: response.statusText ?? ''}; + } catch { + // Not logged: a management operation reports failure to its caller and leaves logging to the application. + return failure(502); + } +}); diff --git a/packages/nuxt/src/runtime/types.ts b/packages/nuxt/src/runtime/types.ts index 2152944b..1512c2b5 100644 --- a/packages/nuxt/src/runtime/types.ts +++ b/packages/nuxt/src/runtime/types.ts @@ -39,6 +39,17 @@ export interface ThunderIDNuxtConfig extends Omit { */ store?: string; }; + /** + * Opt-in admin (management) surface. When `true`, the module serves `POST /api/auth/management`, which + * performs a management API request with the signed-in user's server-held session, installs it as the + * `http.fetcher` of the management composables, and auto-imports the admin components (`UserList`, + * `GroupMembers`, `RoleAssignments`, ... and their `Base*` variants) and composables (`useGetUsers`, + * `useCreateGroup`, `useGrantedPermissions`, ...) from `@thunderid/vue`. Nothing is served by default. + * + * The route only reaches the configured management collections (users, groups, roles, agents, user + * types, agent types), and the management API still decides what the signed-in user may do. + */ + management?: boolean; /** * Opt-in management pickers. `UserSelect`, `AuthFlowSelect` and `UserTypeSelect` load their options through a server * route that lists the users, authentication flows or user types with the signed-in user's own session, so each @@ -151,6 +162,26 @@ export interface ThunderIDAuthState { * one normalized page, or why it could not be loaded. Always carried in the body of a `200` response, since * a `401` means the caller's own session is invalid, not that the directory call failed. */ +/** + * Body of `POST /api/auth/management`: one management API request as the core API function built it. + */ +export interface ThunderIDManagementRequest { + /** JSON request body, already serialized. */ + body?: string; + method?: string; + url: string; +} + +/** + * Response of `POST /api/auth/management`: the upstream status, and its reply on success. A failure + * carries a message built on the server, never the text of the upstream reply. + */ +export interface ThunderIDManagementResult { + body: string; + status: number; + statusText: string; +} + export type ThunderIDSelectPageResult = {page: SelectPage; success: true} | ({success: false} & SelectPageErrorResult); /** Result of `GET /api/auth/users`. */ diff --git a/packages/nuxt/src/runtime/utils/fetchManagementFromServer.ts b/packages/nuxt/src/runtime/utils/fetchManagementFromServer.ts new file mode 100644 index 00000000..2f55e060 --- /dev/null +++ b/packages/nuxt/src/runtime/utils/fetchManagementFromServer.ts @@ -0,0 +1,40 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import type {ApiFetcher} from '@thunderid/browser'; +import NuxtAPIRoutes from '../constants/NuxtAPIRoutes'; +import type {ThunderIDManagementResult} from '../types'; + +/** + * The `http.fetcher` the module installs for the management composables of `@thunderid/vue` when + * `management` is on: it sends each management request through `POST /api/auth/management`, which + * adds the signed-in user's server-held access token, and turns the plain result back into a + * `Response` so the core API functions can read the status (a 403 stays distinct from a 404). + * + * Only the URL, method and body are sent. Headers are not forwarded, since the server sets the ones + * the management API needs. + */ +const fetchManagementFromServer: ApiFetcher = async (url: string, config: RequestInit): Promise => { + const {body, status, statusText}: ThunderIDManagementResult = await $fetch( + NuxtAPIRoutes.MANAGEMENT, + { + body: { + ...(typeof config?.body === 'string' ? {body: config.body} : {}), + method: config?.method ?? 'GET', + url, + }, + method: 'POST', + signal: config?.signal ?? undefined, + }, + ); + + return { + json: () => Promise.resolve(body ? (JSON.parse(body) as unknown) : undefined), + ok: status >= 200 && status < 300, + status, + statusText, + text: () => Promise.resolve(body), + } as Response; +}; + +export default fetchManagementFromServer; diff --git a/packages/nuxt/tests/unit/fetch-management-from-server.test.ts b/packages/nuxt/tests/unit/fetch-management-from-server.test.ts new file mode 100644 index 00000000..0b078783 --- /dev/null +++ b/packages/nuxt/tests/unit/fetch-management-from-server.test.ts @@ -0,0 +1,69 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +/* eslint-disable @typescript-eslint/typedef, sort-keys, @typescript-eslint/explicit-function-return-type */ + +import {ThunderIDAPIError, getRoles} from '@thunderid/browser'; +import {describe, it, expect, vi, beforeEach, afterEach} from 'vitest'; + +import NuxtAPIRoutes from '../../src/runtime/constants/NuxtAPIRoutes'; +import fetchManagementFromServer from '../../src/runtime/utils/fetchManagementFromServer'; + +const $fetch = vi.fn(); + +describe('fetchManagementFromServer', () => { + beforeEach(() => { + vi.stubGlobal('$fetch', $fetch); + $fetch.mockReset(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('posts only the URL, method and body to the management route: no headers cross', async () => { + $fetch.mockResolvedValue({body: '{}', status: 200, statusText: 'OK'}); + const controller = new AbortController(); + + await fetchManagementFromServer('https://id.example.com/groups', { + body: '{"name":"Admins"}', + headers: {Authorization: 'Bearer from-the-browser'}, + method: 'POST', + signal: controller.signal, + }); + + expect($fetch).toHaveBeenCalledWith(NuxtAPIRoutes.MANAGEMENT, { + body: {body: '{"name":"Admins"}', method: 'POST', url: 'https://id.example.com/groups'}, + method: 'POST', + signal: controller.signal, + }); + }); + + it('serves a core API function end to end', async () => { + $fetch.mockResolvedValue({ + body: JSON.stringify({count: 1, roles: [{id: 'r-1', name: 'Auditor'}], startIndex: 0, totalResults: 1}), + status: 200, + statusText: 'OK', + }); + + const page = await getRoles({baseUrl: 'https://id.example.com', fetcher: fetchManagementFromServer}); + + expect(page.roles).toEqual([{id: 'r-1', name: 'Auditor'}]); + }); + + it('keeps a 403 distinct from a 404 for the core API function', async () => { + $fetch.mockResolvedValueOnce({body: 'Management request failed (HTTP 403).', status: 403, statusText: 'Forbidden'}); + $fetch.mockResolvedValueOnce({body: 'Management request failed (HTTP 404).', status: 404, statusText: 'Not Found'}); + + const forbidden = await getRoles({baseUrl: 'https://id.example.com', fetcher: fetchManagementFromServer}).catch( + (error: unknown) => error, + ); + const missing = await getRoles({baseUrl: 'https://id.example.com', fetcher: fetchManagementFromServer}).catch( + (error: unknown) => error, + ); + + expect(forbidden).toBeInstanceOf(ThunderIDAPIError); + expect((forbidden as ThunderIDAPIError).code).toContain('ForbiddenError'); + expect((missing as ThunderIDAPIError).code).toContain('NotFoundError'); + }); +}); diff --git a/packages/nuxt/tests/unit/get-enabled-resource-pickers.test.ts b/packages/nuxt/tests/unit/get-enabled-resource-pickers.test.ts index 80dd0f53..b599b041 100644 --- a/packages/nuxt/tests/unit/get-enabled-resource-pickers.test.ts +++ b/packages/nuxt/tests/unit/get-enabled-resource-pickers.test.ts @@ -33,19 +33,19 @@ describe('getEnabledResourcePickers', () => { expect(getEnabledResourcePickers({authFlows: true, userTypes: true, users: true})).toEqual([ { component: 'AuthFlowSelect', - componentPath: './runtime/components/user/AuthFlowSelect', + componentPath: './runtime/components/primitives/AuthFlowSelect', handlerPath: './runtime/server/routes/auth/select/auth-flows.get', route: NuxtAPIRoutes.AUTH_FLOWS, }, { component: 'UserTypeSelect', - componentPath: './runtime/components/user/UserTypeSelect', + componentPath: './runtime/components/primitives/UserTypeSelect', handlerPath: './runtime/server/routes/auth/select/user-types.get', route: NuxtAPIRoutes.USER_TYPES, }, { component: 'UserSelect', - componentPath: './runtime/components/user/UserSelect', + componentPath: './runtime/components/primitives/UserSelect', handlerPath: './runtime/server/routes/auth/user/users.get', route: NuxtAPIRoutes.USERS, }, diff --git a/packages/nuxt/tests/unit/management-collections.test.ts b/packages/nuxt/tests/unit/management-collections.test.ts new file mode 100644 index 00000000..c7eb7951 --- /dev/null +++ b/packages/nuxt/tests/unit/management-collections.test.ts @@ -0,0 +1,56 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +/* eslint-disable @typescript-eslint/typedef */ + +import {describe, it, expect} from 'vitest'; + +import {MANAGEMENT_COMPONENTS, MANAGEMENT_COMPOSABLES} from '../../src/runtime/constants/ManagementAutoImports'; +import {getManagementCollectionUrls, matchManagementUrl} from '@thunderid/node'; + +describe('management collections', () => { + const collections = getManagementCollectionUrls({baseUrl: 'https://id.example.com/'}); + + it.each([ + 'https://id.example.com/users?limit=20', + 'https://id.example.com/groups/g-1/members', + 'https://id.example.com/o/roles/r-1/assignments/add', + 'https://id.example.com/agent-types', + ])('allows %s', (target) => { + expect(matchManagementUrl(target, collections)).toBeInstanceOf(URL); + }); + + it.each([ + 'https://attacker.example.net/users', + 'https://id.example.com/oauth2/token', + 'https://id.example.com/usersx', + 'https://id.example.com/users/%2e%2e/oauth2', + 'https://id.example.com/users/a%2F..%2Foauth2', + ])('refuses %s', (target) => { + expect(matchManagementUrl(target, collections)).toBeUndefined(); + }); + + it('honors an endpoints override for a collection', () => { + const overridden = getManagementCollectionUrls({ + baseUrl: 'https://id.example.com', + endpoints: {users: 'https://mgmt.example.com/api/users'}, + }); + + expect(matchManagementUrl('https://mgmt.example.com/api/users/u-1', overridden)).toBeInstanceOf(URL); + expect(matchManagementUrl('https://id.example.com/users/u-1', overridden)).toBeUndefined(); + }); +}); + +describe('management auto-imports', () => { + it('registers every admin component with its unstyled variant', () => { + const styled = MANAGEMENT_COMPONENTS.filter((name) => !name.startsWith('Base')); + + expect(styled).toHaveLength(13); + styled.forEach((name) => expect(MANAGEMENT_COMPONENTS).toContain(`Base${name}`)); + }); + + it('registers each composable once', () => { + expect(new Set(MANAGEMENT_COMPOSABLES).size).toBe(MANAGEMENT_COMPOSABLES.length); + expect(MANAGEMENT_COMPOSABLES).toContain('useGrantedPermissions'); + }); +}); diff --git a/packages/nuxt/tests/unit/management-post.test.ts b/packages/nuxt/tests/unit/management-post.test.ts new file mode 100644 index 00000000..b5a9e27b --- /dev/null +++ b/packages/nuxt/tests/unit/management-post.test.ts @@ -0,0 +1,129 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +/* eslint-disable @typescript-eslint/typedef, sort-keys, @typescript-eslint/explicit-function-return-type */ + +import {readBody} from 'h3'; +import {describe, it, expect, vi, beforeEach, afterEach} from 'vitest'; + +import managementHandler from '../../src/runtime/server/routes/auth/management.post'; +import {getValidAccessToken} from '../../src/runtime/server/utils/token-refresh'; + +// ── Module mocks ────────────────────────────────────────────────────────────── + +vi.mock('h3', () => ({ + defineEventHandler: (fn: Function) => fn, + readBody: vi.fn(), +})); + +vi.mock('#imports', () => ({ + useRuntimeConfig: vi.fn(() => ({public: {thunderid: {baseUrl: 'https://id.example.com'}}})), +})); + +vi.mock('../../src/runtime/server/utils/token-refresh', () => ({ + getValidAccessToken: vi.fn(), +})); + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +const mockEvent = {}; +const mockFetch = vi.fn(); + +const replyWith = (status: number, body: string, statusText = ''): Response => + ({ok: status >= 200 && status < 300, status, statusText, text: () => Promise.resolve(body)}) as Response; + +const call = async (body: unknown) => { + vi.mocked(readBody).mockResolvedValue(body); + + return (managementHandler as any)(mockEvent); +}; + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe('POST /api/auth/management', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.stubGlobal('fetch', mockFetch); + vi.mocked(getValidAccessToken).mockResolvedValue('server-held-token'); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('forwards the request with the server-held token and returns the reply', async () => { + mockFetch.mockResolvedValue(replyWith(200, '{"groups":[]}', 'OK')); + + const result = await call({method: 'GET', url: 'https://id.example.com/groups?limit=10'}); + + expect(mockFetch).toHaveBeenCalledWith('https://id.example.com/groups?limit=10', { + headers: {Accept: 'application/json', Authorization: 'Bearer server-held-token'}, + method: 'GET', + redirect: 'error', + }); + expect(result).toEqual({body: '{"groups":[]}', status: 200, statusText: 'OK'}); + }); + + it('sends a JSON body for a write', async () => { + mockFetch.mockResolvedValue(replyWith(201, '{"id":"r-1"}')); + + await call({body: '{"name":"Auditor"}', method: 'post', url: 'https://id.example.com/roles'}); + + expect(mockFetch).toHaveBeenCalledWith('https://id.example.com/roles', { + body: '{"name":"Auditor"}', + headers: { + Accept: 'application/json', + Authorization: 'Bearer server-held-token', + 'Content-Type': 'application/json', + }, + method: 'POST', + redirect: 'error', + }); + }); + + it('returns 401 without a usable session and never reaches the management API', async () => { + vi.mocked(getValidAccessToken).mockRejectedValue(Object.assign(new Error('Unauthorized'), {statusCode: 401})); + + const result = await call({url: 'https://id.example.com/users'}); + + expect(result.status).toBe(401); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it.each([ + ['another host', {url: 'https://attacker.example.net/users'}], + ['a path outside the collections', {url: 'https://id.example.com/oauth2/token'}], + ['a dot segment escaping the collection', {url: 'https://id.example.com/users/../oauth2/token'}], + ['a method the management API does not use', {method: 'PATCH', url: 'https://id.example.com/users'}], + ['a body that is not a string', {body: {name: 'x'}, method: 'POST', url: 'https://id.example.com/groups'}], + ['no URL', {}], + ['no body at all', undefined], + ])('refuses %s without sending the token anywhere', async (_label, body) => { + const result = await call(body); + + expect(result.status).toBe(400); + expect(getValidAccessToken).not.toHaveBeenCalled(); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('keeps 403 and 404 distinct but does not repeat the reply of the upstream', async () => { + mockFetch.mockResolvedValueOnce(replyWith(403, '403 from internal-proxy-7.corp.local', 'Forbidden')); + mockFetch.mockResolvedValueOnce(replyWith(404, 'not here')); + + const forbidden = await call({url: 'https://id.example.com/users/u-1'}); + const missing = await call({url: 'https://id.example.com/users/u-2'}); + + expect(forbidden).toMatchObject({status: 403, statusText: 'Forbidden'}); + expect(JSON.stringify(forbidden)).not.toContain('internal-proxy-7'); + expect(missing.status).toBe(404); + }); + + it('maps a network failure to a plain result without its text', async () => { + mockFetch.mockRejectedValue(new Error('connect ECONNREFUSED 10.0.0.5:8090')); + + const result = await call({url: 'https://id.example.com/users'}); + + expect(result.status).toBe(502); + expect(JSON.stringify(result)).not.toContain('10.0.0.5'); + }); +}); diff --git a/packages/react-router/src/index.ts b/packages/react-router/src/index.ts index 9a9805cb..83ce4b25 100644 --- a/packages/react-router/src/index.ts +++ b/packages/react-router/src/index.ts @@ -6,3 +6,6 @@ export * from './components/ProtectedRoute'; export {default as CallbackRoute} from './components/CallbackRoute'; export * from './components/CallbackRoute'; + +export {default as createAdminRoutes} from './routes/createAdminRoutes'; +export * from './routes/createAdminRoutes'; diff --git a/packages/react-router/src/routes/AdminPage.tsx b/packages/react-router/src/routes/AdminPage.tsx new file mode 100644 index 00000000..c44c00d7 --- /dev/null +++ b/packages/react-router/src/routes/AdminPage.tsx @@ -0,0 +1,148 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import { + AgentCreate, + AgentDetails, + AgentList, + Button, + GroupCreate, + GroupList, + GroupMembers, + RoleAssignments, + RoleCreate, + RoleList, + RolePermissions, + UserCreate, + UserDetails, + UserList, + useTranslation, +} from '@thunderid/react'; +import {FC, ReactElement, ReactNode} from 'react'; +import {useNavigate, useParams} from 'react-router'; +import type {AdminRoutesOptions, AdminSection} from './createAdminRoutes'; + +export type AdminPageKind = 'create' | 'details' | 'list'; + +/** + * Navigation between the pages of one section, relative to the page the user is on, so the routes + * work wherever the application mounts them. + */ +interface AdminNavigation { + createAction: ReactNode; + toDetails: (id: string) => void; + toList: () => void; +} + +interface AdminPageContext extends AdminNavigation { + id: string; + options: AdminRoutesOptions; +} + +type AdminPageRenderer = (context: AdminPageContext) => ReactElement; + +const SECTION_PAGES: Record> = { + agents: { + create: ({options, toDetails}: AdminPageContext) => ( + toDetails(agent.id)} /> + ), + details: ({id, options, toList}: AdminPageContext) => ( + + ), + list: ({createAction, options, toDetails}: AdminPageContext) => ( + toDetails(agent.id)} + /> + ), + }, + groups: { + create: ({options, toDetails}: AdminPageContext) => ( + toDetails(group.id)} /> + ), + details: ({id, options}: AdminPageContext) => ( + + ), + list: ({createAction, options, toDetails}: AdminPageContext) => ( + toDetails(group.id)} + /> + ), + }, + roles: { + create: ({options, toDetails}: AdminPageContext) => ( + toDetails(role.id)} /> + ), + details: ({id, options}: AdminPageContext) => ( + <> + + + + ), + list: ({createAction, options, toDetails}: AdminPageContext) => ( + toDetails(role.id)} + /> + ), + }, + users: { + create: ({options, toDetails}: AdminPageContext) => ( + toDetails(user.id)} /> + ), + details: ({id, options, toList}: AdminPageContext) => ( + + ), + list: ({createAction, options, toDetails}: AdminPageContext) => ( + toDetails(user.id)} + /> + ), + }, +}; + +interface AdminPageProps { + kind: AdminPageKind; + options: AdminRoutesOptions; + section: AdminSection; +} + +/** + * Renders one admin page and wires its callbacks to route-relative navigation. + */ +const AdminPage: FC = ({kind, options, section}: AdminPageProps): ReactElement => { + const navigate: ReturnType = useNavigate(); + const {id = ''} = useParams<{id: string}>(); + const {t} = useTranslation(options.preferences?.i18n); + + // Route-relative: the list is the section's index route, so `..` from `new` or `:id` returns to it. + const toList = (): void => { + void navigate(kind === 'list' ? '.' : '..'); + }; + const toDetails = (resourceId: string): void => { + const target: string = encodeURIComponent(resourceId); + + void navigate(kind === 'list' ? target : `../${target}`); + }; + + const createAction: ReactNode = + kind === 'list' ? ( + + ) : null; + + return SECTION_PAGES[section][kind]({createAction, id, options, toDetails, toList}); +}; + +export default AdminPage; diff --git a/packages/react-router/src/routes/__tests__/createAdminRoutes.test.tsx b/packages/react-router/src/routes/__tests__/createAdminRoutes.test.tsx new file mode 100644 index 00000000..640adee0 --- /dev/null +++ b/packages/react-router/src/routes/__tests__/createAdminRoutes.test.tsx @@ -0,0 +1,162 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {cleanup, fireEvent, render, screen} from '@testing-library/react'; +import {ReactElement, ReactNode} from 'react'; +import {RouterProvider, createMemoryRouter} from 'react-router'; +import {afterEach, describe, expect, it, vi} from 'vitest'; +import createAdminRoutes, {AdminRoutesOptions} from '../createAdminRoutes'; + +interface StubProps { + actions?: ReactNode; + agentId?: string; + deletable?: boolean; + editable?: boolean; + groupId?: string; + onCreated?: (resource: {id: string}) => void; + onDeleted?: () => void; + onSelect?: (resource: {id: string}) => void; + roleId?: string; + userId?: string; +} + +const mocks: {isSignedIn: boolean} = vi.hoisted(() => ({isSignedIn: true})); + +vi.mock('@thunderid/browser', () => ({ + createPackageComponentLogger: () => ({error: (): void => undefined}), +})); + +vi.mock('@thunderid/react', () => { + // Each admin component becomes a stub that shows what it was given and exposes its callbacks as buttons. + const stub = + (name: string) => + // eslint-disable-next-line react/display-name, react/require-default-props + ({actions, onCreated, onDeleted, onSelect, ...rest}: StubProps): ReactElement => ( +
+ {actions} + {onSelect && } + {onCreated && } + {onDeleted && } +
+ ); + + return { + AgentCreate: stub('AgentCreate'), + AgentDetails: stub('AgentDetails'), + AgentList: stub('AgentList'), + Button: ({children, onClick}: {children: ReactNode; onClick: () => void}): ReactElement => ( + + ), + GroupCreate: stub('GroupCreate'), + GroupList: stub('GroupList'), + GroupMembers: stub('GroupMembers'), + RoleAssignments: stub('RoleAssignments'), + RoleCreate: stub('RoleCreate'), + RoleList: stub('RoleList'), + RolePermissions: stub('RolePermissions'), + ThunderIDRuntimeError: class extends Error {}, + UserCreate: stub('UserCreate'), + UserDetails: stub('UserDetails'), + UserList: stub('UserList'), + navigate: (): void => undefined, + useThunderID: () => ({isLoading: false, isSignedIn: mocks.isSignedIn, signIn: () => Promise.resolve()}), + useTranslation: () => ({t: (key: string): string => key}), + }; +}); + +const renderAdmin = (initialPath: string, options?: AdminRoutesOptions): ReturnType => { + const router: ReturnType = createMemoryRouter( + [ + {element:
, path: '/signin'}, + {children: createAdminRoutes(options), path: '/admin'}, + ], + {initialEntries: [initialPath]}, + ); + + render(); + + return router; +}; + +const propsOf = (testId: string): Record => + JSON.parse(screen.getByTestId(testId).getAttribute('data-props') ?? '{}') as Record; + +describe('createAdminRoutes', () => { + afterEach(() => { + cleanup(); + mocks.isSignedIn = true; + }); + + it('mounts a list, create and details route for every section by default', () => { + const routes = createAdminRoutes(); + + expect(routes.map((route) => route.path)).toEqual(['users', 'groups', 'roles', 'agents']); + expect(routes[0].children?.map((route) => route.path ?? 'index')).toEqual(['index', 'new', ':id']); + }); + + it('mounts only the requested sections', () => { + expect(createAdminRoutes({sections: ['roles']}).map((route) => route.path)).toEqual(['roles']); + }); + + it('opens the details page when a list row is selected, keeping the identifier encoded', async () => { + const router = renderAdmin('/admin/users'); + + fireEvent.click(screen.getByText('select')); + + expect(await screen.findByTestId('UserDetails')).toBeDefined(); + expect(router.state.location.pathname).toBe('/admin/users/id%2F1'); + expect(propsOf('UserDetails')).toMatchObject({userId: 'id/1'}); + }); + + it('opens the create form from the list action', async () => { + const router = renderAdmin('/admin/groups'); + + fireEvent.click(screen.getByText('admin.groups.create.title')); + + expect(await screen.findByTestId('GroupCreate')).toBeDefined(); + expect(router.state.location.pathname).toBe('/admin/groups/new'); + }); + + it('opens the new resource after it is created', async () => { + const router = renderAdmin('/admin/agents/new'); + + fireEvent.click(screen.getByText('created')); + + expect(await screen.findByTestId('AgentDetails')).toBeDefined(); + expect(router.state.location.pathname).toBe('/admin/agents/new-1'); + }); + + it('returns to the list after a delete', async () => { + const router = renderAdmin('/admin/users/u-1', {deletable: true}); + + expect(propsOf('UserDetails')).toMatchObject({deletable: true, userId: 'u-1'}); + + fireEvent.click(screen.getByText('deleted')); + + expect(await screen.findByTestId('UserList')).toBeDefined(); + expect(router.state.location.pathname).toBe('/admin/users'); + }); + + it("renders a role's permissions and assignments on its details page", () => { + renderAdmin('/admin/roles/r-1', {editable: false}); + + expect(propsOf('RolePermissions')).toMatchObject({roleId: 'r-1'}); + expect(propsOf('RoleAssignments')).toMatchObject({editable: false, roleId: 'r-1'}); + }); + + it('renders the group members on the group details page', () => { + renderAdmin('/admin/groups/g-1'); + + expect(propsOf('GroupMembers')).toMatchObject({groupId: 'g-1'}); + }); + + it('guards every page with ProtectedRoute when asked to', async () => { + mocks.isSignedIn = false; + + const router = renderAdmin('/admin/users', {protect: {redirectTo: '/signin'}}); + + expect(await screen.findByTestId('signin')).toBeDefined(); + expect(router.state.location.pathname).toBe('/signin'); + expect(screen.queryByTestId('UserList')).toBeNull(); + }); +}); diff --git a/packages/react-router/src/routes/createAdminRoutes.tsx b/packages/react-router/src/routes/createAdminRoutes.tsx new file mode 100644 index 00000000..6799b8aa --- /dev/null +++ b/packages/react-router/src/routes/createAdminRoutes.tsx @@ -0,0 +1,93 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import type {Preferences} from '@thunderid/browser'; +import {ReactElement} from 'react'; +import {RouteObject} from 'react-router'; +import AdminPage, {AdminPageKind} from './AdminPage'; +import ProtectedRoute, {ProtectedRouteProps} from '../components/ProtectedRoute'; + +/** + * An admin section that {@link createAdminRoutes} can mount. + */ +export type AdminSection = 'agents' | 'groups' | 'roles' | 'users'; + +/** + * Options for {@link createAdminRoutes}. + */ +export interface AdminRoutesOptions { + /** + * Shows the delete action on the user and agent details pages. + * @default false + */ + deletable?: boolean; + /** + * Allows editing on the details pages: user attributes, group members and role assignments. When + * unset, each component keeps its own default. + */ + editable?: boolean; + /** + * Passed to every admin component. + */ + preferences?: Preferences; + /** + * Wraps every admin page in {@link ProtectedRoute} with these props. The management API still + * checks every request, so this only decides what an unauthenticated visitor sees. + */ + protect?: Omit; + /** + * The sections to mount. + * @default ['users', 'groups', 'roles', 'agents'] + */ + sections?: AdminSection[]; +} + +const DEFAULT_SECTIONS: AdminSection[] = ['users', 'groups', 'roles', 'agents']; + +/** + * Builds React Router route objects that mount the admin (management) sections of + * `@thunderid/react`: for each section a list at `
`, a create form at `
/new`, and a + * details page at `
/:id`. Selecting, creating and deleting navigate between them. + * + * The paths are relative, so the routes can be nested under any parent route. + * + * @param options - Which sections to mount and how to render them. + * @returns Route objects to use as the children of a parent route, or with `useRoutes`. + * + * @example + * ```tsx + * const router = createBrowserRouter([ + * {path: '/', element: }, + * {path: '/admin', children: createAdminRoutes({protect: {redirectTo: '/signin'}, deletable: true})}, + * ]); + * ``` + * + * @example With `` + * ```tsx + * const AdminRoutes = () => useRoutes(createAdminRoutes({sections: ['users', 'groups']})); + * + * } /> + * ``` + */ +const createAdminRoutes = (options: AdminRoutesOptions = {}): RouteObject[] => { + const {protect, sections = DEFAULT_SECTIONS} = options; + + const page = (section: AdminSection, kind: AdminPageKind): ReactElement => { + const element: ReactElement = ; + + return protect ? {element} : element; + }; + + return sections.map( + (section: AdminSection): RouteObject => ({ + children: [ + {element: page(section, 'list'), index: true}, + {element: page(section, 'create'), path: 'new'}, + {element: page(section, 'details'), path: ':id'}, + ], + path: section, + }), + ); +}; + +export default createAdminRoutes; diff --git a/packages/react/src/api/getUsersMe.ts b/packages/react/src/api/account/getUsersMe.ts similarity index 100% rename from packages/react/src/api/getUsersMe.ts rename to packages/react/src/api/account/getUsersMe.ts diff --git a/packages/react/src/api/getUsersMeMeta.ts b/packages/react/src/api/account/getUsersMeMeta.ts similarity index 100% rename from packages/react/src/api/getUsersMeMeta.ts rename to packages/react/src/api/account/getUsersMeMeta.ts diff --git a/packages/react/src/api/updateMeCredentials.ts b/packages/react/src/api/account/updateMeCredentials.ts similarity index 100% rename from packages/react/src/api/updateMeCredentials.ts rename to packages/react/src/api/account/updateMeCredentials.ts diff --git a/packages/react/src/api/updateMeProfile.ts b/packages/react/src/api/account/updateMeProfile.ts similarity index 100% rename from packages/react/src/api/updateMeProfile.ts rename to packages/react/src/api/account/updateMeProfile.ts diff --git a/packages/react/src/components/presentation/ChangeCredential/BaseChangeCredential.styles.ts b/packages/react/src/components/account/ChangeCredential/BaseChangeCredential.styles.ts similarity index 100% rename from packages/react/src/components/presentation/ChangeCredential/BaseChangeCredential.styles.ts rename to packages/react/src/components/account/ChangeCredential/BaseChangeCredential.styles.ts diff --git a/packages/react/src/components/presentation/ChangeCredential/BaseChangeCredential.tsx b/packages/react/src/components/account/ChangeCredential/BaseChangeCredential.tsx similarity index 100% rename from packages/react/src/components/presentation/ChangeCredential/BaseChangeCredential.tsx rename to packages/react/src/components/account/ChangeCredential/BaseChangeCredential.tsx diff --git a/packages/react/src/components/presentation/ChangeCredential/ChangeCredential.tsx b/packages/react/src/components/account/ChangeCredential/ChangeCredential.tsx similarity index 98% rename from packages/react/src/components/presentation/ChangeCredential/ChangeCredential.tsx rename to packages/react/src/components/account/ChangeCredential/ChangeCredential.tsx index 92811144..51ae7986 100644 --- a/packages/react/src/components/presentation/ChangeCredential/ChangeCredential.tsx +++ b/packages/react/src/components/account/ChangeCredential/ChangeCredential.tsx @@ -12,7 +12,7 @@ import { } from '@thunderid/browser'; import {FC, useMemo, useState} from 'react'; import BaseChangeCredential, {BaseChangeCredentialProps, ChangePasswordValues} from './BaseChangeCredential'; -import updateMeCredentials from '../../../api/updateMeCredentials'; +import updateMeCredentials from '../../../api/account/updateMeCredentials'; import useThunderID from '../../../contexts/ThunderID/useThunderID'; import useUser from '../../../contexts/User/useUser'; import useTranslation from '../../../hooks/useTranslation'; diff --git a/packages/react/src/components/presentation/ChangeCredential/__tests__/ChangeCredential.test.tsx b/packages/react/src/components/account/ChangeCredential/__tests__/ChangeCredential.test.tsx similarity index 99% rename from packages/react/src/components/presentation/ChangeCredential/__tests__/ChangeCredential.test.tsx rename to packages/react/src/components/account/ChangeCredential/__tests__/ChangeCredential.test.tsx index 3930c3e5..b3bc6a1a 100644 --- a/packages/react/src/components/presentation/ChangeCredential/__tests__/ChangeCredential.test.tsx +++ b/packages/react/src/components/account/ChangeCredential/__tests__/ChangeCredential.test.tsx @@ -11,7 +11,7 @@ import ChangeCredential from '../ChangeCredential'; const mockUpdateMeCredentials = vi.fn() as Mock; -vi.mock('../../../../api/updateMeCredentials', () => ({ +vi.mock('../../../../api/account/updateMeCredentials', () => ({ default: (...args: unknown[]): unknown => mockUpdateMeCredentials(...args) as unknown, })); diff --git a/packages/react/src/components/presentation/User/BaseUser.tsx b/packages/react/src/components/account/User/BaseUser.tsx similarity index 100% rename from packages/react/src/components/presentation/User/BaseUser.tsx rename to packages/react/src/components/account/User/BaseUser.tsx diff --git a/packages/react/src/components/presentation/User/User.tsx b/packages/react/src/components/account/User/User.tsx similarity index 100% rename from packages/react/src/components/presentation/User/User.tsx rename to packages/react/src/components/account/User/User.tsx diff --git a/packages/react/src/components/presentation/UserAvatar/BaseUserAvatar.tsx b/packages/react/src/components/account/UserAvatar/BaseUserAvatar.tsx similarity index 100% rename from packages/react/src/components/presentation/UserAvatar/BaseUserAvatar.tsx rename to packages/react/src/components/account/UserAvatar/BaseUserAvatar.tsx diff --git a/packages/react/src/components/presentation/UserAvatar/UserAvatar.tsx b/packages/react/src/components/account/UserAvatar/UserAvatar.tsx similarity index 100% rename from packages/react/src/components/presentation/UserAvatar/UserAvatar.tsx rename to packages/react/src/components/account/UserAvatar/UserAvatar.tsx diff --git a/packages/react/src/components/presentation/UserDropdown/BaseUserDropdown.styles.ts b/packages/react/src/components/account/UserDropdown/BaseUserDropdown.styles.ts similarity index 100% rename from packages/react/src/components/presentation/UserDropdown/BaseUserDropdown.styles.ts rename to packages/react/src/components/account/UserDropdown/BaseUserDropdown.styles.ts diff --git a/packages/react/src/components/presentation/UserDropdown/BaseUserDropdown.tsx b/packages/react/src/components/account/UserDropdown/BaseUserDropdown.tsx similarity index 100% rename from packages/react/src/components/presentation/UserDropdown/BaseUserDropdown.tsx rename to packages/react/src/components/account/UserDropdown/BaseUserDropdown.tsx diff --git a/packages/react/src/components/presentation/UserDropdown/UserDropdown.tsx b/packages/react/src/components/account/UserDropdown/UserDropdown.tsx similarity index 100% rename from packages/react/src/components/presentation/UserDropdown/UserDropdown.tsx rename to packages/react/src/components/account/UserDropdown/UserDropdown.tsx diff --git a/packages/react/src/components/presentation/UserProfile/BaseUserProfile.styles.ts b/packages/react/src/components/account/UserProfile/BaseUserProfile.styles.ts similarity index 100% rename from packages/react/src/components/presentation/UserProfile/BaseUserProfile.styles.ts rename to packages/react/src/components/account/UserProfile/BaseUserProfile.styles.ts diff --git a/packages/react/src/components/presentation/UserProfile/BaseUserProfile.tsx b/packages/react/src/components/account/UserProfile/BaseUserProfile.tsx similarity index 100% rename from packages/react/src/components/presentation/UserProfile/BaseUserProfile.tsx rename to packages/react/src/components/account/UserProfile/BaseUserProfile.tsx diff --git a/packages/react/src/components/presentation/UserProfile/UserProfile.tsx b/packages/react/src/components/account/UserProfile/UserProfile.tsx similarity index 98% rename from packages/react/src/components/presentation/UserProfile/UserProfile.tsx rename to packages/react/src/components/account/UserProfile/UserProfile.tsx index 801a48a0..b6952ff0 100644 --- a/packages/react/src/components/presentation/UserProfile/UserProfile.tsx +++ b/packages/react/src/components/account/UserProfile/UserProfile.tsx @@ -5,7 +5,7 @@ import {ThunderIDError, User, deepMerge, resolveResourceEndpoint} from '@thunder import {FC, ReactElement, useState} from 'react'; // eslint-disable-next-line import/no-named-as-default import BaseUserProfile, {BaseUserProfileProps} from './BaseUserProfile'; -import updateMeProfile from '../../../api/updateMeProfile'; +import updateMeProfile from '../../../api/account/updateMeProfile'; import useThunderID from '../../../contexts/ThunderID/useThunderID'; import useUser from '../../../contexts/User/useUser'; import useTranslation from '../../../hooks/useTranslation'; diff --git a/packages/react/src/components/admin/AgentCreate/AgentCreate.tsx b/packages/react/src/components/admin/AgentCreate/AgentCreate.tsx new file mode 100644 index 00000000..4f799310 --- /dev/null +++ b/packages/react/src/components/admin/AgentCreate/AgentCreate.tsx @@ -0,0 +1,76 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {toManagementErrorMessage, Agent, AgentType, SelectOption} from '@thunderid/browser'; +import {FC, ReactElement, useMemo} from 'react'; +import BaseAgentCreate, {AgentCreateSubmission, BaseAgentCreateProps} from './BaseAgentCreate'; +import useCreateAgent from '../../../hooks/management/agents/useCreateAgent'; +import useGetAgentTypes from '../../../hooks/management/agents/useGetAgentTypes'; +import useTranslation from '../../../hooks/useTranslation'; +import useDefaultOuId from '../shared/useDefaultOuId'; + +export type AgentCreateProps = Omit & { + /** + * Called with the created agent. + */ + onCreated?: (agent: Agent) => void; + /** + * Organization unit the agent is created in. Defaults to the signed-in user's organization unit. + */ + ouId?: string; + /** + * The person responsible for the agent. Defaults to the server's choice, usually the creator. + */ + owner?: string; +}; + +/** + * Registers an agent through the management API. + * + * @example + * ```tsx + * navigate(`/agents/${agent.id}`)} /> + * ``` + */ +const AgentCreate: FC = ({ + onCreated, + ouId, + owner, + preferences, + ...rest +}: AgentCreateProps): ReactElement => { + const {t} = useTranslation(preferences?.i18n); + const resolvedOuId: string | undefined = useDefaultOuId(ouId); + const {data: agentTypes} = useGetAgentTypes({limit: 100}); + const {error, isLoading, mutate} = useCreateAgent({onSuccess: onCreated}); + + const options: SelectOption[] = useMemo( + () => + (agentTypes?.types ?? []).map((agentType: AgentType) => ({ + label: agentType.displayName, + value: agentType.handle, + })), + [agentTypes], + ); + + const handleSubmit = ({description, name, type}: AgentCreateSubmission): void => { + if (resolvedOuId) { + void mutate({description, name, ouId: resolvedOuId, owner, type}); + } + }; + + return ( + + ); +}; + +export default AgentCreate; diff --git a/packages/react/src/components/admin/AgentCreate/BaseAgentCreate.tsx b/packages/react/src/components/admin/AgentCreate/BaseAgentCreate.tsx new file mode 100644 index 00000000..90dbb46e --- /dev/null +++ b/packages/react/src/components/admin/AgentCreate/BaseAgentCreate.tsx @@ -0,0 +1,177 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {Preferences, SelectOption} from '@thunderid/browser'; +import {ChangeEvent, FC, FormEvent, ReactElement, ReactNode, useState} from 'react'; +import useTheme from '../../../contexts/Theme/useTheme'; +import useTranslation from '../../../hooks/useTranslation'; +import Alert from '../../primitives/Alert/Alert'; +import Button from '../../primitives/Button/Button'; +import Select from '../../primitives/Select/Select'; +import TextField from '../../primitives/TextField/TextField'; +import AdminSection from '../shared/AdminSection'; +import useStyles from '../shared/AdminSection.styles'; + +/** + * The agent {@link BaseAgentCreate} submits. The organization unit is added by the caller. + */ +export interface AgentCreateSubmission { + description?: string; + name: string; + type: string; +} + +/** + * What {@link BaseAgentCreate} hands its `children` render function, so the form can be built with any UI. + */ +export interface BaseAgentCreateRenderProps { + agentTypes: SelectOption[]; + error: string | null; + /** + * Sets one field of the form. + */ + handleInputChange: (field: 'description' | 'name' | 'type', value: string) => void; + /** + * Submits the current values. Does nothing while they are not valid. + */ + handleSubmit: () => void; + isSubmitting: boolean; + /** + * Whether the values can be submitted. + */ + isValid: boolean; + onCancel?: () => void; + values: {description: string; name: string; type: string}; +} + +export interface BaseAgentCreateProps { + /** + * The agent types to choose from. The first one is selected by default. + */ + agentTypes: SelectOption[]; + /** + * Renders custom UI in place of the default form. Receives the values, their setters, the agent + * types, the submit handler and the submission state. + */ + children?: (props: BaseAgentCreateRenderProps) => ReactNode; + className?: string; + description?: string; + error?: string | null; + isSubmitting?: boolean; + onCancel?: () => void; + onSubmit: (agent: AgentCreateSubmission) => void; + preferences?: Preferences; + title?: string; +} + +/** + * A form that collects a new agent's name, type and description. Holds no data of its own: pass + * the agent types and handle the submission. + */ +const BaseAgentCreate: FC = ({ + agentTypes, + children, + className, + description, + error = null, + isSubmitting = false, + onCancel, + onSubmit, + preferences, + title, +}: BaseAgentCreateProps): ReactElement => { + const {theme, colorScheme} = useTheme(); + const styles: Record = useStyles(theme, colorScheme); + const {t} = useTranslation(preferences?.i18n); + const [name, setName] = useState(''); + const [chosenType, setType] = useState(''); + const type: string = chosenType || (agentTypes[0]?.value ?? ''); + const [agentDescription, setAgentDescription] = useState(''); + + const isValid: boolean = Boolean(name.trim()) && Boolean(type); + + const submit = (): void => { + if (isValid) { + onSubmit({description: agentDescription.trim() || undefined, name: name.trim(), type}); + } + }; + + const handleSubmit = (event: FormEvent): void => { + event.preventDefault(); + submit(); + }; + + if (children) { + const setters: Record<'description' | 'name' | 'type', (value: string) => void> = { + description: setAgentDescription, + name: setName, + type: setType, + }; + + return ( +
+ {children({ + agentTypes, + error, + handleInputChange: (field: 'description' | 'name' | 'type', value: string): void => setters[field](value), + handleSubmit: submit, + isSubmitting, + isValid, + onCancel, + values: {description: agentDescription, name, type}, + })} +
+ ); + } + + return ( + +
+ {error && ( + + {error} + + )} + ): void => setName(event.target.value)} + /> + ({ + label: t(`admin.common.identity_types.${type}`), + value: type, + })), + ]} + onChange={(event: ChangeEvent): void => + onTypeFilterChange((event.target.value || null) as RoleAssigneeType | null) + } + /> +
+ )} + `${assignment.type}:${assignment.id}`} + isLoading={isLoading} + error={error} + emptyText={t('admin.roles.assignments.empty')} + /> + + ); +}; + +export default BaseRoleAssignments; diff --git a/packages/react/src/components/admin/RoleAssignments/RoleAssignments.tsx b/packages/react/src/components/admin/RoleAssignments/RoleAssignments.tsx new file mode 100644 index 00000000..f00d184d --- /dev/null +++ b/packages/react/src/components/admin/RoleAssignments/RoleAssignments.tsx @@ -0,0 +1,116 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import { + resolveManagedIdentityName, + toManagementErrorMessage, + BasicAgent, + Group, + ManagedUser, + RoleAssigneeType, + RoleAssignment, + RoleAssignmentInput, +} from '@thunderid/browser'; +import {FC, ReactElement, useMemo, useState} from 'react'; +import BaseRoleAssignments, {BaseRoleAssignmentsProps} from './BaseRoleAssignments'; +import useGetAgents from '../../../hooks/management/agents/useGetAgents'; +import useGetGroups from '../../../hooks/management/groups/useGetGroups'; +import useAddRoleAssignments from '../../../hooks/management/roles/useAddRoleAssignments'; +import useGetRoleAssignments from '../../../hooks/management/roles/useGetRoleAssignments'; +import useRemoveRoleAssignments from '../../../hooks/management/roles/useRemoveRoleAssignments'; +import useGetUsers from '../../../hooks/management/users/useGetUsers'; +import useTranslation from '../../../hooks/useTranslation'; +import {IdentityCandidate} from '../shared/IdentityPicker'; + +export type RoleAssignmentsProps = Omit< + BaseRoleAssignmentsProps, + | 'assignments' + | 'candidates' + | 'error' + | 'isLoading' + | 'isMutating' + | 'onAssign' + | 'onTypeFilterChange' + | 'onUnassign' + | 'typeFilter' +> & { + /** + * Whether assignments can be added and removed from this view. + * @default false + */ + editable?: boolean; + /** + * Whether to show the assignee type filter, which narrows the list on the server. + * @default true + */ + filterable?: boolean; + roleId: string; +}; + +/** + * Lists who holds a role, and lets the signed-in user assign it to users, groups and agents or + * remove it from them. + * + * @example + * ```tsx + * + * ``` + */ +const RoleAssignments: FC = ({ + editable = false, + filterable = true, + preferences, + roleId, + ...rest +}: RoleAssignmentsProps): ReactElement => { + const {t} = useTranslation(preferences?.i18n); + const [typeFilter, setTypeFilter] = useState(null); + const assignments = useGetRoleAssignments(roleId, {limit: 100, type: typeFilter ?? undefined}); + const users = useGetUsers({limit: 100}, {enabled: editable}); + const groups = useGetGroups({limit: 100}, {enabled: editable}); + const agents = useGetAgents({limit: 100}, {enabled: editable}); + const assign = useAddRoleAssignments(); + const unassign = useRemoveRoleAssignments(); + + const candidates: Partial> = useMemo( + () => ({ + agent: (agents.data?.agents ?? []).map((agent: BasicAgent) => ({id: agent.id, label: agent.name})), + group: (groups.data?.groups ?? []).map((group: Group) => ({id: group.id, label: group.name})), + user: (users.data?.users ?? []).map((user: ManagedUser) => ({ + id: user.id, + label: resolveManagedIdentityName(user), + })), + }), + [agents.data, groups.data, users.data], + ); + + return ( + void assign.mutate({assignments: [assignment], roleId}) + : undefined + } + onUnassign={ + editable + ? (assignment: RoleAssignment): void => + void unassign.mutate({assignments: [{id: assignment.id, type: assignment.type}], roleId}) + : undefined + } + typeFilter={typeFilter} + onTypeFilterChange={filterable ? setTypeFilter : undefined} + preferences={preferences} + {...rest} + /> + ); +}; + +export default RoleAssignments; diff --git a/packages/react/src/components/admin/RoleCreate/BaseRoleCreate.tsx b/packages/react/src/components/admin/RoleCreate/BaseRoleCreate.tsx new file mode 100644 index 00000000..e64f8ff1 --- /dev/null +++ b/packages/react/src/components/admin/RoleCreate/BaseRoleCreate.tsx @@ -0,0 +1,153 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {Preferences} from '@thunderid/browser'; +import {ChangeEvent, FC, FormEvent, ReactElement, ReactNode, useState} from 'react'; +import useTheme from '../../../contexts/Theme/useTheme'; +import useTranslation from '../../../hooks/useTranslation'; +import Alert from '../../primitives/Alert/Alert'; +import Button from '../../primitives/Button/Button'; +import TextField from '../../primitives/TextField/TextField'; +import AdminSection from '../shared/AdminSection'; +import useStyles from '../shared/AdminSection.styles'; + +/** + * The role {@link BaseRoleCreate} submits. The organization unit is added by the caller. + */ +export interface RoleCreateSubmission { + description?: string; + name: string; +} + +/** + * What {@link BaseRoleCreate} hands its `children` render function, so the form can be built with any UI. + */ +export interface BaseRoleCreateRenderProps { + error: string | null; + /** + * Sets one field of the form. + */ + handleInputChange: (field: 'description' | 'name', value: string) => void; + /** + * Submits the current values. Does nothing while they are not valid. + */ + handleSubmit: () => void; + isSubmitting: boolean; + /** + * Whether the values can be submitted. + */ + isValid: boolean; + onCancel?: () => void; + values: {description: string; name: string}; +} + +export interface BaseRoleCreateProps { + /** + * Renders custom UI in place of the default form. Receives the values, their setters, the submit + * handler and the submission state. + */ + children?: (props: BaseRoleCreateRenderProps) => ReactNode; + className?: string; + description?: string; + error?: string | null; + isSubmitting?: boolean; + onCancel?: () => void; + onSubmit: (role: RoleCreateSubmission) => void; + preferences?: Preferences; + title?: string; +} + +/** + * A form that collects a new role's name and description. Holds no data of its own: handle the + * submission. + */ +const BaseRoleCreate: FC = ({ + children, + className, + description, + error = null, + isSubmitting = false, + onCancel, + onSubmit, + preferences, + title, +}: BaseRoleCreateProps): ReactElement => { + const {theme, colorScheme} = useTheme(); + const styles: Record = useStyles(theme, colorScheme); + const {t} = useTranslation(preferences?.i18n); + const [name, setName] = useState(''); + const [roleDescription, setRoleDescription] = useState(''); + + const isValid: boolean = name.trim().length > 0; + + const submit = (): void => { + if (isValid) { + onSubmit({description: roleDescription.trim() || undefined, name: name.trim()}); + } + }; + + const handleSubmit = (event: FormEvent): void => { + event.preventDefault(); + submit(); + }; + + if (children) { + return ( +
+ {children({ + error, + handleInputChange: (field: 'description' | 'name', value: string): void => + field === 'name' ? setName(value) : setRoleDescription(value), + handleSubmit: submit, + isSubmitting, + isValid, + onCancel, + values: {description: roleDescription, name}, + })} +
+ ); + } + + return ( + + + {error && ( + + {error} + + )} + ): void => setName(event.target.value)} + /> + ): void => setRoleDescription(event.target.value)} + /> +
+ {onCancel && ( + + )} + +
+ +
+ ); +}; + +export default BaseRoleCreate; diff --git a/packages/react/src/components/admin/RoleCreate/RoleCreate.tsx b/packages/react/src/components/admin/RoleCreate/RoleCreate.tsx new file mode 100644 index 00000000..a17a4301 --- /dev/null +++ b/packages/react/src/components/admin/RoleCreate/RoleCreate.tsx @@ -0,0 +1,54 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {toManagementErrorMessage, Role} from '@thunderid/browser'; +import {FC, ReactElement} from 'react'; +import BaseRoleCreate, {BaseRoleCreateProps, RoleCreateSubmission} from './BaseRoleCreate'; +import useCreateRole from '../../../hooks/management/roles/useCreateRole'; +import useTranslation from '../../../hooks/useTranslation'; +import useDefaultOuId from '../shared/useDefaultOuId'; + +export type RoleCreateProps = Omit & { + /** + * Called with the created role. + */ + onCreated?: (role: Role) => void; + /** + * Organization unit the role is created in. Defaults to the signed-in user's organization unit. + */ + ouId?: string; +}; + +/** + * Creates a role through the management API. + * + * @example + * ```tsx + * navigate(`/roles/${role.id}`)} /> + * ``` + */ +const RoleCreate: FC = ({onCreated, ouId, preferences, ...rest}: RoleCreateProps): ReactElement => { + const {t} = useTranslation(preferences?.i18n); + const resolvedOuId: string | undefined = useDefaultOuId(ouId); + const {error, isLoading, mutate} = useCreateRole({onSuccess: onCreated}); + + const handleSubmit = ({description, name}: RoleCreateSubmission): void => { + if (resolvedOuId) { + void mutate({description, name, ouId: resolvedOuId}); + } + }; + + return ( + + ); +}; + +export default RoleCreate; diff --git a/packages/react/src/components/admin/RoleList/BaseRoleList.tsx b/packages/react/src/components/admin/RoleList/BaseRoleList.tsx new file mode 100644 index 00000000..42cc0a1e --- /dev/null +++ b/packages/react/src/components/admin/RoleList/BaseRoleList.tsx @@ -0,0 +1,139 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {Preferences, RoleSummary} from '@thunderid/browser'; +import {FC, ReactElement, ReactNode} from 'react'; +import useTranslation from '../../../hooks/useTranslation'; +import DataTable, {DataTableColumn} from '../../primitives/DataTable/DataTable'; +import {AdminListRenderProps} from '../shared/AdminListRenderProps'; +import AdminSection from '../shared/AdminSection'; + +/** + * What {@link BaseRoleList} hands its `children` render function. `roles` and `rows` are the same array. + */ +export type BaseRoleListRenderProps = AdminListRenderProps & {roles: RoleSummary[]}; + +export interface BaseRoleListProps { + /** + * Controls rendered next to the title, such as a button that opens `RoleCreate`. + */ + actions?: ReactNode; + /** + * Renders custom UI in place of the default table, for example a third-party data grid. Receives + * the roles, the default columns, the loading and error state, and the pagination and selection + * handlers. + */ + children?: (props: BaseRoleListRenderProps) => ReactNode; + className?: string; + description?: string; + error?: string | null; + isLoading?: boolean; + /** + * Page size. + * @default 20 + */ + limit?: number; + offset?: number; + /** + * Called with the offset of the page to show. Pagination is hidden when omitted. + */ + onPageChange?: (offset: number) => void; + /** + * Called when a role is chosen. Rows are selectable only when set. + */ + onSelect?: (role: RoleSummary) => void; + preferences?: Preferences; + roles: RoleSummary[]; + selectedRoleId?: string; + /** + * Heading of the list. Pass an empty string to hide it. + */ + title?: string; + totalResults?: number; +} + +/** + * Lists roles with their description and organization unit. Holds no data of its own: pass the + * page of roles and handle selection and pagination. + */ +const BaseRoleList: FC = ({ + actions, + children, + className, + description, + error = null, + isLoading = false, + limit = 20, + offset = 0, + onPageChange, + onSelect, + preferences, + roles, + selectedRoleId, + title, + totalResults, +}: BaseRoleListProps): ReactElement => { + const {t} = useTranslation(preferences?.i18n); + + const columns: DataTableColumn[] = [ + {header: t('admin.roles.list.columns.name'), id: 'name', render: (role: RoleSummary): ReactNode => role.name}, + { + header: t('admin.roles.list.columns.description'), + id: 'description', + render: (role: RoleSummary): ReactNode => role.description ?? '', + }, + { + header: t('admin.common.columns.organization_unit'), + id: 'ou', + render: (role: RoleSummary): ReactNode => role.ouHandle ?? role.ouId, + }, + ]; + + if (children) { + return ( +
+ {children({ + columns, + error, + isLoading, + limit, + offset, + onPageChange, + onSelect, + rows: roles, + selectedRowId: selectedRoleId, + totalResults: totalResults ?? roles.length, + roles, + })} +
+ ); + } + + return ( + + role.id} + isLoading={isLoading} + error={error} + emptyText={t('admin.roles.list.empty')} + onRowClick={onSelect} + selectedRowId={selectedRoleId} + pagination={ + onPageChange ? {limit, offset, onChange: onPageChange, total: totalResults ?? roles.length} : undefined + } + /> + + ); +}; + +export default BaseRoleList; diff --git a/packages/react/src/components/admin/RoleList/RoleList.tsx b/packages/react/src/components/admin/RoleList/RoleList.tsx new file mode 100644 index 00000000..b14fe34c --- /dev/null +++ b/packages/react/src/components/admin/RoleList/RoleList.tsx @@ -0,0 +1,43 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {toManagementErrorMessage} from '@thunderid/browser'; +import {FC, ReactElement, useState} from 'react'; +import BaseRoleList, {BaseRoleListProps} from './BaseRoleList'; +import useGetRoles from '../../../hooks/management/roles/useGetRoles'; +import useTranslation from '../../../hooks/useTranslation'; + +export type RoleListProps = Omit< + BaseRoleListProps, + 'error' | 'isLoading' | 'offset' | 'onPageChange' | 'roles' | 'totalResults' +>; + +/** + * Lists the roles the signed-in user is allowed to see, a page at a time. + * + * @example + * ```tsx + * setSelectedRoleId(role.id)} /> + * ``` + */ +const RoleList: FC = ({limit = 20, preferences, ...rest}: RoleListProps): ReactElement => { + const {t} = useTranslation(preferences?.i18n); + const [offset, setOffset] = useState(0); + const {data, error, isLoading} = useGetRoles({limit, offset}); + + return ( + + ); +}; + +export default RoleList; diff --git a/packages/react/src/components/admin/RolePermissions/BaseRolePermissions.tsx b/packages/react/src/components/admin/RolePermissions/BaseRolePermissions.tsx new file mode 100644 index 00000000..9b2ebf79 --- /dev/null +++ b/packages/react/src/components/admin/RolePermissions/BaseRolePermissions.tsx @@ -0,0 +1,125 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {Preferences, RoleResourcePermissions} from '@thunderid/browser'; +import {FC, ReactElement, ReactNode} from 'react'; +import useTranslation from '../../../hooks/useTranslation'; +import Alert from '../../primitives/Alert/Alert'; +import Spinner from '../../primitives/Spinner/Spinner'; +import {ChipList, Panel} from '../shared/AdminParts'; +import AdminSection from '../shared/AdminSection'; +import useAdminPartStyles from '../shared/useAdminPartStyles'; + +/** + * What {@link BaseRolePermissions} hands its `children` render function, so the permissions can be + * shown with any UI. + */ +export interface BaseRolePermissionsRenderProps { + error: string | null; + isLoading: boolean; + /** + * The resource servers the role grants at least one permission on, with those permissions. + */ + permissions: RoleResourcePermissions[]; + /** + * Returns a resource server's display name, or its identifier when the name is missing. + */ + resolveResourceServerName: (resourceServerId: string) => string; +} + +export interface BaseRolePermissionsProps { + /** + * Renders custom UI in place of the default view. Receives the granted permissions grouped by + * resource server, the loading and error state, and a resource server name resolver. + */ + children?: (props: BaseRolePermissionsRenderProps) => ReactNode; + className?: string; + description?: string; + error?: string | null; + isLoading?: boolean; + permissions: RoleResourcePermissions[]; + preferences?: Preferences; + /** + * Display names of resource servers, keyed by identifier. The identifier is shown when a name is missing. + */ + resourceServerNames?: Record; + title?: string; +} + +/** + * Shows the permissions a role grants, grouped by resource server. Read-only, and holds no data of its own. + */ +const BaseRolePermissions: FC = ({ + children, + className, + description, + error = null, + isLoading = false, + permissions, + preferences, + resourceServerNames, + title, +}: BaseRolePermissionsProps): ReactElement => { + const {t} = useTranslation(preferences?.i18n); + const styles: Record = useAdminPartStyles(); + const granted: RoleResourcePermissions[] = permissions.filter( + (entry: RoleResourcePermissions) => entry.permissions.length > 0, + ); + const resolveResourceServerName = (resourceServerId: string): string => + resourceServerNames?.[resourceServerId] ?? resourceServerId; + + if (children) { + return ( +
{children({error, isLoading, permissions: granted, resolveResourceServerName})}
+ ); + } + + const renderContent = (): ReactElement => { + if (isLoading) { + return ; + } + if (error) { + return ( + + {error} + + ); + } + if (granted.length === 0) { + return {t('admin.roles.permissions.empty')}; + } + + return ( + <> + {granted.map((entry: RoleResourcePermissions) => { + const name: string = resolveResourceServerName(entry.resourceServerId); + + return ( + + ({id: permission, label: permission}))} + /> + + ); + })} + + ); + }; + + return ( + + {renderContent()} + + ); +}; + +export default BaseRolePermissions; diff --git a/packages/react/src/components/admin/RolePermissions/RolePermissions.tsx b/packages/react/src/components/admin/RolePermissions/RolePermissions.tsx new file mode 100644 index 00000000..1ab578e3 --- /dev/null +++ b/packages/react/src/components/admin/RolePermissions/RolePermissions.tsx @@ -0,0 +1,41 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {toManagementErrorMessage} from '@thunderid/browser'; +import {FC, ReactElement} from 'react'; +import BaseRolePermissions, {BaseRolePermissionsProps} from './BaseRolePermissions'; +import useGetRole from '../../../hooks/management/roles/useGetRole'; +import useTranslation from '../../../hooks/useTranslation'; + +export type RolePermissionsProps = Omit & { + roleId: string; +}; + +/** + * Shows the permissions a role grants. + * + * @example + * ```tsx + * + * ``` + */ +const RolePermissions: FC = ({ + preferences, + roleId, + ...rest +}: RolePermissionsProps): ReactElement => { + const {t} = useTranslation(preferences?.i18n); + const role = useGetRole(roleId); + + return ( + + ); +}; + +export default RolePermissions; diff --git a/packages/react/src/components/admin/UserCreate/BaseUserCreate.tsx b/packages/react/src/components/admin/UserCreate/BaseUserCreate.tsx new file mode 100644 index 00000000..231a97a0 --- /dev/null +++ b/packages/react/src/components/admin/UserCreate/BaseUserCreate.tsx @@ -0,0 +1,203 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {Preferences, SelectOption} from '@thunderid/browser'; +import {ChangeEvent, FC, FormEvent, ReactElement, ReactNode, useState} from 'react'; +import {DEFAULT_USER_CREATE_FIELDS, UserCreateField} from './userCreateFields'; +import useTheme from '../../../contexts/Theme/useTheme'; +import useTranslation from '../../../hooks/useTranslation'; +import Alert from '../../primitives/Alert/Alert'; +import Button from '../../primitives/Button/Button'; +import Select from '../../primitives/Select/Select'; +import TextField from '../../primitives/TextField/TextField'; +import AdminSection from '../shared/AdminSection'; +import useStyles from '../shared/AdminSection.styles'; + +/** + * The user {@link BaseUserCreate} submits. The organization unit is added by the caller. + */ +export interface UserCreateSubmission { + attributes: Record; + type: string; +} + +/** + * What {@link BaseUserCreate} hands its `children` render function, so the form can be built with any UI. + */ +export interface BaseUserCreateRenderProps { + error: string | null; + /** + * The attributes to collect. + */ + fields: UserCreateField[]; + /** + * Sets one attribute. + */ + handleInputChange: (name: string, value: string) => void; + /** + * Submits the chosen type and the non-empty attributes. Does nothing while they are not valid. + */ + handleSubmit: () => void; + /** + * Chooses the user type. + */ + handleTypeChange: (type: string) => void; + isSubmitting: boolean; + /** + * Whether a type is chosen and every required attribute has a value. + */ + isValid: boolean; + onCancel?: () => void; + /** + * The chosen user type. + */ + type: string; + userTypes: SelectOption[]; + /** + * The attribute values, keyed by attribute name. + */ + values: Record; +} + +export interface BaseUserCreateProps { + /** + * Renders custom UI in place of the default form. Receives the fields, the values and their + * setters, the user types, the submit handler and the submission state. + */ + children?: (props: BaseUserCreateRenderProps) => ReactNode; + className?: string; + description?: string; + error?: string | null; + /** + * Attributes to collect. + * @default username, email, given_name, family_name and password + */ + fields?: UserCreateField[]; + isSubmitting?: boolean; + onCancel?: () => void; + onSubmit: (user: UserCreateSubmission) => void; + preferences?: Preferences; + title?: string; + /** + * The user types to choose from. The first one is selected by default. + */ + userTypes: SelectOption[]; +} + +/** + * A form that collects a new user's type and attributes. Holds no data of its own: pass the user + * types and handle the submission. + */ +const BaseUserCreate: FC = ({ + children, + className, + description, + error = null, + fields = DEFAULT_USER_CREATE_FIELDS, + isSubmitting = false, + onCancel, + onSubmit, + preferences, + title, + userTypes, +}: BaseUserCreateProps): ReactElement => { + const {theme, colorScheme} = useTheme(); + const styles: Record = useStyles(theme, colorScheme); + const {t} = useTranslation(preferences?.i18n); + const [chosenType, setType] = useState(''); + const type: string = chosenType || (userTypes[0]?.value ?? ''); + const [values, setValues] = useState>({}); + + const isComplete: boolean = + Boolean(type) && fields.every((field: UserCreateField) => !field.required || Boolean(values[field.name]?.trim())); + + const setValue = (name: string, value: string): void => + setValues((previous: Record) => ({...previous, [name]: value})); + + const submit = (): void => { + if (!isComplete) { + return; + } + + const attributes: Record = Object.fromEntries( + Object.entries(values).filter(([, value]: [string, string]) => value.trim() !== ''), + ); + + onSubmit({attributes, type}); + }; + + const handleSubmit = (event: FormEvent): void => { + event.preventDefault(); + submit(); + }; + + if (children) { + return ( +
+ {children({ + error, + fields, + handleInputChange: setValue, + handleSubmit: submit, + handleTypeChange: setType, + isSubmitting, + isValid: isComplete, + onCancel, + type, + userTypes, + values, + })} +
+ ); + } + + return ( + +
+ {error && ( + + {error} + + )} + handleInputChange('name', event.target.value)} + /> + + + )} + , + ); + + expect(screen.getByRole('button', {name: 'Save'}).disabled).toBe(true); + fireEvent.change(screen.getByLabelText('Group name'), {target: {value: ' Engineering '}}); + fireEvent.click(screen.getByRole('button', {name: 'Save'})); + expect(onSubmit).toHaveBeenCalledWith({description: undefined, name: 'Engineering'}); + }); + + it('defaults a custom agent form to the first agent type', () => { + const onSubmit: Mock = vi.fn(); + let submit: () => void = () => undefined; + + renderWithProviders( + + {({handleInputChange, handleSubmit, values}): ReactNode => { + submit = handleSubmit; + + return ( + handleInputChange('name', event.target.value)} + /> + ); + }} + , + ); + + fireEvent.change(screen.getByLabelText('Agent name'), {target: {value: 'Concierge'}}); + submit(); + expect(onSubmit).toHaveBeenCalledWith({description: undefined, name: 'Concierge', type: 'assistant'}); + }); + + it('hides credentials from custom details and offers no delete for read-only users', () => { + const seen: {attributes?: Record; onDelete?: () => void} = {}; + + renderWithProviders( + + {({attributes, displayName, onDelete}): ReactNode => { + seen.attributes = attributes; + seen.onDelete = onDelete; + + return

{displayName}

; + }} +
, + ); + + expect(screen.getByRole('heading', {name: 'Aiko Tanaka'})).toBeTruthy(); + expect(seen.attributes).toEqual({username: 'aiko'}); + expect(seen.onDelete).toBeUndefined(); + }); + + it('hands custom members UI the remove handler and the actions column', () => { + const onRemove: Mock = vi.fn(); + + renderWithProviders( + + {({columns, members, onRemove: remove}): ReactNode => ( +
+

{columns.map((column: {id: string}) => column.id).join(',')}

+ +
+ )} +
, + ); + + expect(screen.getByText('member,type,actions')).toBeTruthy(); + fireEvent.click(screen.getByRole('button', {name: 'Drop'})); + expect(onRemove).toHaveBeenCalledWith({display: 'Aiko Tanaka', id: 'u-1', type: 'user'}); + }); + + it('hands custom permissions UI only the granted servers, with their names', () => { + renderWithProviders( + + {({permissions, resolveResourceServerName}): ReactNode => ( +
    + {permissions.map((entry: {permissions: string[]; resourceServerId: string}) => ( +
  • {`${resolveResourceServerName(entry.resourceServerId)}: ${entry.permissions.join(' ')}`}
  • + ))} +
+ )} +
, + ); + + expect(screen.getAllByRole('listitem').map((item: HTMLElement) => item.textContent)).toEqual(['Orders API: read']); + }); +}); + +describe('search and filters', () => { + it('searches users on the server by the chosen attribute', async () => { + const fetcher: FetcherMock = vi + .fn() + .mockResolvedValue(respond({count: 0, startIndex: 1, totalResults: 0, users: []})); + + renderWithProviders(, {fetcher}); + + fireEvent.change(await screen.findByRole('combobox', {name: 'Search by'}), {target: {value: 'email'}}); + fireEvent.change(screen.getByRole('searchbox', {name: 'Email'}), {target: {value: ' ada@example.com '}}); + fireEvent.click(screen.getByRole('button', {name: 'Search'})); + + await waitFor(() => { + const url: string = fetcher.mock.calls[fetcher.mock.calls.length - 1][0]; + expect(new URL(url).searchParams.get('filter')).toBe('email eq "ada@example.com"'); + }); + expect(await screen.findByText('No matches for "ada@example.com". Search matches the whole value.')).toBeTruthy(); + + fireEvent.click(screen.getByRole('button', {name: 'Clear search'})); + await waitFor(() => { + const url: string = fetcher.mock.calls[fetcher.mock.calls.length - 1][0]; + expect(new URL(url).searchParams.has('filter')).toBe(false); + }); + }); + + it('hides the user search when a fixed filter is given', () => { + renderWithProviders(, { + fetcher: vi.fn().mockResolvedValue(respond({users: []})), + }); + + expect(screen.queryByRole('search')).toBeNull(); + }); + + it('searches agents by name', async () => { + const fetcher: FetcherMock = vi + .fn() + .mockResolvedValue(respond({agents: [], count: 0, startIndex: 1, totalResults: 0})); + + renderWithProviders(, {fetcher}); + + fireEvent.change(await screen.findByRole('searchbox', {name: 'Name'}), {target: {value: 'Concierge'}}); + fireEvent.click(screen.getByRole('button', {name: 'Search'})); + + await waitFor(() => { + const url: string = fetcher.mock.calls[fetcher.mock.calls.length - 1][0]; + expect(new URL(url).searchParams.get('filter')).toBe('name eq "Concierge"'); + }); + }); + + it('narrows role assignments to one assignee type on the server', async () => { + const fetcher: FetcherMock = vi + .fn() + .mockResolvedValue(respond({assignments: [], count: 0, startIndex: 1, totalResults: 0})); + + renderWithProviders(, {fetcher}); + + fireEvent.change(await screen.findByRole('combobox', {name: 'Assignee type'}), {target: {value: 'group'}}); + + await waitFor(() => { + const url: string = fetcher.mock.calls[fetcher.mock.calls.length - 1][0]; + expect(new URL(url).searchParams.get('type')).toBe('group'); + }); + }); + + it('hands the search to a list render function', () => { + const onSearch: Mock = vi.fn(); + + renderWithProviders( + + {({search, onSearch: apply}): ReactNode => ( + + )} + , + ); + + fireEvent.click(screen.getByRole('button', {name: 'Searching username=aiko'})); + expect(onSearch).toHaveBeenCalledWith(null); + }); +}); diff --git a/packages/react/src/components/admin/shared/AdminListRenderProps.ts b/packages/react/src/components/admin/shared/AdminListRenderProps.ts new file mode 100644 index 00000000..f8650f2a --- /dev/null +++ b/packages/react/src/components/admin/shared/AdminListRenderProps.ts @@ -0,0 +1,48 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {SelectOption} from '@thunderid/browser'; +import {AdminSearchValue} from './AdminSearch'; +import {DataTableColumn} from '../../primitives/DataTable/DataTable'; + +/** + * What an admin list hands its `children` render function, so the rows can be shown with any UI, + * such as a third-party data grid. + */ +export interface AdminListRenderProps { + /** + * The columns the default table shows, with translated headers and cell renderers, to reuse in a + * custom table. + */ + columns: DataTableColumn[]; + error: string | null; + isLoading: boolean; + limit: number; + offset: number; + /** + * Shows the page that starts at `offset`. Undefined when the list is not paginated. + */ + onPageChange?: (offset: number) => void; + /** + * Applies a search, or clears it with `null`. Undefined when the list cannot be searched. + */ + onSearch?: (search: AdminSearchValue | null) => void; + /** + * Chooses a row. Undefined when rows are not selectable. + */ + onSelect?: (row: T) => void; + rows: T[]; + /** + * The search currently applied, if any. + */ + search?: AdminSearchValue | null; + /** + * The attributes that can be searched, with translated labels. + */ + searchAttributes?: SelectOption[]; + selectedRowId?: string; + /** + * Total number of rows on the server, or the number of rows given when unknown. + */ + totalResults: number; +} diff --git a/packages/react/src/components/admin/shared/AdminParts.styles.ts b/packages/react/src/components/admin/shared/AdminParts.styles.ts new file mode 100644 index 00000000..55d5e138 --- /dev/null +++ b/packages/react/src/components/admin/shared/AdminParts.styles.ts @@ -0,0 +1,89 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {Theme} from '@thunderid/browser'; +import {useMemo} from 'react'; +import {css} from '../../../styles/emotion'; + +/** + * Creates the styles for the detail list, chip list and inline confirmation used by the admin + * components. + * + * @param theme - The theme object containing design tokens. + * @param colorScheme - The current color scheme, used for memoization. + * @returns The CSS class names for each part. + */ +const useStyles = (theme: Theme, colorScheme: string): Record => + useMemo(() => { + const unit: string = theme.vars.spacing.unit; + + return { + chip: css` + display: inline-flex; + align-items: center; + gap: calc(${unit} / 2); + padding: calc(${unit} / 2) ${unit}; + border-radius: 999px; + border: 1px solid ${theme.vars.colors.border}; + background: ${theme.vars.colors.background.surface}; + font-size: ${theme.vars.typography.fontSizes.sm}; + color: ${theme.vars.colors.text.primary}; + `, + chipList: css` + display: flex; + flex-wrap: wrap; + gap: ${unit}; + margin: 0; + padding: 0; + list-style: none; + `, + confirm: css` + display: inline-flex; + flex-wrap: wrap; + align-items: center; + gap: ${unit}; + font-size: ${theme.vars.typography.fontSizes.sm}; + `, + detail: css` + display: grid; + grid-template-columns: fit-content(200px) minmax(0, 1fr); + gap: ${unit} calc(${unit} * 2); + margin: 0; + font-size: ${theme.vars.typography.fontSizes.sm}; + `, + detailLabel: css` + color: ${theme.vars.colors.text.secondary}; + `, + detailValue: css` + margin: 0; + color: ${theme.vars.colors.text.primary}; + overflow-wrap: anywhere; + `, + muted: css` + color: ${theme.vars.colors.text.secondary}; + font-size: ${theme.vars.typography.fontSizes.sm}; + `, + panel: css` + display: flex; + flex-direction: column; + gap: calc(${unit} * 1.5); + padding: calc(${unit} * 2); + border: 1px solid ${theme.vars.colors.border}; + border-radius: ${theme.vars.borderRadius.large}; + background: ${theme.vars.colors.background.surface}; + `, + panelTitle: css` + margin: 0; + font-size: ${theme.vars.typography.fontSizes.md}; + font-weight: ${theme.vars.typography.fontWeights.semibold}; + `, + row: css` + display: flex; + flex-wrap: wrap; + align-items: center; + gap: calc(${unit} * 1.5); + `, + }; + }, [theme, colorScheme]); + +export default useStyles; diff --git a/packages/react/src/components/admin/shared/AdminParts.tsx b/packages/react/src/components/admin/shared/AdminParts.tsx new file mode 100644 index 00000000..4ad9c14d --- /dev/null +++ b/packages/react/src/components/admin/shared/AdminParts.tsx @@ -0,0 +1,147 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {FC, ReactElement, ReactNode, useState} from 'react'; +import useAdminPartStyles from './useAdminPartStyles'; +import useTranslation from '../../../hooks/useTranslation'; +import Button from '../../primitives/Button/Button'; + +/** + * One label and value pair of a {@link DetailList}. + */ +export interface DetailListItem { + label: string; + value: ReactNode; +} + +export interface DetailListProps { + items: DetailListItem[]; +} + +/** + * Renders label and value pairs as a description list. + */ +export const DetailList: FC = ({items}: DetailListProps): ReactElement => { + const styles: Record = useAdminPartStyles(); + + return ( +
+ {items.map((item: DetailListItem) => ( +
+
{item.label}
+
{item.value}
+
+ ))} +
+ ); +}; + +export interface PanelProps { + actions?: ReactNode; + children?: ReactNode; + title: string; +} + +/** + * A bordered block with a heading, used to group parts of a details view. + */ +export const Panel: FC = ({actions, children, title}: PanelProps): ReactElement => { + const styles: Record = useAdminPartStyles(); + + return ( +
+
+

{title}

+ {actions} +
+ {children} +
+ ); +}; + +export interface ChipListProps { + /** + * Accessible name of the list, already translated. + */ + label: string; + /** + * Message shown when there are no items, already translated. + */ + emptyText: string; + items: {id: string; label: string; onRemove?: () => void; removeLabel?: string}[]; +} + +/** + * Renders a list of named items, each optionally removable. + */ +export const ChipList: FC = ({emptyText, items, label}: ChipListProps): ReactElement => { + const styles: Record = useAdminPartStyles(); + + if (items.length === 0) { + return {emptyText}; + } + + return ( +
    + {items.map((item: ChipListProps['items'][number]) => ( +
  • + {item.label} + {item.onRemove && ( + + )} +
  • + ))} +
+ ); +}; + +export interface ConfirmButtonProps { + /** + * Label of the button that starts the action, already translated. + */ + children: ReactNode; + /** + * Question asked before the action runs, already translated. + */ + confirmText: string; + disabled?: boolean; + isLoading?: boolean; + onConfirm: () => void; +} + +/** + * A destructive action that asks for confirmation inline before it runs. + */ +export const ConfirmButton: FC = ({ + children, + confirmText, + disabled = false, + isLoading = false, + onConfirm, +}: ConfirmButtonProps): ReactElement => { + const styles: Record = useAdminPartStyles(); + const {t} = useTranslation(); + const [isConfirming, setIsConfirming] = useState(false); + + if (!isConfirming) { + return ( + + ); + } + + return ( + + {confirmText} + + + + ); +}; diff --git a/packages/react/src/components/admin/shared/AdminSearch.tsx b/packages/react/src/components/admin/shared/AdminSearch.tsx new file mode 100644 index 00000000..7cc83313 --- /dev/null +++ b/packages/react/src/components/admin/shared/AdminSearch.tsx @@ -0,0 +1,91 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {SelectOption} from '@thunderid/browser'; +import {ChangeEvent, FormEvent, ReactElement, useState} from 'react'; +import useAdminPartStyles from './useAdminPartStyles'; +import useTranslation from '../../../hooks/useTranslation'; +import Button from '../../primitives/Button/Button'; +import Select from '../../primitives/Select/Select'; +import TextField from '../../primitives/TextField/TextField'; + +/** + * A search an admin list applies on the server: the attribute to match and the value it must equal. + */ +export interface AdminSearchValue { + attribute: string; + value: string; +} + +export interface AdminSearchProps { + /** + * The attributes that can be searched, with translated labels. The attribute select is hidden when + * there is only one. + */ + attributes: SelectOption[]; + /** + * Called with the search to apply when the form is submitted, or `null` when it is cleared. + */ + onSearch: (search: AdminSearchValue | null) => void; + /** + * The search currently applied. + */ + search?: AdminSearchValue | null; +} + +/** + * Searches an admin list by one attribute. The server matches the whole value, so the search runs when + * submitted rather than on every keystroke. + */ +const AdminSearch = ({attributes, onSearch, search = null}: AdminSearchProps): ReactElement => { + const styles: Record = useAdminPartStyles(); + const {t} = useTranslation(); + const [attribute, setAttribute] = useState(search?.attribute ?? attributes[0]?.value ?? ''); + const [value, setValue] = useState(search?.value ?? ''); + const label: string = attributes.find((option: SelectOption) => option.value === attribute)?.label ?? attribute; + + const handleSubmit = (event: FormEvent): void => { + event.preventDefault(); + onSearch(value.trim() ? {attribute, value: value.trim()} : null); + }; + + return ( + + {attributes.length > 1 && ( + ): void => { + setType(event.target.value as TType); + setIdentityId(''); + }} + /> + ) => onChange(event.target.value.trim())} + /> + +); + +type AnyMutation = Pick, 'error' | 'isLoading'>; + +const MutationRow = ({label, mutation}: {label: string; mutation: AnyMutation}): ReactElement => ( + <> +
{label}
+
{mutation.isLoading ? 'running…' : mutation.error ? describeError(mutation.error) : 'idle / ok'}
+ +); + +/** + * The management hooks and `useGrantedPermissions` from @thunderid/nextjs. Every request goes + * through the SDK's management server action, so each card works only with a session; without one + * the server action answers HTTP 401 and the card shows it. + */ +const Hooks = (): ReactElement => { + const sdk = useThunderID(); + const isSignedIn = !!sdk.isSignedIn; + const {flattenedProfile, profile} = useUser(); + const {session} = useServerSession(isSignedIn); + const sub: unknown = session?.claims?.['sub']; + const ouIdValue: unknown = ((flattenedProfile ?? profile ?? {}) as Record)['ouId']; + const ouId: string | undefined = typeof ouIdValue === 'string' ? ouIdValue : undefined; + + const [groupInput, setGroupInput] = useState(''); + const [roleInput, setRoleInput] = useState(''); + const [agentInput, setAgentInput] = useState(''); + const [userInput, setUserInput] = useState(''); + const [required, setRequired] = useState('system:group:view'); + const [createdGroup, setCreatedGroup] = useState(null); + const [createdRole, setCreatedRole] = useState(null); + + // Lists, which also supply the default identifiers for the single-resource hooks. + const groups = useGetGroups({limit: 5}); + const roles = useGetRoles({limit: 5}); + const agents = useGetAgents({limit: 5}); + const userTypes = useGetUserTypes({limit: 10}); + const agentTypes = useGetAgentTypes({limit: 10}); + + const firstGroup: string | undefined = groups.data?.groups[0]?.id; + const firstRole: string | undefined = roles.data?.roles[0]?.id; + const firstAgent: string | undefined = agents.data?.agents[0]?.id; + const groupId: string | undefined = groupInput || firstGroup; + const roleId: string | undefined = roleInput || firstRole; + const agentId: string | undefined = agentInput || firstAgent; + const userId: string | undefined = userInput || (typeof sub === 'string' ? sub : undefined); + + const group = useGetGroup(groupId); + const members = useGetGroupMembers(groupId, {limit: 5}); + const role = useGetRole(roleId); + const assignments = useGetRoleAssignments(roleId, {limit: 5}); + const userGroups = useGetUserGroups(userId); + const agentGroups = useGetAgentGroups(agentId); + const agentRoles = useGetAgentRoles(agentId); + + const granted = useGrantedPermissions(); + const reactGranted = useReactGrantedPermissions(); + + const onError = (name: string) => (error: Error) => logEvent(`${name}:failed`, error.message, 'error'); + + const createGroup = useCreateGroup({ + onError: onError('useCreateGroup'), + onSuccess: (created: {id: string}) => setCreatedGroup(created.id), + }); + const updateGroup = useUpdateGroup({onError: onError('useUpdateGroup')}); + const addMembers = useAddGroupMembers({onError: onError('useAddGroupMembers')}); + const removeMembers = useRemoveGroupMembers({onError: onError('useRemoveGroupMembers')}); + const deleteGroup = useDeleteGroup({onError: onError('useDeleteGroup'), onSuccess: () => setCreatedGroup(null)}); + + const createRole = useCreateRole({ + onError: onError('useCreateRole'), + onSuccess: (created: {id: string}) => setCreatedRole(created.id), + }); + const updateRole = useUpdateRole({onError: onError('useUpdateRole')}); + const addAssignments = useAddRoleAssignments({onError: onError('useAddRoleAssignments')}); + const removeAssignments = useRemoveRoleAssignments({onError: onError('useRemoveRoleAssignments')}); + const deleteRole = useDeleteRole({onError: onError('useDeleteRole'), onSuccess: () => setCreatedRole(null)}); + + const name = (kind: string): string => `playground-${kind}-${Date.now().toString(36)}`; + const me = (type: 'user'): {id: string; type: 'user'}[] => (userId ? [{id: userId, type}] : []); + const writeBlocked: string | null = !isSignedIn + ? 'sign in first' + : !ouId + ? 'the profile has no ouId' + : !userId + ? 'no user id' + : null; + + return ( +
+
+
+

Hooks

+

+ The management hooks and useGrantedPermissions from{' '} + @thunderid/nextjs. Each request runs through the SDK's management + server action with the server-held session; the access token never reaches the browser. +

+
+
+ + {!isSignedIn && ( +
+ {sdk.isLoading + ? 'The SDK is still loading.' + : 'Not signed in: every management hook below fails with HTTP 401 from the server action. Sign in from the Overview page.'} +
+ )} + +
Permissions
+
+ + + + +
+ +
Groups
+
+ + + + + + +
+ + + + + +
+
+ + + + + +
+
+
+ +
Roles
+
+ + + + + + +
+ + + + + +
+
+ + + + + +
+
+
+ +
Users
+
+ + + + +
+ +
Agents
+
+ + + + + + +
+
+ ); +}; + +export default Hooks; diff --git a/playgrounds/nextjs/src/lib/management.ts b/playgrounds/nextjs/src/lib/management.ts new file mode 100644 index 00000000..dd70f11a --- /dev/null +++ b/playgrounds/nextjs/src/lib/management.ts @@ -0,0 +1,43 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +/** + * The management collections the browser may reach through the SDK's management server action, + * keyed by their `endpoints` override key. The paths mirror `MANAGEMENT_COLLECTIONS` in + * `@thunderid/node`, which `@thunderid/nextjs` does not re-export. This module has no imports so + * server and client code can share it. + */ +export const MANAGEMENT_PATHS = { + agentTypes: 'agent-types', + agents: 'agents', + groups: 'groups', + roles: 'roles', + userTypes: 'user-types', + users: 'users', +} as const; + +export type ManagementKey = keyof typeof MANAGEMENT_PATHS; + +export const MANAGEMENT_KEYS: ManagementKey[] = Object.keys(MANAGEMENT_PATHS) as ManagementKey[]; + +/** The management collection keys added with groups and roles management. */ +export const NEW_ENDPOINT_KEYS: ManagementKey[] = ['agentTypes', 'groups', 'roles']; + +export type ManagementOverrides = Partial>; + +/** + * Playground-only variables that override where a management collection lives. The SDK reads no + * environment variable for `endpoints`; the layout passes these to `ThunderIDProvider` instead. + */ +export const ENDPOINT_ENV: Record = { + agentTypes: 'THUNDERID_ENDPOINT_AGENT_TYPES', + agents: 'THUNDERID_ENDPOINT_AGENTS', + groups: 'THUNDERID_ENDPOINT_GROUPS', + roles: 'THUNDERID_ENDPOINT_ROLES', + userTypes: 'THUNDERID_ENDPOINT_USER_TYPES', + users: 'THUNDERID_ENDPOINT_USERS', +}; + +/** Where a collection resolves without an override: `{baseUrl}/{path}`, as the core API functions build it. */ +export const defaultCollectionUrl = (baseUrl: string, key: ManagementKey): string => + `${baseUrl.replace(/\/+$/, '')}/${MANAGEMENT_PATHS[key]}`; diff --git a/playgrounds/nextjs/src/lib/serverConfig.ts b/playgrounds/nextjs/src/lib/serverConfig.ts index a1370a2c..e630ea16 100644 --- a/playgrounds/nextjs/src/lib/serverConfig.ts +++ b/playgrounds/nextjs/src/lib/serverConfig.ts @@ -1,6 +1,8 @@ // Copyright 2026 The ThunderID Authors // SPDX-License-Identifier: Apache-2.0 +import {ENDPOINT_ENV, MANAGEMENT_KEYS, ManagementKey, ManagementOverrides} from './management'; + /** * What the playground is configured with, as the server sees it. The SDK reads the same variables * (`NEXT_PUBLIC_THUNDERID_*`, `THUNDERID_*`) on its own; this summary only reports them, and never @@ -13,6 +15,10 @@ export interface ConfigSummary { baseUrl: string; clientId: string; clientSecretSet: boolean; + /** The `endpoints` overrides the layout hands to `ThunderIDProvider`. */ + endpointOverrides: ManagementOverrides; + /** The `THUNDERID_ENDPOINT_*` variable that set each override. */ + endpointSources: Partial>; flowSecretSet: boolean; scopes: string; sessionSecretSet: boolean; @@ -25,6 +31,22 @@ const read = (key: string): string => process.env[key]?.trim() ?? ''; export const DEFAULT_BASE_URL = 'https://localhost:8090'; +/** + * Builds the management `endpoints` overrides from the playground's per-collection + * `THUNDERID_ENDPOINT_*` variables. Returns `undefined` when nothing is overridden, so the SDK derives `{baseUrl}/{path}`. + */ +export const getManagementEndpointOverrides = (): ManagementOverrides | undefined => { + const overrides: ManagementOverrides = {}; + + MANAGEMENT_KEYS.forEach((key: ManagementKey) => { + const value: string = read(ENDPOINT_ENV[key]); + + if (value) overrides[key] = value.replace(/\/+$/, ''); + }); + + return Object.keys(overrides).length ? overrides : undefined; +}; + export const getConfigSummary = (): ConfigSummary => ({ afterSignInUrl: read('NEXT_PUBLIC_THUNDERID_AFTER_SIGN_IN_URL'), afterSignOutUrl: read('NEXT_PUBLIC_THUNDERID_AFTER_SIGN_OUT_URL'), @@ -32,6 +54,13 @@ export const getConfigSummary = (): ConfigSummary => ({ baseUrl: (read('NEXT_PUBLIC_THUNDERID_BASE_URL') || DEFAULT_BASE_URL).replace(/\/+$/, ''), clientId: read('NEXT_PUBLIC_THUNDERID_CLIENT_ID'), clientSecretSet: !!read('THUNDERID_CLIENT_SECRET'), + endpointOverrides: getManagementEndpointOverrides() ?? {}, + endpointSources: Object.fromEntries( + MANAGEMENT_KEYS.filter((key: ManagementKey) => read(ENDPOINT_ENV[key])).map((key: ManagementKey) => [ + key, + ENDPOINT_ENV[key], + ]), + ), flowSecretSet: !!read('THUNDERID_FLOW_SECRET'), scopes: read('NEXT_PUBLIC_THUNDERID_SCOPES'), sessionSecretSet: !!read('THUNDERID_SECRET'), diff --git a/playgrounds/nextjs/src/overview/Overview.tsx b/playgrounds/nextjs/src/overview/Overview.tsx index 717d1c91..84a7f71e 100644 --- a/playgrounds/nextjs/src/overview/Overview.tsx +++ b/playgrounds/nextjs/src/overview/Overview.tsx @@ -3,8 +3,15 @@ 'use client'; -import {useThunderID} from '@thunderid/nextjs'; -import {useI18n, useTheme, useThunderID as useReactThunderID, useUser} from '@thunderid/react'; +import {resolveResourceEndpoint} from '@thunderid/browser'; +import {useGrantedPermissions, useThunderID} from '@thunderid/nextjs'; +import { + useGrantedPermissions as useReactGrantedPermissions, + useI18n, + useTheme, + useThunderID as useReactThunderID, + useUser, +} from '@thunderid/react'; import {ReactElement, useEffect, useRef, useState} from 'react'; import { CAPABILITIES, @@ -20,6 +27,7 @@ import {Bang, Check, Cross, Dash, Play, Spinner} from '../components/Icons'; import SessionCard from '../components/SessionCard'; import {logEvent} from '../lib/events'; import {useServerHealth} from '../lib/health'; +import {defaultCollectionUrl, MANAGEMENT_KEYS, ManagementKey} from '../lib/management'; import type {ConfigSummary} from '../lib/serverConfig'; interface CapabilityState { @@ -93,6 +101,8 @@ const Overview = (): ReactElement => { const i18n = useI18n(); const theme = useTheme(); const health = useServerHealth(); + const granted = useGrantedPermissions(); + const reactGranted = useReactGrantedPermissions(); const [results, setResults] = useState>({}); const [lastRun, setLastRun] = useState(null); @@ -102,10 +112,10 @@ const Overview = (): ReactElement => { const [now, setNow] = useState(0); const [config, setConfig] = useState(null); - const contextRef = useRef({i18n, reactSdk, sdk, theme, user}); + const contextRef = useRef({granted, i18n, reactGranted, reactSdk, sdk, theme, user}); useEffect(() => { - contextRef.current = {i18n, reactSdk, sdk, theme, user}; + contextRef.current = {granted, i18n, reactGranted, reactSdk, sdk, theme, user}; }); // Results survive the sign-in redirect in sessionStorage. Read them after hydration so the server @@ -216,6 +226,22 @@ const Overview = (): ReactElement => { .join('\n') : 'Loading from the server'; + // The management collections, resolved from the overrides the client provider received, as the + // SDK's hooks resolve them. + const endpointsText: string = config + ? [ + `${'baseUrl'.padEnd(15)}${sdk.baseUrl ?? config.baseUrl}`, + '', + ...MANAGEMENT_KEYS.map((key: ManagementKey) => { + const override: string | undefined = resolveResourceEndpoint(key, {endpoints: reactSdk.endpoints}); + + const source: string | undefined = config.endpointSources[key]; + + return `${key.padEnd(15)}${override ?? defaultCollectionUrl(config.baseUrl, key)}${source ? ` (${source})` : ''}`; + }), + ].join('\n') + : 'Loading from the server'; + return (
@@ -357,6 +383,14 @@ const Overview = (): ReactElement => {
{configText}
+
+
+

Management endpoints

+ endpoints · via server action +
+
{endpointsText}
+
+
diff --git a/playgrounds/nextjs/src/overview/capabilities.ts b/playgrounds/nextjs/src/overview/capabilities.ts index ad005e59..57f0f53c 100644 --- a/playgrounds/nextjs/src/overview/capabilities.ts +++ b/playgrounds/nextjs/src/overview/capabilities.ts @@ -1,8 +1,37 @@ // Copyright 2026 The ThunderID Authors // SPDX-License-Identifier: Apache-2.0 -import {EmbeddedFlowType} from '@thunderid/browser'; +import { + addGroupMembers, + addRoleAssignments, + ApiFetcher, + createGroup, + createRole, + deleteGroup, + deleteRole, + EmbeddedFlowType, + getAgentGroups, + getAgentRoles, + getAgents, + getAgentTypes, + getGroupMembers, + getGroups, + getRole, + getRoleAssignments, + getRoles, + getTokenPermissions, + getUserGroups, + getUserTypes, + hasPermission, + ManagementRequestConfig, + removeGroupMembers, + removeRoleAssignments, + resolveResourceEndpoint, + updateGroup, + updateRole, +} from '@thunderid/browser'; import type { + GrantedPermissions, I18nContextValue, ThemeContextValue, ThunderIDContextProps as ReactContextProps, @@ -13,6 +42,7 @@ import {COMPONENTS, STORY_COUNT} from '../explorer/stories'; import {describeError, track} from '../lib/events'; import {checkServerHealth} from '../lib/health'; import {formatDuration} from '../lib/jwt'; +import {defaultCollectionUrl, MANAGEMENT_KEYS, ManagementKey, NEW_ENDPOINT_KEYS} from '../lib/management'; import {asText, NextSdk, sdkSignIn, sdkSignOut, sdkSignUp, toUser} from '../lib/sdk'; import type {ServerSession} from '../lib/serverSession'; import {getDisplayName} from '../lib/user'; @@ -25,7 +55,11 @@ export interface CapabilityResult { } export interface CapabilityContext { + /** `useGrantedPermissions()` from @thunderid/nextjs: the list comes from a server action. */ + granted: GrantedPermissions; i18n: I18nContextValue; + /** `useGrantedPermissions()` from @thunderid/react, which reads the token in the browser. */ + reactGranted: GrantedPermissions; /** The @thunderid/react context that @thunderid/nextjs bridges for the components it reuses. */ reactSdk: ReactContextProps; sdk: NextSdk; @@ -107,6 +141,111 @@ const getJson = async (path: string, init: RequestInit = {}): Promise<{body: unk return {body, response}; }; +/** + * The inputs a core management API function needs, resolved the way the SDK's hooks resolve them: + * the `endpoints` override the Next.js provider received, or `baseUrl`, and the provider's + * `http.fetcher`, which sends the request through the SDK's management server action. + */ +const managementConfig = (context: CapabilityContext, key: ManagementKey): ManagementRequestConfig => { + const fetcher: ApiFetcher | undefined = context.reactSdk.http?.fetcher; + + if (!fetcher) throw new Error('the provider installed no http.fetcher: management requests would leave the browser'); + + return { + baseUrl: context.reactSdk.baseUrl ?? context.sdk.baseUrl, + fetcher, + url: resolveResourceEndpoint(key, {endpoints: context.reactSdk.endpoints}), + }; +}; + +/** + * Turns a management failure into a reason. The server action answers with a status and a message + * it built itself, so the status says what went wrong. + */ +const managementFailure = (error: unknown): CapabilityResult => { + const status: unknown = (error as {statusCode?: unknown})?.statusCode; + + switch (status) { + case 401: + return fail('HTTP 401 from the management server action: no session, sign in first'); + case 403: + return fail('HTTP 403: the access token lacks the permission for this collection'); + case 400: + return fail('HTTP 400: the server action refused the URL (not a configured management collection)'); + case 502: + return fail('HTTP 502: the Next.js server could not reach the management API'); + default: + return fail(describeError(error)); + } +}; + +/** Runs a management check, mapping a rejected request onto a reason. */ +const management = async (run: () => Promise): Promise => { + try { + return await run(); + } catch (error) { + return managementFailure(error); + } +}; + +const signedInSubject = async (): Promise => { + const session: ServerSession = await readServerSession(); + const sub: unknown = session.claims?.['sub']; + + return typeof sub === 'string' ? sub : null; +}; + +const profileOuId = (context: CapabilityContext): string | null => { + const source: Record = (context.user.flattenedProfile ?? context.user.profile ?? {}) as Record< + string, + unknown + >; + + return typeof source['ouId'] === 'string' ? source['ouId'] : null; +}; + +interface CapturedRequest { + hasAuthorization: boolean; + isServerAction: boolean; + method: string; + url: URL; +} + +/** + * Records every `fetch` the page makes while `run` is pending, so a check can tell a request the + * browser sent to ThunderID from a Next.js server action call (a POST carrying `Next-Action`). + */ +const captureFetches = async ( + run: () => Promise, +): Promise<{captured: CapturedRequest[]; error: unknown; value: T | undefined}> => { + const original: typeof fetch = window.fetch; + const captured: CapturedRequest[] = []; + + window.fetch = (input: RequestInfo | URL, init?: RequestInit): Promise => { + const raw: string = input instanceof Request ? input.url : String(input); + const headers: Headers = new Headers(input instanceof Request ? input.headers : init?.headers); + + captured.push({ + hasAuthorization: headers.has('authorization'), + isServerAction: headers.has('next-action'), + method: (init?.method ?? (input instanceof Request ? input.method : 'GET')).toUpperCase(), + url: new URL(raw, window.location.href), + }); + + return original(input, init); + }; + + try { + return {captured, error: null, value: await run()}; + } catch (error) { + return {captured, error, value: undefined}; + } finally { + window.fetch = original; + } +}; + +const temporaryName = (kind: string): string => `playground-${kind}-${Date.now().toString(36)}`; + export const CAPABILITIES: Capability[] = [ { api: 'server action · GET /health/liveness', @@ -488,6 +627,355 @@ export const CAPABILITIES: Capability[] = [ run: () => unavailable('@thunderid/nextjs has no organization API'), section: 'User', }, + { + api: 'endpoints.{groups, roles, agentTypes} · resolveResourceEndpoint()', + id: 'mgmtEndpoints', + name: 'Management endpoints', + run: async (getContext: () => CapabilityContext) => { + const config = await readConfig(); + const {reactSdk} = getContext(); + const received: Record = (reactSdk.endpoints ?? {}) as Record; + // The server provider hands the browser only the management overrides it was configured with. + const mismatched: string[] = MANAGEMENT_KEYS.filter( + (key: ManagementKey) => (received[key] ?? undefined) !== config.endpointOverrides[key], + ); + + if (mismatched.length) { + return fail(`the client context's endpoints differ from the server's for ${mismatched.join(', ')}`); + } + + const leaked: string[] = Object.keys(received).filter( + (key: string) => !MANAGEMENT_KEYS.includes(key as ManagementKey), + ); + + if (leaked.length) return fail(`non-management overrides reached the browser: ${leaked.join(', ')}`); + + // An explicit per-call URL wins over the override, and no override falls back to baseUrl. + const explicit: string | undefined = resolveResourceEndpoint( + 'roles', + {endpoints: {roles: 'https://api.example/roles'}}, + 'https://call.example/roles', + ); + + if (explicit !== 'https://call.example/roles') return fail('an explicit URL did not win over the override'); + + const resolved: string[] = NEW_ENDPOINT_KEYS.map((key: ManagementKey) => { + const override: string | undefined = resolveResourceEndpoint(key, {endpoints: reactSdk.endpoints}); + + return `${key} → ${override ?? defaultCollectionUrl(config.baseUrl, key)}${override ? ' (override)' : ''}`; + }); + + return pass(resolved.join(', ')); + }, + section: 'Management', + }, + { + api: 'ThunderIDProvider http.fetcher → managementRequestAction', + id: 'mgmtRelay', + name: 'Management via server action', + run: async (getContext: () => CapabilityContext) => { + const context: CapabilityContext = getContext(); + const config: ManagementRequestConfig = managementConfig(context, 'groups'); + const target: URL = new URL(config.url ?? defaultCollectionUrl(config.baseUrl ?? '', 'groups')); + const {captured, error} = await track( + 'getGroups (relay)', + () => captureFetches(() => getGroups({...config, limit: 1})), + ({captured: requests}: {captured: CapturedRequest[]}) => `${requests.length} browser requests`, + ); + const direct: CapturedRequest[] = captured.filter( + (request: CapturedRequest) => request.url.origin === target.origin, + ); + const actions: CapturedRequest[] = captured.filter( + (request: CapturedRequest) => request.isServerAction && request.url.origin === window.location.origin, + ); + + if (direct.length) return fail(`the browser called ${target.origin} directly (${direct.length} requests)`); + if (!actions.length) return fail('no server action request was observed for the management call'); + if (actions.some((request: CapturedRequest) => request.hasAuthorization)) { + return fail('the server action request carried an Authorization header from the browser'); + } + + const status: unknown = (error as {statusCode?: unknown})?.statusCode; + const outcome: string = error ? `HTTP ${asText(status) || 'error'}` : 'HTTP 200'; + + return pass( + `${actions.length} Next-Action POST to ${window.location.origin}, none to ${target.origin}, no token in the browser; ${outcome}`, + ); + }, + section: 'Management', + }, + { + api: 'getGroups() · getGroupMembers()', + id: 'groups', + name: 'Groups', + run: (getContext: () => CapabilityContext) => + management(async () => { + const config: ManagementRequestConfig = managementConfig(getContext(), 'groups'); + const page = await track('getGroups', () => getGroups({...config, limit: 5})); + const first = page.groups[0]; + + if (!first) return pass(`${page.totalResults} groups`); + + const members = await track('getGroupMembers', () => getGroupMembers({...config, groupId: first.id, limit: 5})); + + return pass(`${page.totalResults} groups; "${first.name}" has ${members.totalResults} members`); + }), + section: 'Management', + }, + { + api: 'getRoles() · getRole() · getRoleAssignments()', + id: 'roles', + name: 'Roles', + run: (getContext: () => CapabilityContext) => + management(async () => { + const config: ManagementRequestConfig = managementConfig(getContext(), 'roles'); + const page = await track('getRoles', () => getRoles({...config, limit: 5})); + const first = page.roles[0]; + + if (!first) return pass(`${page.totalResults} roles`); + + const role = await track('getRole', () => getRole({...config, roleId: first.id})); + const assignments = await track('getRoleAssignments', () => + getRoleAssignments({...config, limit: 5, roleId: first.id}), + ); + const permissions: number = role.permissions.reduce( + (total: number, entry: {permissions: string[]}) => total + entry.permissions.length, + 0, + ); + + return pass( + `${page.totalResults} roles; "${role.name}" grants ${permissions} permissions, ${assignments.totalResults} assignments`, + ); + }), + section: 'Management', + }, + { + api: 'getAgentTypes()', + id: 'agentTypes', + name: 'Agent types', + run: (getContext: () => CapabilityContext) => + management(async () => { + const page = await track('getAgentTypes', () => + getAgentTypes({...managementConfig(getContext(), 'agentTypes'), limit: 10}), + ); + + return pass( + `${page.totalResults} agent types${page.types.length ? `: ${page.types.map((type: {handle: string}) => type.handle).join(', ')}` : ''}`, + ); + }), + section: 'Management', + }, + { + api: 'getAgents() · getAgentGroups() · getAgentRoles()', + id: 'agentMemberships', + name: 'Agent groups and roles', + run: (getContext: () => CapabilityContext) => + management(async () => { + const config: ManagementRequestConfig = managementConfig(getContext(), 'agents'); + const page = await track('getAgents', () => getAgents({...config, limit: 1})); + const agent = page.agents[0]; + + if (!agent) return skip('no agents to read: create one in Components > AgentCreate'); + + const groups = await track('getAgentGroups', () => getAgentGroups({...config, agentId: agent.id})); + const roles = await track('getAgentRoles', () => getAgentRoles({...config, agentId: agent.id})); + + return pass(`"${agent.name}": ${groups.totalResults} groups, roles [${roles.roles.join(', ')}]`); + }), + section: 'Management', + }, + { + api: 'getUserGroups(sub)', + id: 'userGroups', + name: 'User groups', + run: (getContext: () => CapabilityContext) => + management(async () => { + const config: ManagementRequestConfig = managementConfig(getContext(), 'users'); + const userId: string | null = await signedInSubject(); + // Without a session there is no user to ask about; send the request anyway so the reason + // comes from the server action. + const groups = await track('getUserGroups', () => getUserGroups({...config, userId: userId ?? 'me'})); + + return pass( + `${groups.totalResults} groups${groups.groups.length ? `: ${groups.groups.map((group: {name: string}) => group.name).join(', ')}` : ''}`, + ); + }), + section: 'Management', + }, + { + api: 'getUserTypes()', + id: 'userTypes', + name: 'User types', + run: (getContext: () => CapabilityContext) => + management(async () => { + const page = await track('getUserTypes', () => + getUserTypes({...managementConfig(getContext(), 'userTypes'), limit: 10}), + ); + + return pass( + `${page.totalResults} user types${page.types.length ? `: ${page.types.map((type: {handle: string}) => type.handle).join(', ')}` : ''}`, + ); + }), + section: 'Management', + }, + { + api: 'createGroup() · updateGroup() · add/removeGroupMembers() · deleteGroup()', + id: 'groupWrite', + name: 'Group lifecycle', + run: async (getContext: () => CapabilityContext, {interactive}: RunOptions) => { + if (!interactive) return skip('writes data: press Run to create and delete a temporary group'); + + return management(async () => { + const context: CapabilityContext = getContext(); + const config: ManagementRequestConfig = managementConfig(context, 'groups'); + const ouId: string | null = profileOuId(context); + const userId: string | null = await signedInSubject(); + + if (!ouId || !userId) return fail('sign in first: the group needs your ouId and user id'); + + const group = await track('createGroup', () => + createGroup({...config, payload: {name: temporaryName('group'), ouId}}), + ); + + try { + await track('updateGroup', () => + updateGroup({...config, groupId: group.id, payload: {description: 'updated', name: group.name, ouId}}), + ); + await track('addGroupMembers', () => + addGroupMembers({...config, groupId: group.id, members: [{id: userId, type: 'user'}]}), + ); + await track('removeGroupMembers', () => + removeGroupMembers({...config, groupId: group.id, members: [{id: userId, type: 'user'}]}), + ); + } finally { + await track('deleteGroup', () => deleteGroup({...config, groupId: group.id})); + } + + return pass(`created, renamed, added and removed you, and deleted ${group.name}`); + }); + }, + section: 'Management', + }, + { + api: 'createRole() · updateRole() · add/removeRoleAssignments() · deleteRole()', + id: 'roleWrite', + name: 'Role lifecycle', + run: async (getContext: () => CapabilityContext, {interactive}: RunOptions) => { + if (!interactive) return skip('writes data: press Run to create and delete a temporary role'); + + return management(async () => { + const context: CapabilityContext = getContext(); + const config: ManagementRequestConfig = managementConfig(context, 'roles'); + const ouId: string | null = profileOuId(context); + const userId: string | null = await signedInSubject(); + + if (!ouId || !userId) return fail('sign in first: the role needs your ouId and user id'); + + const role = await track('createRole', () => + createRole({...config, payload: {name: temporaryName('role'), ouId}}), + ); + + try { + await track('updateRole', () => + updateRole({ + ...config, + payload: {description: 'updated', name: role.name, ouId, permissions: []}, + roleId: role.id, + }), + ); + await track('addRoleAssignments', () => + addRoleAssignments({...config, assignments: [{id: userId, type: 'user'}], roleId: role.id}), + ); + await track('removeRoleAssignments', () => + removeRoleAssignments({...config, assignments: [{id: userId, type: 'user'}], roleId: role.id}), + ); + } finally { + await track('deleteRole', () => deleteRole({...config, roleId: role.id})); + } + + return pass(`created, updated, assigned and unassigned you, and deleted ${role.name}`); + }); + }, + section: 'Management', + }, + { + api: 'useGrantedPermissions() · getGrantedPermissionsAction', + id: 'grantedServer', + name: 'Granted permissions (server)', + run: async (getContext: () => CapabilityContext) => { + if (!getContext().sdk.isSignedIn) { + return fail('sign in first: without a session the server action returns no permissions'); + } + + await waitFor(getContext, (context: CapabilityContext) => !context.granted.isLoading, 8000); + + const {granted} = getContext(); + const session: ServerSession = await readServerSession(); + const fromToken: string[] = getTokenPermissions(session.accessToken ?? undefined); + const same: boolean = + fromToken.length === granted.permissions.length && + fromToken.every((permission: string) => granted.permissions.includes(permission)); + + if (!same) { + return fail( + `hook has ${granted.permissions.length} permissions, the token's scope claim has ${fromToken.length}`, + ); + } + + return pass( + `${granted.permissions.length} permissions from the server action, same as the token's claim; can('system:group:view')=${granted.can('system:group:view')}`, + ); + }, + section: 'Permissions', + }, + { + api: '@thunderid/react useGrantedPermissions() under Next.js', + id: 'grantedClient', + name: 'Granted permissions (client token)', + run: async (getContext: () => CapabilityContext) => { + if (!getContext().sdk.isSignedIn) return fail('sign in first: the hook returns [] without a session'); + + await waitFor(getContext, (context: CapabilityContext) => !context.reactGranted.isLoading, 8000); + + const {reactGranted} = getContext(); + + return reactGranted.permissions.length + ? fail(`the browser read ${reactGranted.permissions.length} permissions from a token it should not hold`) + : pass('[] as designed: the token stays on the server, so use the @thunderid/nextjs hook'); + }, + section: 'Permissions', + }, + { + api: 'hasPermission() · getTokenPermissions()', + id: 'hasPermission', + name: 'Permission helpers', + run: () => { + const payload: string = btoa(JSON.stringify({scope: 'system:user openid'})) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, ''); + const permissions: string[] = getTokenPermissions(`e30.${payload}.sig`); + const cases: [string, boolean][] = [ + ['system:user', true], + ['system:user:view', true], + ['system:users', false], + ['system:group:view', false], + ['', true], + ]; + const wrong: string[] = cases + .filter(([required, expected]: [string, boolean]) => hasPermission(permissions, required) !== expected) + .map(([required]: [string, boolean]) => required || "''"); + + if (permissions.join(' ') !== 'system:user openid') { + return fail(`getTokenPermissions read [${permissions.join(', ')}]`); + } + + return wrong.length + ? fail(`hasPermission() got ${wrong.join(', ')} wrong`) + : pass(`[${permissions.join(', ')}] covers system:user:view, not system:users`); + }, + section: 'Permissions', + }, { api: 'useI18n() · preferences.i18n', id: 'i18n', diff --git a/playgrounds/node/.env.example b/playgrounds/node/.env.example index 56ef5510..d9da5e46 100644 --- a/playgrounds/node/.env.example +++ b/playgrounds/node/.env.example @@ -23,6 +23,18 @@ THUNDERID_APPLICATION_ID= THUNDERID_SERVICE_CLIENT_ID= THUNDERID_SERVICE_CLIENT_SECRET= +# Optional: per-collection management endpoint overrides (users, user types, groups, roles, agents, +# agent types, applications, flows), each a full collection URL. Unset, each collection resolves to +# THUNDERID_BASE_URL/{collection}. +# THUNDERID_ENDPOINT_USERS= +# THUNDERID_ENDPOINT_USER_TYPES= +# THUNDERID_ENDPOINT_GROUPS= +# THUNDERID_ENDPOINT_ROLES= +# THUNDERID_ENDPOINT_AGENTS= +# THUNDERID_ENDPOINT_AGENT_TYPES= +# THUNDERID_ENDPOINT_APPLICATIONS= +# THUNDERID_ENDPOINT_FLOWS= + # Where the playground listens. PORT=3003 APP_URL=http://localhost:3003 diff --git a/playgrounds/node/README.md b/playgrounds/node/README.md index ea191fc4..ff1c1528 100644 --- a/playgrounds/node/README.md +++ b/playgrounds/node/README.md @@ -5,14 +5,15 @@ that holds a `ThunderIDNodeClient`, and serves an operations explorer for runnin ## What it covers -| Group | Operations | -| -------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | -| Discovery | `getOpenIDProviderEndpoints()`, `getDiscoveryResponse()`, `getConfiguration()` | -| Sign-in | `getSignInUrl()`, `signIn()` (start, and code exchange), `getOBOSignInURL()`, `initiateCIBA()`, `pollCIBA()` | -| Session | `isSignedIn()`, `getUser()`, `getIdToken()`, `getDecodedIdToken()`, `getAccessToken()`, `decodeJwtToken()`, `clearSession()` | -| Tokens | `refreshAccessToken()`, `revokeAccessToken()`, `exchangeToken()`, client credentials (`getAccessToken()` on a `client_credentials` client) | -| Sign-out | `signOut()`, `handleBackchannelLogout()` | -| Management API | `getUsers()`, `getFlows()`, `getUserTypes()`, `updateUserCredentials()` | +| Group | Operations | +| ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Discovery | resolved resource endpoints (`resolveResourceEndpoint()` on `getConfiguration()`), `getOpenIDProviderEndpoints()`, `getDiscoveryResponse()`, `getConfiguration()` | +| Sign-in | `getSignInUrl()`, `signIn()` (start, and code exchange), `getOBOSignInURL()`, `initiateCIBA()`, `pollCIBA()` | +| Session | `isSignedIn()`, `getUser()`, `getIdToken()`, `getDecodedIdToken()`, `getAccessToken()`, `decodeJwtToken()`, `clearSession()` | +| Tokens | `refreshAccessToken()`, `revokeAccessToken()`, `exchangeToken()`, client credentials (`getAccessToken()` on a `client_credentials` client) | +| Sign-out | `signOut()`, `handleBackchannelLogout()` | +| Management API | `getUsers()`, `getFlows()`, `getUserTypes()`, `updateUserCredentials()` | +| Management client | `client.management(sessionId)`: `groups.list/get/listMembers`, `roles.list/get/listAssignments`, `agentTypes.list`, `agents.listGroups/listRoles`, `users.listGroups`, and a permission-gated `groups.list()` behind `hasPermission(getTokenPermissions(token), permission)` | `signInSilently()`, `signUp()`, `recover()`, `getUserProfile()` and `updateUserProfile()` are listed as not available: `ThunderIDNodeClient` does not implement them. @@ -27,7 +28,14 @@ The operations explorer at the playground's root lets you: - read what the call resolved to, or the error it rejected with; - open the **HTTP** tab for the requests the SDK sent to ThunderID, with their responses; - follow the **SDK trace**: the client methods the call went through, its requests and the SDK's own log lines; -- copy the call as code and read the operation's source. +- copy the call as code and read the operation's source; +- read the **resolved endpoints** in the sidebar: where each management collection and each sign-in and self-service + endpoint resolves for the client, and which ones an override set. + +Management client operations run as a signed-in session. Run as a new session, they reject with +`NODE-MANAGEMENT-RC-NF01` before any request; a token without the permission gets the server's 403 as +`-ForbiddenError-001`. The trace shows `client.management()`, then `management..()` with the +`getAccessToken()` the SDK reads and the request it sends. ## Run it against a local ThunderID server @@ -64,6 +72,23 @@ restart. Without a ThunderID server, the explorer still runs: the top bar shows ThunderID as unreachable and the operations that call it reject with the SDK's errors, which the trace shows. +## Endpoint overrides + +| Variable | Purpose | +| -------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | +| `THUNDERID_ENDPOINT_` | Optional. One collection URL each. `` is `USERS`, `USER_TYPES`, `GROUPS`, `ROLES`, `AGENTS`, `AGENT_TYPES`, `APPLICATIONS`, or `FLOWS`. | + +They become `endpoints` in `client.initialize()`. Unset, each collection resolves to `THUNDERID_BASE_URL/`. + +## Known SDK gaps + +- The SDK exports no table of default resource paths. `MANAGEMENT_COLLECTIONS` covers six collections but not + `applications` or `flows`, and there is none for `flowExecute`, `flowMeta` or the `usersMe*` endpoints, so + `src/endpoints.ts` lists them itself. +- There is no client method for the permissions a session holds: the permission-gated operation combines + `getAccessToken()` with `getTokenPermissions()`, which reads nothing from an opaque token. +- The management client is a plain object built per `management()` call, so the trace wraps what it returns. + ## How the trace works `@thunderid/node` emits no debug events, so the playground assembles the trace itself (`src/trace.ts`): it routes the diff --git a/playgrounds/node/public/app.js b/playgrounds/node/public/app.js index 659fa6bf..a54e86a0 100644 --- a/playgrounds/node/public/app.js +++ b/playgrounds/node/public/app.js @@ -1,7 +1,21 @@ // Copyright 2026 The ThunderID Authors // SPDX-License-Identifier: Apache-2.0 -const GROUPS = ['Discovery', 'Sign-in', 'Session', 'Tokens', 'Sign-out', 'Management API', 'Not available']; +const GROUPS = [ + 'Discovery', + 'Sign-in', + 'Session', + 'Tokens', + 'Sign-out', + 'Management API', + 'Management client', + 'Not available', +]; + +const ENDPOINT_GROUPS = [ + ['management', 'Management API'], + ['selfService', 'Sign-in and self-service'], +]; const state = { codeCopied: false, @@ -399,12 +413,49 @@ const selectOperation = (id) => { renderResponse(); }; +const renderEndpoints = () => { + const {endpoints} = state.meta; + const shortUrl = (url) => { + try { + const parsed = new URL(url); + return `${parsed.host}${parsed.pathname}`; + } catch { + return url; + } + }; + const overrides = endpoints.filter((endpoint) => endpoint.source === 'override').length; + $('endpoints-note').textContent = overrides + ? `${overrides} endpoint${overrides === 1 ? '' : 's'} set by THUNDERID_ENDPOINT_; the rest resolve to {baseUrl}/{path}.` + : 'No overrides: every collection resolves to {baseUrl}/{path}. See .env.example.'; + $('endpoint-groups').replaceChildren( + ...ENDPOINT_GROUPS.map(([group, label]) => + el('div', {className: 'group'}, [ + el('div', {className: 'group-name', text: label}), + el( + 'dl', + {className: 'endpoint-list'}, + endpoints + .filter((endpoint) => endpoint.group === group) + .flatMap((endpoint) => [ + el('dt', {}, [ + document.createTextNode(endpoint.key), + endpoint.source === 'override' ? el('span', {className: 'override', text: 'override'}) : '', + ]), + el('dd', {text: shortUrl(endpoint.url), title: endpoint.url}), + ]), + ), + ]), + ), + ); +}; + const loadMeta = async () => { const response = await fetch('/_playground/meta'); state.meta = await response.json(); const session = state.meta.session; if (state.identity === 'expired' && !(session.present && session.hasTokens)) state.identity = 'session'; renderTopbar(); + renderEndpoints(); }; const send = async () => { diff --git a/playgrounds/node/public/index.html b/playgrounds/node/public/index.html index 4f725be2..f61de271 100644 --- a/playgrounds/node/public/index.html +++ b/playgrounds/node/public/index.html @@ -57,6 +57,13 @@ />
+
+
+
Resolved endpoints
+

+
+
+
diff --git a/playgrounds/node/public/styles.css b/playgrounds/node/public/styles.css index 3561b03d..0fa506bc 100644 --- a/playgrounds/node/public/styles.css +++ b/playgrounds/node/public/styles.css @@ -340,6 +340,47 @@ code, text-overflow: ellipsis; } +#endpoint-groups { + display: flex; + flex-direction: column; + gap: 12px; +} + +.endpoints { + padding-top: 12px; + border-top: 1px solid var(--line); +} + +.endpoint-list { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 4px 10px; + padding: 0 6px; + font: 400 11.5px/1.4 var(--mono); +} + +.endpoint-list dt { + display: flex; + align-items: center; + gap: 6px; + color: rgba(255, 255, 255, 0.6); +} + +.endpoint-list dd { + color: var(--code); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.endpoint-list .override { + padding: 0 5px; + border-radius: 4px; + font-size: 10px; + color: var(--warn); + background: rgba(245, 158, 11, 0.12); +} + .lock { margin-left: auto; flex: none; diff --git a/playgrounds/node/src/catalog.ts b/playgrounds/node/src/catalog.ts index f148e2d6..e4b86333 100644 --- a/playgrounds/node/src/catalog.ts +++ b/playgrounds/node/src/catalog.ts @@ -17,6 +17,7 @@ export type OperationGroup = | 'Tokens' | 'Sign-out' | 'Management API' + | 'Management client' | 'Not available'; export interface OperationSpec { @@ -55,7 +56,46 @@ const json = (value: unknown): string => JSON.stringify(value, null, 2); const NOT_IMPLEMENTED = 'ThunderIDNodeClient inherits this method from ThunderIDJavaScriptClient without implementing it, so it throws "Method not implemented."'; +const page = (): OperationParam[] => [param('limit', '10', 'number'), param('offset', '0', 'number')]; + +const MANAGEMENT_NOTE = + 'Sent with the session access token, which needs the permission ThunderID asks for. The collection URL comes from the call, then `endpoints` in the config, then {baseUrl}/{collection}: see the resolved endpoints in the sidebar.'; + +/** An operation of the session-bound management client, `client.management(sessionId)`. */ +const management = ( + id: string, + call: string, + returns: string, + summary: string, + description: string, + params: OperationParam[] = page(), +): OperationSpec => ({ + call: `client.management(sessionId).${call}`, + description: `${description} ${MANAGEMENT_NOTE}`, + group: 'Management client', + id, + network: true, + params, + regions: ['management', id], + returns, + session: true, + summary, +}); + const operations: OperationSpec[] = [ + { + call: 'resolveResourceEndpoint(key, await client.getConfiguration())', + description: + 'Where each management and sign-in resource endpoint resolves for this client: the `endpoints` override from the config, else {baseUrl}/{path}. Set a THUNDERID_ENDPOINT_ variable in .env to override one. The sidebar shows the same list.', + group: 'Discovery', + id: 'resolveEndpoints', + network: false, + params: [], + regions: ['setup', 'resolveEndpoints'], + returns: 'ResolvedEndpoint[]', + session: false, + summary: 'Resolved resource endpoints.', + }, { call: 'client.getOpenIDProviderEndpoints()', description: @@ -409,6 +449,91 @@ const operations: OperationSpec[] = [ session: true, summary: 'Update own credentials.', }, + management( + 'listGroups', + 'groups.list({limit, offset})', + 'Promise', + 'List groups.', + 'One page of groups.', + ), + management( + 'getGroup', + 'groups.get(groupId)', + 'Promise', + 'Get a group.', + 'One group. An unknown id rejects with getGroup-NotFoundError-001.', + [param('groupId', '', 'string', true)], + ), + management( + 'listGroupMembers', + 'groups.listMembers(groupId, {limit, offset})', + 'Promise', + 'List group members.', + 'The users, agents and groups in a group.', + [param('groupId', '', 'string', true), ...page()], + ), + management( + 'listRoles', + 'roles.list({limit, offset})', + 'Promise', + 'List roles.', + 'One page of roles.', + ), + management('getRole', 'roles.get(roleId)', 'Promise', 'Get a role.', 'One role, with its permissions.', [ + param('roleId', '', 'string', true), + ]), + management( + 'listRoleAssignments', + 'roles.listAssignments(roleId, {limit, offset, type})', + 'Promise', + 'List role assignments.', + 'Who holds a role. type is one of user, group, app or agent; empty lists all.', + [param('roleId', '', 'string', true), param('type', '', 'user | group | app | agent'), ...page()], + ), + management( + 'listAgentTypes', + 'agentTypes.list({limit, offset})', + 'Promise', + 'List agent types.', + 'One page of agent types.', + ), + management( + 'listAgentGroups', + 'agents.listGroups(agentId, {limit, offset})', + 'Promise', + "List an agent's groups.", + 'The groups an agent belongs to.', + [param('agentId', '', 'string', true), ...page()], + ), + management( + 'listAgentRoles', + 'agents.listRoles(agentId, {limit, offset})', + 'Promise', + "List an agent's roles.", + 'The roles assigned to an agent.', + [param('agentId', '', 'string', true), ...page()], + ), + management( + 'listUserGroups', + 'users.listGroups(userId, {limit, offset})', + 'Promise', + "List a user's groups.", + 'The groups a user belongs to. Your own user id is the sub claim of getDecodedIdToken().', + [param('userId', '', 'string', true), ...page()], + ), + { + call: 'hasPermission(getTokenPermissions(await client.getAccessToken(sessionId)), permission)', + description: + 'Checks the session access token for a permission before calling groups.list(), the way an app gates an admin page. A granted parent permission (system, system:group) covers system:group:view. Resolves {allowed: false, granted, required} without calling ThunderID when the token lacks it.', + group: 'Management client', + id: 'permissionGatedGroups', + network: true, + params: [param('permission', 'system:group:view', 'string', true), ...page()], + regions: ['permissionGatedGroups'], + returns: 'Promise<{allowed: boolean, ...}>', + session: true, + summary: 'Permission-gated call.', + }, ...['signInSilently', 'signUp', 'recover', 'getUserProfile', 'updateUserProfile'].map( (name: string): OperationSpec => ({ call: `client.${name}()`, diff --git a/playgrounds/node/src/client.ts b/playgrounds/node/src/client.ts index aa214370..c76d7914 100644 --- a/playgrounds/node/src/client.ts +++ b/playgrounds/node/src/client.ts @@ -16,6 +16,9 @@ await client.initialize({ baseUrl: config.baseUrl, clientId: config.clientId, clientSecret: config.clientSecret, + // Per-collection management endpoint overrides (see .env.example). Unset keys resolve to + // {baseUrl}/{collection}. + endpoints: config.endpoints, scopes: config.scopes, }); // #endregion diff --git a/playgrounds/node/src/config.ts b/playgrounds/node/src/config.ts index 5b6f6fa8..4cb0bea7 100644 --- a/playgrounds/node/src/config.ts +++ b/playgrounds/node/src/config.ts @@ -1,11 +1,40 @@ // Copyright 2026 The ThunderID Authors // SPDX-License-Identifier: Apache-2.0 +import {MANAGEMENT_COLLECTIONS} from '@thunderid/node'; + const env = (name: string): string | undefined => { const value: string | undefined = process.env[name]?.trim(); return value === '' ? undefined : value; }; +/** THUNDERID_ENDPOINT_ names, for example agentTypes -> THUNDERID_ENDPOINT_AGENT_TYPES. */ +export const endpointEnvName = (key: string): string => + `THUNDERID_ENDPOINT_${key.replace(/[A-Z]/g, (char: string) => `_${char}`).toUpperCase()}`; + +/** + * Paths of the management collections under `baseUrl`. `MANAGEMENT_COLLECTIONS` has no + * entry for `applications` or `flows`, which the management client also addresses. + */ +export const MANAGEMENT_PATHS: Record = { + ...MANAGEMENT_COLLECTIONS, + applications: 'applications', + flows: 'flows', +}; + +/** + * `endpoints` overrides for the SDK config, from THUNDERID_ENDPOINT_. An unset key resolves to + * `{baseUrl}/{path}`, the SDK default. + */ +const readEndpoints = (): Record => { + const endpoints: Record = {}; + Object.keys(MANAGEMENT_PATHS).forEach((key: string): void => { + const value: string | undefined = env(endpointEnvName(key)); + if (value) endpoints[key] = value; + }); + return endpoints; +}; + const port = Number(env('PORT') ?? 3003); const appUrl: string = env('APP_URL') ?? `http://localhost:${port}`; @@ -18,6 +47,7 @@ const config: { baseUrl: string; clientId: string | undefined; clientSecret: string | undefined; + endpoints: Record; port: number; scopes: string[]; serviceClientId: string | undefined; @@ -30,6 +60,7 @@ const config: { baseUrl: env('THUNDERID_BASE_URL') ?? 'https://localhost:8090', clientId: env('THUNDERID_CLIENT_ID'), clientSecret: env('THUNDERID_CLIENT_SECRET'), + endpoints: readEndpoints(), port, scopes: (env('THUNDERID_SCOPES') ?? 'openid profile email').split(/\s+/), serviceClientId: env('THUNDERID_SERVICE_CLIENT_ID') ?? env('THUNDERID_CLIENT_ID'), diff --git a/playgrounds/node/src/endpoints.ts b/playgrounds/node/src/endpoints.ts new file mode 100644 index 00000000..f8961b52 --- /dev/null +++ b/playgrounds/node/src/endpoints.ts @@ -0,0 +1,43 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {resolveResourceEndpoint, type ResourceEndpointConfig, type ResourceEndpointKey} from '@thunderid/node'; +import {MANAGEMENT_PATHS} from './config.ts'; + +/** + * Paths of the sign-in and self-service endpoints under `baseUrl`. The SDK exports no table of + * these defaults, so the playground lists them to show what an unset override resolves to. + */ +const SELF_SERVICE_PATHS: Record = { + flowExecute: 'flow/execute', + flowMeta: 'flow/meta', + usersMe: 'users/me', + usersMeCredentials: 'users/me/update-credentials', + usersMeMeta: 'users/me/meta', +}; + +export interface ResolvedEndpoint { + key: string; + group: 'management' | 'selfService'; + /** Whether `endpoints.` set the URL, or the SDK falls back to `{baseUrl}/{path}`. */ + source: 'override' | 'baseUrl'; + url: string; +} + +/** + * The URL each resource endpoint resolves to for a client configuration, the way the SDK resolves + * it: `resolveResourceEndpoint()` returns the `endpoints` override, and an unset one falls back to + * `{baseUrl}/{path}`. + */ +const resolveEndpoints = (config: ResourceEndpointConfig & {baseUrl?: string}): ResolvedEndpoint[] => { + const baseUrl: string = config.baseUrl?.replace(/\/+$/, '') ?? ''; + const rows = (paths: Record, group: ResolvedEndpoint['group']): ResolvedEndpoint[] => + Object.entries(paths).map(([key, path]: [string, string]): ResolvedEndpoint => { + const override: string | undefined = resolveResourceEndpoint(key as ResourceEndpointKey, config); + return {key, group, source: override ? 'override' : 'baseUrl', url: override ?? `${baseUrl}/${path}`}; + }); + + return [...rows(MANAGEMENT_PATHS, 'management'), ...rows(SELF_SERVICE_PATHS, 'selfService')]; +}; + +export default resolveEndpoints; diff --git a/playgrounds/node/src/explorer.ts b/playgrounds/node/src/explorer.ts index 205b5634..3dcee515 100644 --- a/playgrounds/node/src/explorer.ts +++ b/playgrounds/node/src/explorer.ts @@ -8,6 +8,7 @@ import {generateSessionId, type SessionData, type User} from '@thunderid/node'; import operations, {type OperationSpec} from './catalog.ts'; import {client, serviceClient} from './client.ts'; import config from './config.ts'; +import resolveEndpoints from './endpoints.ts'; import runners, {type Runner} from './operations.ts'; import {traced, type Traced} from './trace.ts'; @@ -185,6 +186,8 @@ const explorer = (): { const meta = async (browserSession: string | undefined): Promise> => ({ app: config.appUrl, configured: Boolean(config.clientId), + // Resolved from the client's own configuration, so the list shows what the SDK will call. + endpoints: resolveEndpoints(await client.getConfiguration()), idp: {baseUrl: config.baseUrl, ...(await idpStatus())}, operations: operations.map((operation: OperationSpec) => { const blocks: Region[] = operation.regions diff --git a/playgrounds/node/src/operations.ts b/playgrounds/node/src/operations.ts index 3b2356a5..3a87fc97 100644 --- a/playgrounds/node/src/operations.ts +++ b/playgrounds/node/src/operations.ts @@ -4,13 +4,18 @@ // What each operation in the explorer runs. The explorer shows an operation's source by reading // its `#region` block in this file, so keep each runner inside its region. -import type { - AgentConfig, - CIBAInitiateOptions, - ExtendedAuthorizeRequestUrlParams, - ThunderIDNodeClient, - TokenExchangeRequestConfig, +import { + getTokenPermissions, + hasPermission, + type AgentConfig, + type CIBAInitiateOptions, + type ExtendedAuthorizeRequestUrlParams, + type ManagementListOptions, + type RoleAssigneeType, + type ThunderIDNodeClient, + type TokenExchangeRequestConfig, } from '@thunderid/node'; +import resolveEndpoints from './endpoints.ts'; export interface RunContext { /** The JSON argument from the explorer's Arguments field, already parsed. */ @@ -27,6 +32,17 @@ export type Runner = (ctx: RunContext) => Promise; const num = (value: string | undefined): number | undefined => (value ? Number(value) : undefined); +const page = (params: Record): ManagementListOptions => ({ + limit: num(params['limit']), + offset: num(params['offset']), +}); + +// #region resolveEndpoints +// Where each resource endpoint resolves for the initialised client: resolveResourceEndpoint() +// returns the `endpoints` override, and an unset key falls back to {baseUrl}/{path}. +const resolveEndpointsOp: Runner = async ({client}) => resolveEndpoints(await client.getConfiguration()); +// #endregion + // #region getOpenIDProviderEndpoints const getOpenIDProviderEndpoints: Runner = ({client}) => client.getOpenIDProviderEndpoints(); // #endregion @@ -183,6 +199,79 @@ const updateUserCredentials: Runner = async ({args, client, sessionId}) => { }; // #endregion +// #region management +// client.management(sessionId) binds the management operations to one session: each call is sent +// with that session's access token, read at call time, and never copied onto the returned object. +// ThunderID decides whether the token may read the resource; a 403 or 404 rejects with a +// ThunderIDAPIError (-ForbiddenError-001, -NotFoundError-001). +// #endregion + +// #region listGroups +const listGroups: Runner = ({client, params, sessionId}) => client.management(sessionId).groups.list(page(params)); +// #endregion + +// #region getGroup +const getGroup: Runner = ({client, params, sessionId}) => + client.management(sessionId).groups.get(params['groupId'] ?? ''); +// #endregion + +// #region listGroupMembers +const listGroupMembers: Runner = ({client, params, sessionId}) => + client.management(sessionId).groups.listMembers(params['groupId'] ?? '', page(params)); +// #endregion + +// #region listRoles +const listRoles: Runner = ({client, params, sessionId}) => client.management(sessionId).roles.list(page(params)); +// #endregion + +// #region getRole +const getRole: Runner = ({client, params, sessionId}) => client.management(sessionId).roles.get(params['roleId'] ?? ''); +// #endregion + +// #region listRoleAssignments +// `type` narrows the assignments to users, groups, apps or agents. +const listRoleAssignments: Runner = ({client, params, sessionId}) => + client.management(sessionId).roles.listAssignments(params['roleId'] ?? '', { + ...page(params), + type: params['type'] ? (params['type'] as RoleAssigneeType) : undefined, + }); +// #endregion + +// #region listAgentTypes +const listAgentTypes: Runner = ({client, params, sessionId}) => + client.management(sessionId).agentTypes.list(page(params)); +// #endregion + +// #region listAgentGroups +const listAgentGroups: Runner = ({client, params, sessionId}) => + client.management(sessionId).agents.listGroups(params['agentId'] ?? '', page(params)); +// #endregion + +// #region listAgentRoles +const listAgentRoles: Runner = ({client, params, sessionId}) => + client.management(sessionId).agents.listRoles(params['agentId'] ?? '', page(params)); +// #endregion + +// #region listUserGroups +const listUserGroups: Runner = ({client, params, sessionId}) => + client.management(sessionId).users.listGroups(params['userId'] ?? '', page(params)); +// #endregion + +// #region permissionGatedGroups +// getTokenPermissions() reads the `scope` or `scopes` claim of the access token, and hasPermission() +// treats permissions as `:` paths, so `system` or `system:group` also cover `system:group:view`. The +// token is decoded, not verified: the check only decides whether to make the call, and ThunderID +// still checks the request. +const permissionGatedGroups: Runner = async ({client, params, sessionId}) => { + const permission: string = params['permission'] ?? ''; + const granted: string[] = getTokenPermissions(await client.getAccessToken(sessionId)); + if (!hasPermission(granted, permission)) { + return {allowed: false, granted, required: permission}; + } + return {allowed: true, groups: await client.management(sessionId).groups.list(page(params))}; +}; +// #endregion + const runners: Record = { clearSession, clientCredentials, @@ -197,14 +286,26 @@ const runners: Record = { getOBOSignInURL, getOpenIDProviderEndpoints, getSignInUrl, + getGroup, + getRole, getUser, getUserTypes, getUsers, handleBackchannelLogout, initiateCIBA, isSignedIn, + listAgentGroups, + listAgentRoles, + listAgentTypes, + listGroupMembers, + listGroups, + listRoleAssignments, + listRoles, + listUserGroups, + permissionGatedGroups, pollCIBA, refreshAccessToken, + resolveEndpoints: resolveEndpointsOp, revokeAccessToken, signInCode, signInStart, diff --git a/playgrounds/node/src/trace.ts b/playgrounds/node/src/trace.ts index 905e5381..01584210 100644 --- a/playgrounds/node/src/trace.ts +++ b/playgrounds/node/src/trace.ts @@ -15,7 +15,7 @@ */ import {AsyncLocalStorage} from 'node:async_hooks'; -import {logger, type LogLevel, type ThunderIDNodeClient} from '@thunderid/node'; +import {logger, type LogLevel, type ThunderIDNodeClient, type ThunderIDNodeManagementClient} from '@thunderid/node'; export type TraceKind = 'ok' | 'info' | 'warn' | 'error'; @@ -163,6 +163,7 @@ const CLIENT_METHODS: Record = { initiateCIBA: -1, isSignedIn: 0, loadOpenIDProviderConfiguration: -1, + management: 0, pollCIBA: -1, refreshAccessToken: 0, requestAccessToken: 2, @@ -176,6 +177,47 @@ const CLIENT_METHODS: Record = { validateLogoutToken: -1, }; +const describeArg = (value: unknown): string => { + if (typeof value === 'string') return `'${value.length > 12 ? `${value.slice(0, 12)}...` : value}'`; + if (value && typeof value === 'object') { + const keys: string[] = Object.entries(value) + .filter(([, v]: [string, unknown]) => v !== undefined) + .map(([key]: [string, unknown]) => key); + return `{${keys.join(', ')}}`; + } + return String(value); +}; + +/** + * The management client is a plain object of operations, built per call to `management()`, so its + * operations are wrapped on the object it returns. + */ +const traceManagement = (management: ThunderIDNodeManagementClient): ThunderIDNodeManagementClient => + Object.fromEntries( + Object.entries(management).map(([collection, operations]: [string, Record]) => [ + collection, + Object.fromEntries( + Object.entries(operations).map(([name, operation]: [string, unknown]) => [ + name, + async (...args: unknown[]): Promise => { + const end: (detail: string, kind: TraceKind) => void = begin( + `management.${collection}.${name}(${args.map(describeArg).join(', ')})`, + 'client', + ); + try { + const result: unknown = await (operation as (...a: unknown[]) => Promise)(...args); + end(`returned ${summarize(result)}`, 'ok'); + return result; + } catch (e: unknown) { + end(`threw ${errorText(e)}`, 'error'); + throw e; + } + }, + ]), + ), + ]), + ) as unknown as ThunderIDNodeManagementClient; + /** * Wraps the client's methods on the instance. Methods the SDK calls through `this` then go through * the wrappers too, so the trace shows how one call is built from the others. @@ -194,6 +236,10 @@ export const traceClient = (client: ThunderIDNodeClient, label: string): void => 'client', ); const done = (result: unknown): unknown => { + if (name === 'management') { + end('returned the management client bound to the session', 'ok'); + return traceManagement(result as ThunderIDNodeManagementClient); + } end(`returned ${summarize(result)}`, result === false ? 'warn' : 'ok'); return result; }; diff --git a/playgrounds/nuxt/.env.example b/playgrounds/nuxt/.env.example index af3e3fa3..e823c327 100644 --- a/playgrounds/nuxt/.env.example +++ b/playgrounds/nuxt/.env.example @@ -23,6 +23,19 @@ THUNDERID_SESSION_SECRET= NUXT_PUBLIC_THUNDERID_AFTER_SIGN_IN_URL=/ NUXT_PUBLIC_THUNDERID_AFTER_SIGN_OUT_URL=/ +# Optional: per-collection management endpoint overrides. The management collections (users, user +# types, groups, roles, agents, agent types) default to {NUXT_PUBLIC_THUNDERID_BASE_URL}/{collection}. +# The names say ENDPOINTS (plural) because Nuxt maps runtime config keys to env names: +# thunderid.endpoints.groups <-> NUXT_PUBLIC_THUNDERID_ENDPOINTS_GROUPS. +# Leave unset ones commented out: an empty override is ignored. The Overview page shows what they +# resolve to. +# NUXT_PUBLIC_THUNDERID_ENDPOINTS_USERS=https://localhost:9090/users +# NUXT_PUBLIC_THUNDERID_ENDPOINTS_USER_TYPES=https://localhost:9090/user-types +# NUXT_PUBLIC_THUNDERID_ENDPOINTS_GROUPS=https://localhost:9090/groups +# NUXT_PUBLIC_THUNDERID_ENDPOINTS_ROLES=https://localhost:9090/roles +# NUXT_PUBLIC_THUNDERID_ENDPOINTS_AGENTS=https://localhost:9090/agents +# NUXT_PUBLIC_THUNDERID_ENDPOINTS_AGENT_TYPES=https://localhost:9090/agent-types + # DANGER: turns off TLS verification for every request the Nuxt server makes. Only for a local # ThunderID server with a self-signed certificate. Never use it in production. NODE_TLS_REJECT_UNAUTHORIZED=0 diff --git a/playgrounds/nuxt/README.md b/playgrounds/nuxt/README.md index 980db59f..838d8a26 100644 --- a/playgrounds/nuxt/README.md +++ b/playgrounds/nuxt/README.md @@ -4,12 +4,12 @@ A Nuxt 3 app that exercises every capability of `@thunderid/nuxt`, in the browse local ThunderID server. It links the SDK packages from this workspace (`workspace:*`), so it always runs the code you are changing. It is not published. -| Page | What it does | -| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Overview (`/`) | Capability checks for the client and the server (run one or run all, pass/fail with a reason, filters), a session card with an access token countdown read from the server session, the module configuration, and a live SDK event log. | -| Components (`/components`) | A component explorer: the components the module auto-imports and the primitives of `@thunderid/vue`, with stories, a viewport, theme, and grid toolbar, and Controls, Code (a Vue SFC generated from the props), Actions, and Accessibility panels. | -| Composables (`/composables`) | Live values of `useThunderID`, `useUser`, `useThunderIDI18n`, `useTheme`, `useFlowMeta`, and `useFlow`. | -| Server (`/server`) | The context the Nitro plugin built for the server render (`getThunderIDContext`), the session behind `useServerSession`, and an API explorer for every `/api/auth/*` route the module mounts, the playground's own server routes, and the pages behind route middleware, with an SDK trace. | +| Page | What it does | +| ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Overview (`/`) | Capability checks for the client and the server (run one or run all, pass/fail with a reason, filters), a session card with an access token countdown read from the server session, the module configuration, the resolved management endpoints, and a live SDK event log. | +| Components (`/components`) | A component explorer: the components the module auto-imports (the admin components included), the primitives of `@thunderid/vue` (`DataTable` included) and `Attribution`, with stories, a viewport, theme, and grid toolbar, and Controls, Code (a Vue SFC generated from the props), Actions, and Accessibility panels. | +| Composables (`/composables`) | Live values of `useThunderID`, `useUser`, `useThunderIDI18n`, `useTheme`, `useFlowMeta`, and `useFlow`, then the management composables: `useGrantedPermissions`, `useManagementEndpoint`, the group, role, user type, agent type, user group and agent group/role queries, and `useCreateGroup` (shown, not run). | +| Server (`/server`) | The context the Nitro plugin built for the server render (`getThunderIDContext`), the session behind `useServerSession`, and an API explorer for every `/api/auth/*` route the module mounts (`POST /api/auth/management` included), the playground's own server routes, and the pages behind route middleware, with an SDK trace. | The playground's own server code shows how an app uses the module: @@ -55,19 +55,42 @@ Open http://localhost:3002. After changing SDK source, rebuild that package (for | `THUNDERID_FLOW_SECRET` | Flow Secret of the application, sent when an app-native flow starts. Server-only. | | `THUNDERID_SESSION_SECRET` | Signs the session cookie. Unset uses an insecure development default. | | `NUXT_PUBLIC_THUNDERID_AFTER_SIGN_IN_URL`, `NUXT_PUBLIC_THUNDERID_AFTER_SIGN_OUT_URL` | Where the browser goes after sign-in and sign-out. Default to `/`. | +| `NUXT_PUBLIC_THUNDERID_ENDPOINTS_{USERS,USER_TYPES,GROUPS,ROLES,AGENTS,AGENT_TYPES}` | Optional. Overrides one collection URL. | | `NODE_TLS_REJECT_UNAUTHORIZED=0` | Lets the Nuxt server talk to a local ThunderID with a self-signed certificate. Local only. | -`nuxt.config.ts` turns on the opt-in features of the module so the playground can show them: back-channel logout and the -three resource pickers (`UserSelect`, `AuthFlowSelect`, `UserTypeSelect`). +`nuxt.config.ts` turns on the opt-in features of the module so the playground can show them: back-channel logout, the +three resource pickers (`UserSelect`, `AuthFlowSelect`, `UserTypeSelect`), and `management`, which serves +`POST /api/auth/management`, installs it as the `http.fetcher` of the management composables, and auto-imports the admin +components and management composables of `@thunderid/vue`. It also builds `thunderid.endpoints` from the endpoint +variables above. Only set variables become overrides; unset collections resolve to +`NUXT_PUBLIC_THUNDERID_BASE_URL/`, and the Overview page shows where each collection resolves and why. The +per-collection names use `ENDPOINTS` (plural), not the `ENDPOINT_` used by the other playgrounds, because that is +the name Nuxt's runtime config mapping gives `thunderid.endpoints.` (`NUXT_PUBLIC_THUNDERID_ENDPOINTS_GROUPS`). +There is no `APPLICATIONS` key: the module's management route does not relay that collection. Without a client ID the app still runs: the Nitro plugin does not initialize, and checks that need it fail with the reason. +## Custom rendering + +The admin components take a default scoped slot, the same pattern as ``, and render it in place of their own UI +with the component's data, state and handlers. The explorer's **Custom rendering** group draws `UserList` as a +[PrimeVue](https://primevue.org) DataTable, `GroupCreate` with PrimeVue inputs and `UserDetails` as a PrimeVue card, +registered through `plugins/primevue.ts`. PrimeVue is a playground dependency only. + ## Checks Checks that would leave the page (redirect sign-in, sign-out) only run from their own **Run** button; **Run all checks** marks them as not run. Results are kept in `sessionStorage` so they survive the sign-in redirect. +The **Management** section checks the module option and fetcher, that every auto-imported admin component and composable +resolves to the `@thunderid/vue` export, the route's URL allowlist (a foreign host gets 400, the configured groups +collection is accepted), that a management call reaches only `/api/auth/management` (observed with a +`PerformanceObserver`), endpoint resolution with overrides for `agentTypes`, `groups` and `roles`, the groups, roles, +agent types, agent groups/roles, user groups and user types APIs, the permission helpers, and `useGrantedPermissions`. +The API checks always send the request: without a session they fail with the route's `HTTP 401` and the reason (for +example, no client ID configured). + ## Known SDK gaps The playground reports these instead of faking them: @@ -80,3 +103,17 @@ The playground reports these instead of faking them: makes. - No way to issue an expired copy of a session: the cookie is signed with `THUNDERID_SESSION_SECRET` and the module exposes no helper for it, so the API explorer's "Expired access token" identity is disabled. +- The management route only relays users, user types, groups, roles, agents and agent types. `applications` is a + resource endpoint key and `useGetApplications` exists in `@thunderid/vue`, but the route answers 400 for it and the + module does not auto-import the application composables. +- `DataTable`, `Attribution`, `useManagementEndpoint`, `useResourceQuery` and `useResourceMutation` are not + auto-imported with `management`; the playground imports them from `@thunderid/vue`. +- The endpoint overrides have no env mapping in the module: `NUXT_PUBLIC_THUNDERID_ENDPOINTS_*` only works when the key + is already in `nuxt.config.ts`, so the playground reads the variables in `nuxt.config.ts` itself. +- `MANAGEMENT_COMPONENTS`, `MANAGEMENT_COMPOSABLES`, `NuxtAPIRoutes.MANAGEMENT`, the `ThunderIDManagementRequest` and + `ThunderIDManagementResult` types, and `getManagementCollectionUrls` are not exported from `@thunderid/nuxt`, so the + playground repeats the lists and the route path, and cannot show the route's allowlist itself. +- The management composables are not SSR-aware: on a server render they report `isLoading` and fetch again in the + browser. +- An unauthenticated management request (401) is shown by the admin components as the generic "could not load" message; + `toManagementErrorMessage` only distinguishes 403 and 404. `RESOURCE_ENDPOINT_KEYS` is not exported either. diff --git a/playgrounds/nuxt/assets/styles.css b/playgrounds/nuxt/assets/styles.css index f4d8132d..b50a3d65 100644 --- a/playgrounds/nuxt/assets/styles.css +++ b/playgrounds/nuxt/assets/styles.css @@ -1594,3 +1594,31 @@ a:hover { gap: 4px; } } + +/* Custom rendering stories (PrimeVue). */ +.pg-prime-stack { + display: flex; + flex-direction: column; + gap: 12px; +} +.pg-prime-field { + display: flex; + flex-direction: column; + gap: 6px; + font-size: 13px; +} +.pg-prime-actions { + display: flex; + justify-content: flex-end; + gap: 8px; +} +.pg-prime-chips { + display: flex; + flex-wrap: wrap; + gap: 6px; +} +.pg-prime-title { + display: flex; + align-items: center; + gap: 10px; +} diff --git a/playgrounds/nuxt/components/ManagementComposables.vue b/playgrounds/nuxt/components/ManagementComposables.vue new file mode 100644 index 00000000..c8451e98 --- /dev/null +++ b/playgrounds/nuxt/components/ManagementComposables.vue @@ -0,0 +1,176 @@ + + + diff --git a/playgrounds/nuxt/lib/adminStories.ts b/playgrounds/nuxt/lib/adminStories.ts new file mode 100644 index 00000000..e6b461b1 --- /dev/null +++ b/playgrounds/nuxt/lib/adminStories.ts @@ -0,0 +1,974 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +// Story components are small stateful wrappers that live next to the stories that use them. +/* eslint-disable vue/one-component-per-file */ + +import type { + BasicAgent, + Group, + GroupMember, + ManagedUser, + MembershipGroup, + RoleAssignment, + RoleResourcePermissions, + RoleSummary, +} from '@thunderid/browser'; +import {Attribution, Button, DataTable, type DataTableColumn, type IdentityCandidate} from '@thunderid/vue'; +import {defineComponent, h, ref, type Component, type PropType, type Ref, type VNode} from 'vue'; +import {tag} from './storyCode'; +import type {Action, Args, ComponentDef, PropControl, Snippet} from './storyTypes'; +import {narrow} from './storyWidgets'; +import { + AgentCreate, + AgentDetails, + AgentList, + BaseAgentCreate, + BaseAgentDetails, + BaseAgentList, + BaseGroupCreate, + BaseGroupList, + BaseGroupMembers, + BaseRoleAssignments, + BaseRoleCreate, + BaseRoleList, + BaseRolePermissions, + BaseUserCreate, + BaseUserDetails, + BaseUserList, + GroupCreate, + GroupList, + GroupMembers, + RoleAssignments, + RoleCreate, + RoleList, + RolePermissions, + UserCreate, + UserDetails, + UserList, +} from '#components'; + +// The admin components come from @thunderid/vue; the module auto-imports them, Base* variants +// included, when thunderid.management is on. The live variants fetch through POST /api/auth/management. + +const str = (args: Args, key: string): string => String(args[key] ?? ''); +const bool = (args: Args, key: string): boolean => args[key] === true; +const num = (args: Args, key: string): number => Number(args[key] ?? 0); +const optional = (args: Args, key: string): string | undefined => str(args, key) || undefined; +const isLive = (args: Args): boolean => str(args, 'source') === 'live'; + +const omit = (args: Args, ...keys: string[]): Args => + Object.fromEntries(Object.entries(args).filter(([key]: [string, unknown]) => !keys.includes(key))); + +/** Writes a value as a JavaScript literal for the Code panel. */ +const literal = (value: unknown): string => + JSON.stringify(value) + .replace(/"(\w+)":/g, '$1: ') + .replace(/"/g, "'") + .replace(/,(?=\S)/g, ', '); + +const storyProps = { + action: {required: true, type: Function as PropType}, + args: {required: true, type: Object as PropType}, +}; + +const SOURCE_CONTROL: PropControl = { + control: ['base', 'live'], + description: + 'base renders the Base* component with sample data; live renders the container, which fetches through POST /api/auth/management.', + name: 'source', + type: "'base' | 'live' (playground only)", +}; + +const STATE_CONTROLS: PropControl[] = [ + {control: 'bool', description: 'Shows the loading state (base only).', name: 'isLoading', type: 'boolean'}, + { + control: 'text', + description: 'Error message, already translated (base only).', + name: 'error', + type: 'string | null', + }, +]; + +const SAMPLE_USERS: ManagedUser[] = [ + { + attributes: {email: 'liam.chen@acme.io', family_name: 'Chen', given_name: 'Liam', username: 'liam.chen'}, + display: 'Liam Chen', + id: 'usr_8f2c41', + ouHandle: 'acme', + ouId: 'ou_acme', + type: 'employee', + }, + { + attributes: {email: 'ava.patel@acme.io', given_name: 'Ava', username: 'ava.patel'}, + display: 'Ava Patel', + id: 'usr_31b9d0', + ouHandle: 'acme', + ouId: 'ou_acme', + type: 'employee', + }, + { + attributes: {email: 'noah@partner.io', username: 'noah'}, + display: 'Noah Silva', + id: 'usr_c7a2e5', + ouHandle: 'partners', + ouId: 'ou_partners', + type: 'contractor', + }, +]; + +const SAMPLE_GROUPS: Group[] = [ + {description: 'Product engineering', id: 'grp_eng', name: 'Engineering', ouHandle: 'acme', ouId: 'ou_acme'}, + {description: 'Support and success', id: 'grp_support', name: 'Support', ouHandle: 'acme', ouId: 'ou_acme'}, + { + description: 'External contractors', + id: 'grp_partners', + name: 'Partners', + ouHandle: 'partners', + ouId: 'ou_partners', + }, +]; + +const SAMPLE_ROLES: RoleSummary[] = [ + {description: 'Full access to the directory', id: 'role_admin', name: 'Directory admin', ouId: 'ou_acme'}, + {description: 'Read-only access', id: 'role_viewer', name: 'Viewer', isReadOnly: true, ouId: 'ou_acme'}, + {description: 'Manages agents', id: 'role_agents', name: 'Agent operator', ouId: 'ou_acme'}, +]; + +const SAMPLE_AGENTS: BasicAgent[] = [ + { + clientId: 'agt_concierge', + id: 'agt_1', + name: 'Travel concierge', + ouHandle: 'acme', + ouId: 'ou_acme', + type: 'assistant', + }, + {clientId: 'agt_triage', id: 'agt_2', name: 'Ticket triage', ouHandle: 'acme', ouId: 'ou_acme', type: 'worker'}, + {clientId: 'agt_reports', id: 'agt_3', name: 'Report builder', ouHandle: 'acme', ouId: 'ou_acme', type: 'worker'}, +]; + +const SAMPLE_MEMBERSHIPS: MembershipGroup[] = [ + {id: 'grp_eng', name: 'Engineering', ouId: 'ou_acme'}, + {id: 'grp_support', name: 'Support', ouId: 'ou_acme'}, +]; + +const SAMPLE_MEMBERS: GroupMember[] = [ + {display: 'Liam Chen', id: 'usr_8f2c41', type: 'user'}, + {display: 'Travel concierge', id: 'agt_1', type: 'agent'}, +]; + +const SAMPLE_ASSIGNMENTS: RoleAssignment[] = [ + {display: 'Ava Patel', id: 'usr_31b9d0', type: 'user'}, + {display: 'Engineering', id: 'grp_eng', type: 'group'}, +]; + +const SAMPLE_PERMISSIONS: RoleResourcePermissions[] = [ + {permissions: ['system:user:view', 'system:group'], resourceServerId: 'rs_system'}, + {permissions: ['booking:read', 'booking:write'], resourceServerId: 'rs_booking'}, +]; + +const RESOURCE_SERVER_NAMES: Record = {rs_booking: 'Booking API', rs_system: 'ThunderID system API'}; + +const USER_CANDIDATES: IdentityCandidate[] = SAMPLE_USERS.map((user: ManagedUser) => ({ + id: user.id, + label: user.display ?? user.id, +})); +const GROUP_CANDIDATES: IdentityCandidate[] = SAMPLE_GROUPS.map((group: Group) => ({id: group.id, label: group.name})); +const AGENT_CANDIDATES: IdentityCandidate[] = SAMPLE_AGENTS.map((agent: BasicAgent) => ({ + id: agent.id, + label: agent.name, +})); + +const USER_TYPES: {label: string; value: string}[] = [ + {label: 'Employee', value: 'employee'}, + {label: 'Contractor', value: 'contractor'}, +]; +const AGENT_TYPES: {label: string; value: string}[] = [ + {label: 'Assistant', value: 'assistant'}, + {label: 'Worker', value: 'worker'}, +]; + +const stateProps = (args: Args): {error: string | null; isLoading: boolean} => ({ + error: optional(args, 'error') ?? null, + isLoading: bool(args, 'isLoading'), +}); + +interface ListSpec { + base: Component; + id: string; + live: Component; + name: string; + rows: {id: string}[]; + rowsProp: string; + selectedProp: string; +} + +/** A list holds the page offset and the selected row, the way an application would. */ +const listStory = (spec: ListSpec): Component => + defineComponent({ + name: `${spec.name}Story`, + props: storyProps, + setup(props: {action: Action; args: Args}): () => VNode { + const offset: Ref = ref(0); + const selected: Ref = ref(); + + return (): VNode => { + const {action, args} = props; + const limit: number = Math.max(1, num(args, 'limit')); + const onSelect = (row: {id: string}): void => { + selected.value = row.id; + action('select', row.id); + }; + + if (isLive(args)) { + return h(spec.live, {limit, onSelect, [spec.selectedProp]: selected.value, title: optional(args, 'title')}); + } + + return h(spec.base, { + ...stateProps(args), + [spec.rowsProp]: bool(args, 'empty') ? [] : spec.rows.slice(offset.value, offset.value + limit), + [spec.selectedProp]: selected.value, + limit, + offset: offset.value, + onPageChange: (next: number) => { + offset.value = next; + action('pageChange', String(next)); + }, + onSelect, + title: optional(args, 'title'), + totalResults: bool(args, 'empty') ? 0 : spec.rows.length, + }); + }; + }, + }); + +const listDef = (spec: ListSpec): ComponentDef => { + const story: Component = listStory(spec); + const kebabSelected: string = spec.selectedProp.replace(/[A-Z]/g, (char: string) => `-${char.toLowerCase()}`); + + return { + code: (args: Args): Snippet => + isLive(args) + ? { + imports: [], + script: ['const selectedId = ref();'], + template: tag(spec.name, omit(args, 'source', 'isLoading', 'error', 'empty'), { + defaults: {limit: 20}, + extra: [`:${kebabSelected}="selectedId"`, '@select="(row) => (selectedId = row.id)"'], + }), + } + : { + imports: [], + script: [ + `const ${spec.rowsProp} = ref(${literal(spec.rows.slice(0, 2))});`, + 'const selectedId = ref();', + 'const offset = ref(0);', + ], + template: tag(`Base${spec.name}`, omit(args, 'source', 'empty'), { + defaults: {limit: 20}, + extra: [ + `:${spec.rowsProp}="${spec.rowsProp}"`, + `:${kebabSelected}="selectedId"`, + ':offset="offset"', + `:total-results="${spec.rowsProp}.length"`, + '@select="(row) => (selectedId = row.id)"', + '@page-change="(next) => (offset = next)"', + ], + }), + }, + controls: [ + SOURCE_CONTROL, + {control: 'number', description: 'Page size.', name: 'limit', type: 'number'}, + {control: 'text', description: 'Heading; empty uses the translated default.', name: 'title', type: 'string'}, + ...STATE_CONTROLS, + {control: 'bool', description: 'No rows (base only).', name: 'empty', type: 'playground only'}, + ], + defaults: {empty: false, error: '', isLoading: false, limit: 2, source: 'base', title: ''}, + group: 'Admin', + id: spec.id, + live: true, + name: spec.name, + render: (args: Args, action: Action) => narrow(720, h(story, {action, args})), + stories: [ + {id: 'base', name: 'Sample page (paginated)'}, + {args: {empty: true}, id: 'empty', name: 'Empty'}, + {args: {isLoading: true}, id: 'loading', name: 'Loading'}, + {args: {error: 'You do not have permission to view this list.'}, id: 'error', name: 'Error'}, + {args: {limit: 20, source: 'live'}, id: 'live', name: 'Live (useGet* via /api/auth/management)'}, + ], + }; +}; + +interface CreateSpec { + base: Component; + /** Props only the Base form takes, such as the type options. */ + baseProps?: Record; + baseScript?: string[]; + baseExtra?: string[]; + id: string; + live: Component; + name: string; +} + +const createDef = (spec: CreateSpec): ComponentDef => ({ + code: (args: Args): Snippet => + isLive(args) + ? { + imports: [], + template: tag(spec.name, omit(args, 'source', 'isSubmitting', 'error'), { + extra: ['@created="handleCreated"', '@cancel="handleCancel"'], + }), + } + : { + imports: [], + script: spec.baseScript, + template: tag(`Base${spec.name}`, omit(args, 'source', 'ouId'), { + extra: [...(spec.baseExtra ?? []), '@submit="handleSubmit"', '@cancel="handleCancel"'], + }), + }, + controls: [ + SOURCE_CONTROL, + {control: 'text', description: 'Heading; empty uses the translated default.', name: 'title', type: 'string'}, + { + control: 'bool', + description: 'Disables the form while saving (base only).', + name: 'isSubmitting', + type: 'boolean', + }, + { + control: 'text', + description: 'Error message, already translated (base only).', + name: 'error', + type: 'string | null', + }, + { + control: 'text', + description: "Organization unit (live only). Empty uses the signed-in user's.", + name: 'ouId', + type: 'string', + }, + ], + defaults: {error: '', isSubmitting: false, ouId: '', source: 'base', title: ''}, + group: 'Admin', + id: spec.id, + live: true, + name: spec.name, + render: (args: Args, action: Action) => { + const shared = { + onCancel: () => action('cancel'), + title: optional(args, 'title'), + }; + + return narrow( + 480, + isLive(args) + ? h(spec.live, { + ...shared, + onCreated: (created: {id?: string}) => action('created', created?.id ?? ''), + ouId: optional(args, 'ouId'), + }) + : h(spec.base, { + ...shared, + ...spec.baseProps, + error: optional(args, 'error') ?? null, + isSubmitting: bool(args, 'isSubmitting'), + onSubmit: (submission: unknown) => action('submit', JSON.stringify(submission)), + }), + ); + }, + stories: [ + {id: 'base', name: 'Form (emits submit)'}, + {args: {error: 'A resource with that name already exists.'}, id: 'error', name: 'Error'}, + {args: {source: 'live'}, id: 'live', name: 'Live (creates through /api/auth/management)'}, + ], +}); + +interface DetailsSpec { + base: Component; + baseProps: Record; + baseScript: string[]; + baseExtra: string[]; + id: string; + idProp: string; + live: Component; + liveFlags: string[]; + name: string; +} + +const detailsDef = (spec: DetailsSpec): ComponentDef => { + const kebabId: string = spec.idProp.replace(/[A-Z]/g, (char: string) => `-${char.toLowerCase()}`); + + return { + code: (args: Args): Snippet => + isLive(args) + ? { + imports: [], + template: tag( + spec.name, + {}, + { + extra: [ + `${kebabId}="${str(args, 'resourceId') || ''}"`, + ...spec.liveFlags.filter((flag: string) => bool(args, flag)), + '@deleted="handleDeleted"', + ], + }, + ), + } + : { + imports: [], + script: spec.baseScript, + template: tag(`Base${spec.name}`, omit(args, 'source', 'resourceId', ...spec.liveFlags), { + extra: [...spec.baseExtra, '@delete="handleDelete"'], + }), + }, + controls: [ + SOURCE_CONTROL, + { + control: 'text', + description: `${spec.idProp} of the resource (live only). Copy one from a live list.`, + name: 'resourceId', + type: 'string', + }, + ...spec.liveFlags.map( + (flag: string): PropControl => ({ + control: 'bool', + description: `Turns on ${flag} actions (live only).`, + name: flag, + type: 'boolean', + }), + ), + ...STATE_CONTROLS, + ], + defaults: { + error: '', + isLoading: false, + resourceId: '', + source: 'base', + ...Object.fromEntries(spec.liveFlags.map((flag: string) => [flag, false])), + }, + group: 'Admin', + id: spec.id, + live: true, + name: spec.name, + render: (args: Args, action: Action) => { + if (!isLive(args)) { + return narrow( + 560, + h(spec.base, { + ...spec.baseProps, + ...stateProps(args), + onDelete: () => action('delete'), + onUpdate: (attributes: unknown) => action('update', JSON.stringify(attributes)), + }), + ); + } + + const resourceId: string = str(args, 'resourceId'); + + if (!resourceId) { + return h('p', {class: 'pg-empty'}, `Set ${spec.idProp} in Controls: copy one from a live list.`); + } + + return narrow( + 560, + h(spec.live, { + [spec.idProp]: resourceId, + ...Object.fromEntries(spec.liveFlags.map((flag: string) => [flag, bool(args, flag)])), + onDeleted: () => action('deleted', resourceId), + }), + ); + }, + stories: [ + {id: 'base', name: 'Sample resource'}, + {args: {isLoading: true}, id: 'loading', name: 'Loading'}, + {args: {error: 'This resource could not be found.'}, id: 'error', name: 'Error'}, + {args: {source: 'live'}, id: 'live', name: `Live (${spec.idProp})`}, + ], + }; +}; + +/** Group members and role assignments keep their rows in local state, so add and remove work on sample data. */ +const membershipStory = ( + name: string, + base: Component, + rowsProp: string, + initial: T[], + candidates: Record, + addEvent: 'onAdd' | 'onAssign', + removeEvent: 'onRemove' | 'onUnassign', +): Component => + defineComponent({ + name, + props: storyProps, + setup(props: {action: Action; args: Args}): () => VNode { + const rows: Ref = ref(initial.slice()) as Ref; + const labelOf = (row: {id: string}): string => + Object.values(candidates) + .flat() + .find((candidate: IdentityCandidate) => candidate.id === row.id)?.label ?? row.id; + + return (): VNode => + h(base, { + ...stateProps(props.args), + [addEvent]: (row: T) => { + rows.value = [...rows.value, {...row, display: labelOf(row)}]; + props.action(addEvent.slice(2).toLowerCase(), `${row.type} ${row.id}`); + }, + [removeEvent]: (row: T) => { + rows.value = rows.value.filter((item: T) => item.id !== row.id); + props.action(removeEvent.slice(2).toLowerCase(), `${row.type} ${row.id}`); + }, + [rowsProp]: rows.value, + candidates: bool(props.args, 'editable') ? candidates : undefined, + isMutating: false, + title: optional(props.args, 'title'), + }); + }, + }); + +interface MembershipSpec { + base: Component; + baseExtra: string[]; + baseScript: string[]; + idProp: string; + id: string; + live: Component; + name: string; + story: Component; +} + +const membershipDef = (spec: MembershipSpec): ComponentDef => { + const kebabId: string = spec.idProp.replace(/[A-Z]/g, (char: string) => `-${char.toLowerCase()}`); + + return { + code: (args: Args): Snippet => + isLive(args) + ? { + imports: [], + template: tag( + spec.name, + {editable: bool(args, 'editable')}, + { + extra: [`${kebabId}="${str(args, 'resourceId') || ''}"`], + }, + ), + } + : { + imports: [], + script: spec.baseScript, + template: tag(`Base${spec.name}`, omit(args, 'source', 'resourceId', 'editable'), { + extra: [...spec.baseExtra, ...(bool(args, 'editable') ? [':candidates="candidates"'] : [])], + }), + }, + controls: [ + SOURCE_CONTROL, + { + control: 'text', + description: `${spec.idProp} (live only). Copy one from a live list.`, + name: 'resourceId', + type: 'string', + }, + { + control: 'bool', + description: 'Shows the add picker and remove buttons. Live: loads candidates and writes changes.', + name: 'editable', + type: 'boolean', + }, + {control: 'text', description: 'Heading; empty uses the translated default.', name: 'title', type: 'string'}, + ...STATE_CONTROLS, + ], + defaults: {editable: true, error: '', isLoading: false, resourceId: '', source: 'base', title: ''}, + group: 'Admin', + id: spec.id, + live: true, + name: spec.name, + render: (args: Args, action: Action) => { + if (!isLive(args)) return narrow(560, h(spec.story, {action, args})); + + const resourceId: string = str(args, 'resourceId'); + + if (!resourceId) { + return h('p', {class: 'pg-empty'}, `Set ${spec.idProp} in Controls: copy one from a live list.`); + } + + return narrow( + 560, + h(spec.live, {[spec.idProp]: resourceId, editable: bool(args, 'editable'), title: optional(args, 'title')}), + ); + }, + stories: [ + {id: 'editable', name: 'Editable (sample data)'}, + {args: {editable: false}, id: 'readonly', name: 'Read only'}, + {args: {error: 'You do not have permission to change this.'}, id: 'error', name: 'Error'}, + {args: {editable: false, source: 'live'}, id: 'live', name: `Live (${spec.idProp})`}, + ], + }; +}; + +const GroupMembersStory: Component = membershipStory( + 'GroupMembersStory', + BaseGroupMembers, + 'members', + SAMPLE_MEMBERS, + {agent: AGENT_CANDIDATES, group: GROUP_CANDIDATES, user: USER_CANDIDATES}, + 'onAdd', + 'onRemove', +); + +const RoleAssignmentsStory: Component = membershipStory( + 'RoleAssignmentsStory', + BaseRoleAssignments, + 'assignments', + SAMPLE_ASSIGNMENTS, + {agent: AGENT_CANDIDATES, group: GROUP_CANDIDATES, user: USER_CANDIDATES}, + 'onAssign', + 'onUnassign', +); + +const DATA_TABLE_COLUMNS: DataTableColumn[] = [ + {header: 'Name', id: 'name', render: (row: ManagedUser) => row.display ?? row.id}, + {header: 'Type', id: 'type', render: (row: ManagedUser) => row.type}, + {header: 'Organization unit', id: 'ou', render: (row: ManagedUser) => row.ouHandle ?? row.ouId}, +]; + +/** DataTable holds no state: the story keeps the offset and the selected row. */ +const DataTableStory: Component = defineComponent({ + name: 'DataTableStory', + props: storyProps, + setup(props: {action: Action; args: Args}): () => VNode { + const offset: Ref = ref(0); + const selected: Ref = ref(); + + return (): VNode => { + const {action, args} = props; + const limit: number = Math.max(1, num(args, 'limit')); + const rows: ManagedUser[] = bool(args, 'empty') ? [] : SAMPLE_USERS; + + return h(DataTable, { + ...stateProps(args), + caption: str(args, 'caption'), + columns: DATA_TABLE_COLUMNS, + emptyText: str(args, 'emptyText'), + getRowId: (row: unknown) => (row as ManagedUser).id, + onRowClick: bool(args, 'selectable') + ? (row: unknown) => { + selected.value = (row as ManagedUser).id; + action('rowClick', (row as ManagedUser).id); + } + : undefined, + pagination: { + limit, + offset: offset.value, + onChange: (next: number) => { + offset.value = next; + action('pagination.onChange', String(next)); + }, + total: rows.length, + }, + rows: rows.slice(offset.value, offset.value + limit), + selectedRowId: bool(args, 'selectable') ? selected.value : undefined, + }); + }; + }, +}); + +/** A card for the ribbon, which is positioned against its nearest `position: relative` ancestor. */ +const ribbonCard = (child: VNode): VNode => + h( + 'div', + { + style: { + border: '1px solid var(--pg-border, #8884)', + borderRadius: '12px', + height: '180px', + overflow: 'hidden', + position: 'relative', + width: '360px', + }, + }, + [h('div', {style: {padding: '20px'}}, [h(Button, {variant: 'outline'}, {default: () => 'Sign in'})]), child], + ); + +const sampleDetailsScript = (name: string, value: unknown): string[] => [ + `const ${name} = ref(${literal(value)});`, + `const groups = ref(${literal(SAMPLE_MEMBERSHIPS.slice(0, 1))});`, +]; + +export const ADMIN_COMPONENTS: ComponentDef[] = [ + { + code: (args: Args): Snippet => ({ + imports: ['DataTable'], + script: [ + `const rows = ref(${literal(SAMPLE_USERS.slice(0, 2).map(({id, display, type}: ManagedUser) => ({display, id, type})))});`, + 'const offset = ref(0);', + 'const selectedId = ref();', + 'const columns = [', + " {header: 'Name', id: 'name', render: (row) => row.display},", + " {header: 'Type', id: 'type', render: (row) => row.type},", + '];', + `const pagination = computed(() => ({limit: ${num(args, 'limit')}, offset: offset.value, total: rows.value.length, onChange: (next) => (offset.value = next)}));`, + ], + template: tag('DataTable', omit(args, 'limit', 'selectable', 'empty'), { + defaults: {error: '', isLoading: false}, + extra: [ + ':rows="rows"', + ':columns="columns"', + ':get-row-id="(row) => row.id"', + ':pagination="pagination"', + ...(bool(args, 'selectable') + ? [':selected-row-id="selectedId"', ':on-row-click="(row) => (selectedId = row.id)"'] + : []), + ], + }), + }), + controls: [ + { + control: 'text', + description: 'Accessible name of the table, already translated.', + name: 'caption', + type: 'string', + }, + {control: 'text', description: 'Shown when there are no rows.', name: 'emptyText', type: 'string'}, + {control: 'number', description: 'Rows per page (pagination.limit).', name: 'limit', type: 'number'}, + { + control: 'bool', + description: 'Sets onRowClick, which makes rows selectable.', + name: 'selectable', + type: 'boolean', + }, + ...STATE_CONTROLS, + {control: 'bool', description: 'No rows (playground only).', name: 'empty', type: 'playground only'}, + ], + defaults: { + caption: 'Users', + empty: false, + emptyText: 'No users yet.', + error: '', + isLoading: false, + limit: 2, + selectable: true, + }, + group: 'Primitives', + id: 'datatable', + name: 'DataTable', + render: (args: Args, action: Action) => narrow(640, h(DataTableStory, {action, args})), + stories: [ + {id: 'playground', name: 'Selectable, paginated'}, + {args: {limit: 10, selectable: false}, id: 'plain', name: 'Plain'}, + {args: {empty: true}, id: 'empty', name: 'Empty'}, + {args: {isLoading: true}, id: 'loading', name: 'Loading'}, + {args: {error: 'Could not load users.'}, id: 'error', name: 'Error'}, + ], + }, + { + code: (args: Args): Snippet => ({ + imports: ['Attribution'], + template: tag('Attribution', args, {defaults: {appearance: 'inline', variant: 'secured'}}), + }), + controls: [ + { + control: ['inline', 'ribbon'], + description: 'A line of text, or a ribbon on the edge of a position: relative card.', + name: 'appearance', + type: "'inline' | 'ribbon'", + }, + { + control: ['secured', 'powered'], + description: '"Secured by" or "Powered by".', + name: 'variant', + type: "'secured' | 'powered'", + }, + {control: 'text', description: 'Makes the credit a link that opens in a new tab.', name: 'href', type: 'string'}, + ], + defaults: {appearance: 'inline', href: '', variant: 'secured'}, + group: 'Branding', + id: 'attribution', + name: 'Attribution', + render: (args: Args) => { + const credit: VNode = h(Attribution, { + appearance: str(args, 'appearance'), + href: optional(args, 'href'), + variant: str(args, 'variant'), + }); + + return str(args, 'appearance') === 'ribbon' ? ribbonCard(credit) : credit; + }, + stories: [ + {id: 'inline', name: 'Inline, secured by'}, + {args: {href: 'https://thunderid.dev', variant: 'powered'}, id: 'powered', name: 'Inline link, powered by'}, + {args: {appearance: 'ribbon'}, id: 'ribbon', name: 'Ribbon on a card'}, + ], + }, + listDef({ + base: BaseUserList, + id: 'userlist', + live: UserList, + name: 'UserList', + rows: SAMPLE_USERS, + rowsProp: 'users', + selectedProp: 'selectedUserId', + }), + createDef({ + base: BaseUserCreate, + baseExtra: [':user-types="userTypes"'], + baseProps: {userTypes: USER_TYPES}, + baseScript: [`const userTypes = ref(${literal(USER_TYPES)});`], + id: 'usercreate', + live: UserCreate, + name: 'UserCreate', + }), + detailsDef({ + base: BaseUserDetails, + baseExtra: [':user="user"', ':groups="groups"', ':on-update="handleUpdate"'], + baseProps: {groups: SAMPLE_MEMBERSHIPS, user: SAMPLE_USERS[0]}, + baseScript: sampleDetailsScript('user', { + display: 'Liam Chen', + id: 'usr_8f2c41', + ouId: 'ou_acme', + type: 'employee', + }), + id: 'userdetails', + idProp: 'userId', + live: UserDetails, + liveFlags: ['editable', 'deletable'], + name: 'UserDetails', + }), + listDef({ + base: BaseGroupList, + id: 'grouplist', + live: GroupList, + name: 'GroupList', + rows: SAMPLE_GROUPS, + rowsProp: 'groups', + selectedProp: 'selectedGroupId', + }), + createDef({base: BaseGroupCreate, id: 'groupcreate', live: GroupCreate, name: 'GroupCreate'}), + membershipDef({ + base: BaseGroupMembers, + baseExtra: [':members="members"', '@add="handleAdd"', '@remove="handleRemove"'], + baseScript: [ + `const members = ref(${literal(SAMPLE_MEMBERS.slice(0, 1))});`, + `const candidates = {user: ${literal(USER_CANDIDATES.slice(1, 2))}};`, + ], + id: 'groupmembers', + idProp: 'groupId', + live: GroupMembers, + name: 'GroupMembers', + story: GroupMembersStory, + }), + listDef({ + base: BaseRoleList, + id: 'rolelist', + live: RoleList, + name: 'RoleList', + rows: SAMPLE_ROLES, + rowsProp: 'roles', + selectedProp: 'selectedRoleId', + }), + createDef({base: BaseRoleCreate, id: 'rolecreate', live: RoleCreate, name: 'RoleCreate'}), + membershipDef({ + base: BaseRoleAssignments, + baseExtra: [':assignments="assignments"', '@assign="handleAssign"', '@unassign="handleUnassign"'], + baseScript: [ + `const assignments = ref(${literal(SAMPLE_ASSIGNMENTS.slice(0, 1))});`, + `const candidates = {group: ${literal(GROUP_CANDIDATES.slice(0, 1))}};`, + ], + id: 'roleassignments', + idProp: 'roleId', + live: RoleAssignments, + name: 'RoleAssignments', + story: RoleAssignmentsStory, + }), + { + code: (args: Args): Snippet => + isLive(args) + ? { + imports: [], + template: tag('RolePermissions', {}, {extra: [`role-id="${str(args, 'resourceId') || ''}"`]}), + } + : { + imports: [], + script: [ + `const permissions = ref(${literal(SAMPLE_PERMISSIONS.slice(0, 1))});`, + `const names = ${literal({rs_system: RESOURCE_SERVER_NAMES.rs_system})};`, + ], + template: tag('BaseRolePermissions', omit(args, 'source', 'resourceId'), { + extra: [':permissions="permissions"', ':resource-server-names="names"'], + }), + }, + controls: [ + SOURCE_CONTROL, + { + control: 'text', + description: 'roleId (live only). Copy one from a live RoleList.', + name: 'resourceId', + type: 'string', + }, + ...STATE_CONTROLS, + ], + defaults: {error: '', isLoading: false, resourceId: '', source: 'base'}, + group: 'Admin', + id: 'rolepermissions', + live: true, + name: 'RolePermissions', + render: (args: Args) => { + if (!isLive(args)) { + return narrow( + 560, + h(BaseRolePermissions, { + ...stateProps(args), + permissions: SAMPLE_PERMISSIONS, + resourceServerNames: RESOURCE_SERVER_NAMES, + }), + ); + } + + return str(args, 'resourceId') + ? narrow(560, h(RolePermissions, {roleId: str(args, 'resourceId')})) + : h('p', {class: 'pg-empty'}, 'Set roleId in Controls: copy one from a live RoleList.'); + }, + stories: [ + {id: 'base', name: 'Sample permissions'}, + {args: {isLoading: true}, id: 'loading', name: 'Loading'}, + {args: {source: 'live'}, id: 'live', name: 'Live (roleId)'}, + ], + }, + listDef({ + base: BaseAgentList, + id: 'agentlist', + live: AgentList, + name: 'AgentList', + rows: SAMPLE_AGENTS, + rowsProp: 'agents', + selectedProp: 'selectedAgentId', + }), + createDef({ + base: BaseAgentCreate, + baseExtra: [':agent-types="agentTypes"'], + baseProps: {agentTypes: AGENT_TYPES}, + baseScript: [`const agentTypes = ref(${literal(AGENT_TYPES)});`], + id: 'agentcreate', + live: AgentCreate, + name: 'AgentCreate', + }), + detailsDef({ + base: BaseAgentDetails, + baseExtra: [':agent="agent"', ':groups="groups"', ':roles="roles"'], + baseProps: { + agent: {...SAMPLE_AGENTS[0], description: 'Books travel for employees', owner: 'usr_8f2c41'}, + groups: SAMPLE_MEMBERSHIPS.slice(0, 1), + roles: ['Agent operator'], + }, + baseScript: [ + ...sampleDetailsScript('agent', {id: 'agt_1', name: 'Travel concierge', ouId: 'ou_acme', type: 'assistant'}), + "const roles = ref(['Agent operator']);", + ], + id: 'agentdetails', + idProp: 'agentId', + live: AgentDetails, + liveFlags: ['deletable'], + name: 'AgentDetails', + }), +]; diff --git a/playgrounds/nuxt/lib/capabilities.ts b/playgrounds/nuxt/lib/capabilities.ts index c4a4cf90..eab417c1 100644 --- a/playgrounds/nuxt/lib/capabilities.ts +++ b/playgrounds/nuxt/lib/capabilities.ts @@ -1,10 +1,39 @@ // Copyright 2026 The ThunderID Authors // SPDX-License-Identifier: Apache-2.0 -import type {ThunderIDContext} from '@thunderid/vue'; +import { + getAgentGroups, + getAgentRoles, + getAgents, + getAgentTypes, + getGroupMembers, + getGroups, + getRoleAssignments, + getRoles, + getTokenPermissions, + getUserGroups, + getUsers, + getUserTypes, + hasPermission, + resolveResourceEndpoint, + type ApiFetcher, +} from '@thunderid/browser'; +import type {GrantedPermissions, ManagementEndpoint, ManagementEndpointKey, ThunderIDContext} from '@thunderid/vue'; import {describeError, track} from './events'; import {checkServerHealth} from './health'; import {decodeJwt, formatDuration} from './jwt'; +import { + AUTO_IMPORTED_COMPONENTS, + AUTO_IMPORTED_COMPOSABLES, + MANAGEMENT_COLLECTIONS, + MANAGEMENT_ROUTE, + NEW_ENDPOINT_KEYS, + describeManagementError, + mismatchedAutoImports, + observeManagementRequests, + requestConfig, + type EndpointOverrides, +} from './management'; import {loadServerSession} from './session'; import {COMPONENTS, STORY_COUNT} from './stories'; import type {PlaygroundSession, SsrSnapshot} from './types'; @@ -22,9 +51,18 @@ export interface PublicThunderIDConfig { applicationId?: string; baseUrl?: string; clientId?: string; + endpoints?: EndpointOverrides; + management?: boolean; scopes?: string | string[]; } +/** What the management checks need from the page's setup, where composables can run. */ +export interface ManagementContext { + /** `useManagementEndpoint(key)` for each collection. */ + endpoint: (key: ManagementEndpointKey) => ManagementEndpoint; + permissions: GrantedPermissions; +} + /** * The live SDK state a check reads. Composables return refs, so this object stays current for the * whole run without being rebuilt. @@ -33,6 +71,7 @@ export interface CapabilityContext { config: PublicThunderIDConfig; flowMeta: ReturnType; i18n: ReturnType; + management: ManagementContext; sdk: ThunderIDContext; ssr: SsrSnapshot | null; theme: ReturnType; @@ -113,6 +152,42 @@ const probePage = async (path: string): Promise<'redirect' | number> => { return response.type === 'opaqueredirect' ? 'redirect' : response.status; }; +/** The fetcher the module installs as `http.fetcher` when `management` is on. */ +const fetcherOf = (context: CapabilityContext): ApiFetcher | undefined => context.sdk.http?.fetcher; + +/** + * Calls a management API function with the endpoint and fetcher the management composables use. A + * request is made even without a session, so the module's route answers with the reason. + */ +const manage = ( + context: CapabilityContext, + key: ManagementEndpointKey, + label: string, + call: (config: ManagementEndpoint & {fetcher?: ApiFetcher}) => Promise, +): Promise => track(label, () => call(requestConfig(context.management.endpoint(key), fetcherOf(context)))); + +const managementCheck = + ( + run: (context: CapabilityContext) => Promise, + ): ((context: CapabilityContext) => Promise) => + async (context: CapabilityContext) => { + if (!context.config.management) return fail('thunderid.management is off in nuxt.config.ts'); + + try { + return await run(context); + } catch (error) { + return fail(describeManagementError(error, context.config.clientId)); + } + }; + +/** Posts one request body to the management route and returns the upstream status it reports. */ +const postManagement = async (body: Record): Promise => + (await $fetch<{status: number}>(MANAGEMENT_ROUTE, {body, method: 'POST'})).status; + +/** A JWT with the given payload and no valid signature: enough for `getTokenPermissions`, which only decodes. */ +const unsignedJwt = (payload: Record): string => + `e30.${btoa(JSON.stringify(payload)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')}.sig`; + export const CAPABILITIES: Capability[] = [ { api: 'GET /api/playground/health', @@ -559,6 +634,257 @@ export const CAPABILITIES: Capability[] = [ run: () => pass(`${COMPONENTS.length} components · ${STORY_COUNT} stories`), section: 'UI', }, + { + api: 'nuxt.config thunderid.management · runtimeConfig.public.thunderid.management', + id: 'managementOption', + name: 'Management surface on', + run: ({config, sdk}: CapabilityContext) => { + if (!config.management) return fail('thunderid.management is not true in nuxt.config.ts'); + if (typeof sdk.http?.fetcher !== 'function') return fail('management is on but http.fetcher is not installed'); + + return pass(`http.fetcher installed: requests go to POST ${MANAGEMENT_ROUTE}`); + }, + section: 'Management', + }, + { + api: 'addComponent / addImports from @thunderid/vue · #components · #imports', + id: 'managementAutoImports', + name: 'Admin components and composables auto-imported', + run: () => { + const components: string[] = mismatchedAutoImports(AUTO_IMPORTED_COMPONENTS); + const composables: string[] = mismatchedAutoImports(AUTO_IMPORTED_COMPOSABLES); + + if (components.length || composables.length) { + return fail(`not the @thunderid/vue export: ${[...components, ...composables].join(', ')}`); + } + + return pass( + `${Object.keys(AUTO_IMPORTED_COMPONENTS).length} components, ${Object.keys(AUTO_IMPORTED_COMPOSABLES).length} composables resolve to @thunderid/vue`, + ); + }, + section: 'Management', + }, + { + api: `POST ${MANAGEMENT_ROUTE} · URL allowlist`, + id: 'managementRoute', + name: 'Management route', + run: async (context: CapabilityContext) => { + if (!context.config.management) return fail('thunderid.management is off in nuxt.config.ts'); + + const foreign: number = await track(`POST ${MANAGEMENT_ROUTE} (foreign host)`, () => + postManagement({method: 'GET', url: 'https://example.invalid/groups'}), + ); + const groups: ManagementEndpoint = context.management.endpoint('groups'); + const target: string = groups.url ?? `${(groups.baseUrl ?? '').replace(/\/+$/, '')}/groups`; + const allowed: number = await track( + `POST ${MANAGEMENT_ROUTE} (groups)`, + () => postManagement({method: 'GET', url: target}), + String, + ); + + if (foreign !== 400) return fail(`a foreign host got HTTP ${foreign}, expected 400`); + if (allowed === 400) return fail(`the configured groups collection ${target} was refused`); + + return pass(`foreign host 400; ${target} accepted (HTTP ${allowed}${allowed === 401 ? ': no session' : ''})`); + }, + section: 'Management', + }, + { + api: `http.fetcher → POST ${MANAGEMENT_ROUTE}`, + id: 'managementRelay', + name: 'Requests go through the server route', + run: async (context: CapabilityContext) => { + if (!context.config.management) return fail('thunderid.management is off in nuxt.config.ts'); + if (typeof PerformanceObserver === 'undefined') return unavailable('no PerformanceObserver in this browser'); + + const observed = await observeManagementRequests(() => + manage(context, 'groups', 'getGroups (relay)', (config) => getGroups({...config, limit: 1})), + ); + + if (observed.direct.length) return fail(`browser called ${observed.direct.join(', ')} directly`); + if (!observed.relayed) return fail(`no request to ${MANAGEMENT_ROUTE} was observed`); + + return pass( + `${observed.relayed} request via ${MANAGEMENT_ROUTE}, none to ThunderID; ${ + observed.error ? describeManagementError(observed.error, context.config.clientId) : 'HTTP 200' + }`, + ); + }, + section: 'Management', + }, + { + api: 'useManagementEndpoint(key) · resolveResourceEndpoint() · endpoints.{agentTypes,groups,roles}', + id: 'endpoints', + name: 'Endpoint resolution and overrides', + run: ({config, management}: CapabilityContext) => { + const sample: EndpointOverrides = { + agentTypes: 'https://api.example/agent-types', + groups: 'https://api.example/groups', + roles: 'https://api.example/roles', + }; + const broken: string[] = NEW_ENDPOINT_KEYS.filter( + (key) => + resolveResourceEndpoint(key, {endpoints: sample}) !== sample[key] || + resolveResourceEndpoint(key, {endpoints: sample}, 'https://explicit.example') !== + 'https://explicit.example' || + resolveResourceEndpoint(key, {endpoints: {}}) !== undefined, + ); + + if (broken.length) return fail(`resolveResourceEndpoint ignores the override of ${broken.join(', ')}`); + + const drift: string[] = MANAGEMENT_COLLECTIONS.filter( + ({key}) => management.endpoint(key).url !== config.endpoints?.[key], + ).map(({key}) => key); + + if (drift.length) return fail(`useManagementEndpoint disagrees with the config for ${drift.join(', ')}`); + + const overridden: string[] = MANAGEMENT_COLLECTIONS.filter(({key}) => management.endpoint(key).url).map( + ({key}) => key, + ); + + return pass( + `override > {baseUrl}/{path} for agentTypes, groups, roles; ${ + overridden.length ? `overridden here: ${overridden.join(', ')}` : 'no overrides set' + }`, + ); + }, + section: 'Management', + }, + { + api: 'getGroups() · getGroupMembers() · useGetGroups()', + id: 'groups', + name: 'Groups', + run: managementCheck(async (context: CapabilityContext) => { + const page = await manage(context, 'groups', 'getGroups', (config) => getGroups({...config, limit: 5})); + const first = page.groups[0]; + + if (!first) return pass(`0 groups (${page.totalResults} total)`); + + const members = await manage(context, 'groups', 'getGroupMembers', (config) => + getGroupMembers({...config, groupId: first.id, limit: 5}), + ); + + return pass(`${page.totalResults} groups; "${first.name}" has ${members.totalResults} members`); + }), + section: 'Management', + }, + { + api: 'getRoles() · getRoleAssignments() · useGetRoles()', + id: 'roles', + name: 'Roles', + run: managementCheck(async (context: CapabilityContext) => { + const page = await manage(context, 'roles', 'getRoles', (config) => getRoles({...config, limit: 5})); + const first = page.roles[0]; + + if (!first) return pass(`0 roles (${page.totalResults} total)`); + + const assignments = await manage(context, 'roles', 'getRoleAssignments', (config) => + getRoleAssignments({...config, limit: 5, roleId: first.id}), + ); + + return pass(`${page.totalResults} roles; "${first.name}" has ${assignments.totalResults} assignments`); + }), + section: 'Management', + }, + { + api: 'getAgentTypes() · useGetAgentTypes() · endpoints.agentTypes', + id: 'agentTypes', + name: 'Agent types', + run: managementCheck(async (context: CapabilityContext) => { + const page = await manage(context, 'agentTypes', 'getAgentTypes', (config) => + getAgentTypes({...config, limit: 5}), + ); + + return pass( + `${page.totalResults} agent types${page.types.length ? `: ${page.types.map((type) => type.handle).join(', ')}` : ''}`, + ); + }), + section: 'Management', + }, + { + api: 'getAgentGroups() · getAgentRoles() · useGetAgentGroups() · useGetAgentRoles()', + id: 'agentMemberships', + name: 'Agent groups and roles', + run: managementCheck(async (context: CapabilityContext) => { + const agents = await manage(context, 'agents', 'getAgents', (config) => getAgents({...config, limit: 1})); + const agent = agents.agents[0]; + + if (!agent) return skip('no agents to read groups and roles of: create one in the Console'); + + const groups = await manage(context, 'agents', 'getAgentGroups', (config) => + getAgentGroups({...config, agentId: agent.id}), + ); + const roles = await manage(context, 'agents', 'getAgentRoles', (config) => + getAgentRoles({...config, agentId: agent.id}), + ); + + return pass(`"${agent.name}": ${groups.totalResults} groups, ${roles.totalResults} roles`); + }), + section: 'Management', + }, + { + api: 'getUserGroups() · getUserTypes() · useGetUserGroups() · useGetUserTypes()', + id: 'userMemberships', + name: 'User groups and types', + run: managementCheck(async (context: CapabilityContext) => { + const types = await manage(context, 'userTypes', 'getUserTypes', (config) => getUserTypes({...config, limit: 5})); + const users = await manage(context, 'users', 'getUsers', (config) => getUsers({...config, limit: 1})); + const user = users.users[0]; + + if (!user) return pass(`${types.totalResults} user types; no users to read groups of`); + + const groups = await manage(context, 'users', 'getUserGroups', (config) => + getUserGroups({...config, userId: user.id}), + ); + + return pass( + `${types.totalResults} user types; user ${user.display ?? user.id} is in ${groups.totalResults} groups`, + ); + }), + section: 'Management', + }, + { + api: 'getTokenPermissions() · hasPermission()', + id: 'permissionHelpers', + name: 'Permission helpers', + run: () => { + const fromScope: string[] = getTokenPermissions(unsignedJwt({scope: 'openid system:user'})); + const fromScopes: string[] = getTokenPermissions(unsignedJwt({scopes: ['system:group']})); + + if (fromScope.join(' ') !== 'openid system:user') return fail(`scope claim read as ${fromScope.join(' ')}`); + if (fromScopes.join(' ') !== 'system:group') return fail(`scopes claim read as ${fromScopes.join(' ')}`); + if (getTokenPermissions('not-a-jwt').length) return fail('an opaque token produced permissions'); + if (!hasPermission(['system:user'], 'system:user:view')) + return fail('system:user does not cover system:user:view'); + if (hasPermission(['system:user'], 'system:userx')) return fail('system:user covers system:userx'); + + return pass('scope and scopes claims read; system:user covers system:user:view, not system:userx'); + }, + section: 'Management', + }, + { + api: 'useGrantedPermissions() · can()', + id: 'grantedPermissions', + name: 'Granted permissions', + run: async ({config, management, sdk}: CapabilityContext) => { + const {can, isLoading, permissions} = management.permissions; + + if (!sdk.isSignedIn.value) { + return fail( + `no session: permissions stay [] until you sign in${config.clientId ? '' : ' (NUXT_PUBLIC_THUNDERID_CLIENT_ID is not set)'}`, + ); + } + if (!(await waitFor(() => !isLoading.value, 5000))) return fail('isLoading stayed true for 5s'); + + const granted: string[] = permissions.value; + const sample: string[] = ['system:user:view', 'system:group:view', 'system:role:view'].filter(can); + + return pass( + `${granted.length} permissions from the access token; can: ${sample.length ? sample.join(', ') : 'none of system:{user,group,role}:view'}`, + ); + }, + section: 'Management', + }, ]; export const runCapability = async ( diff --git a/playgrounds/nuxt/lib/customRenderStories.ts b/playgrounds/nuxt/lib/customRenderStories.ts new file mode 100644 index 00000000..be1de27e --- /dev/null +++ b/playgrounds/nuxt/lib/customRenderStories.ts @@ -0,0 +1,296 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import type {ManagedUser, MembershipGroup} from '@thunderid/browser'; +import type {AdminListRenderProps, BaseGroupCreateRenderProps, BaseUserDetailsRenderProps} from '@thunderid/vue'; +import {defineComponent, h, ref, type Component, type PropType, type Ref, type VNode} from 'vue'; +import {groupForm, PrimeTheme, userCard, userTable} from './primeRenderers'; +import type {Action, Args, ComponentDef, PropControl} from './storyTypes'; +import {narrow} from './storyWidgets'; +import {BaseGroupCreate, BaseUserDetails, BaseUserList, GroupCreate, UserDetails, UserList} from '#components'; + +// The admin components are auto-imported by @thunderid/nuxt when thunderid.management is on. + +const str = (args: Args, key: string): string => String(args[key] ?? ''); +const bool = (args: Args, key: string): boolean => args[key] === true; +const num = (args: Args, key: string): number => Number(args[key] ?? 0); +const quoted = (value: unknown): string => `"${String(value)}"`; + +const SOURCE_CONTROL: PropControl = { + control: ['base', 'live'], + description: + 'base renders the Base* component with sample data; live renders the container, which fetches through POST /api/auth/management.', + name: 'source', + type: "'base' | 'live' (playground only)", +}; + +// Sample data for the Base components. Nothing here reaches a server. + +const OU = 'ou_7d1e'; + +const SAMPLE_USERS: ManagedUser[] = [ + ['usr_8f2c41', 'Liam Chen', 'liam.chen'], + ['usr_2b9d07', 'Ava Patel', 'ava.patel'], + ['usr_c41a55', 'Noah Kim', 'noah.kim'], + ['usr_5d0e91', 'Mia Rossi', 'mia.rossi'], + ['usr_a77b20', 'Kofi Mensah', 'kofi.mensah'], +].map(([id, display, username]: string[]) => ({ + attributes: {email: `${username}@acme.io`, username}, + display, + id, + ouHandle: 'acme', + ouId: OU, + type: 'employee', +})); + +const SAMPLE_MEMBERSHIPS: MembershipGroup[] = [ + {id: 'grp_eng', name: 'Engineering', ouId: OU}, + {id: 'grp_sec', name: 'Security', ouId: OU}, +]; + +const isLive = (args: Args): boolean => str(args, 'source') === 'live'; + +const themed = (width: number, child: VNode): VNode => narrow(width, h(PrimeTheme, null, {default: () => child})); + +/** Pages and selects the sample users, the way an application holding the data would. */ +const SampleUserTable: Component = defineComponent({ + name: 'SampleUserTable', + props: { + action: {required: true, type: Function as PropType}, + error: {default: null, type: String as PropType}, + isLoading: {default: false, type: Boolean}, + limit: {default: 3, type: Number}, + }, + setup(props: {action: Action; error: string | null; isLoading: boolean; limit: number}): () => VNode { + const offset: Ref = ref(0); + const selected: Ref = ref(); + + return (): VNode => + h( + BaseUserList, + { + error: props.error, + isLoading: props.isLoading, + limit: props.limit, + offset: offset.value, + onPageChange: (next: number): void => { + offset.value = next; + props.action('page-change', String(next)); + }, + onSelect: (user: ManagedUser): void => { + selected.value = user.id; + props.action('select', quoted(user.id)); + }, + selectedUserId: selected.value, + totalResults: SAMPLE_USERS.length, + users: SAMPLE_USERS.slice(offset.value, offset.value + props.limit), + }, + {default: (state: AdminListRenderProps) => userTable(state)}, + ); + }, +}); + +const TABLE_TEMPLATE = ` + +`; + +const FORM_TEMPLATE = ` + +`; + +const CARD_TEMPLATE = ` + +`; + +/** + * Stories that replace an admin component's default UI through its default scoped slot, here with + * PrimeVue. The same slot props work with any component library. + */ +export const CUSTOM_RENDER_COMPONENTS: ComponentDef[] = [ + { + code: () => ({ + imports: [], + otherImports: ["import Column from 'primevue/column';", "import DataTable from 'primevue/datatable';"], + template: TABLE_TEMPLATE, + }), + controls: [ + SOURCE_CONTROL, + {control: 'number', description: 'Rows per page.', name: 'limit', type: 'number'}, + {control: 'bool', description: 'Base only: isLoading.', name: 'isLoading', type: 'boolean'}, + {control: 'text', description: 'Base only: error.', name: 'error', type: 'string | null'}, + ], + defaults: {error: '', isLoading: false, limit: 3, source: 'base'}, + group: 'Custom rendering', + id: 'custom-userlist-datatable', + live: true, + name: 'UserList → PrimeVue DataTable', + render: (args: Args, action: Action): VNode => + themed( + 760, + isLive(args) + ? h( + UserList, + { + key: `live-${num(args, 'limit')}`, + limit: Math.max(1, num(args, 'limit')), + onSelect: (user: ManagedUser): void => action('select', quoted(user.id)), + }, + {default: (state: AdminListRenderProps) => userTable(state)}, + ) + : h(SampleUserTable, { + action, + error: str(args, 'error') || null, + isLoading: bool(args, 'isLoading'), + key: `base-${num(args, 'limit')}`, + limit: Math.max(1, num(args, 'limit')), + }), + ), + stories: [ + {id: 'base', name: 'Sample data'}, + {args: {isLoading: true}, id: 'loading', name: 'Loading'}, + {args: {limit: 10, source: 'live'}, id: 'live', name: 'Live (needs sign-in)'}, + ], + }, + { + code: () => ({ + imports: [], + otherImports: [ + "import Button from 'primevue/button';", + "import InputText from 'primevue/inputtext';", + "import Message from 'primevue/message';", + ], + template: FORM_TEMPLATE, + }), + controls: [ + SOURCE_CONTROL, + {control: 'bool', description: 'Base only: isSubmitting.', name: 'isSubmitting', type: 'boolean'}, + {control: 'text', description: 'Base only: error.', name: 'error', type: 'string | null'}, + ], + defaults: {error: '', isSubmitting: false, source: 'base'}, + group: 'Custom rendering', + id: 'custom-groupcreate-form', + live: true, + name: 'GroupCreate → PrimeVue form', + render: (args: Args, action: Action): VNode => + themed( + 480, + isLive(args) + ? h( + GroupCreate, + { + onCancel: (): void => action('cancel'), + onCreated: (group: {id: string}): void => action('created', quoted(group.id)), + }, + {default: (state: BaseGroupCreateRenderProps) => groupForm(state)}, + ) + : h( + BaseGroupCreate, + { + error: str(args, 'error') || null, + isSubmitting: bool(args, 'isSubmitting'), + onCancel: (): void => action('cancel'), + onSubmit: (group: {description?: string; name: string}): void => + action('submit', JSON.stringify(group)), + }, + {default: (state: BaseGroupCreateRenderProps) => groupForm(state)}, + ), + ), + stories: [ + {id: 'base', name: 'Sample'}, + {args: {error: 'A group with this name already exists.'}, id: 'error', name: 'Error'}, + {args: {source: 'live'}, id: 'live', name: 'Live (needs sign-in)'}, + ], + }, + { + code: () => ({ + imports: [], + otherImports: [ + "import Button from 'primevue/button';", + "import Card from 'primevue/card';", + "import Chip from 'primevue/chip';", + ], + template: CARD_TEMPLATE, + }), + controls: [ + SOURCE_CONTROL, + {control: 'text', description: 'Live only: the user to load.', name: 'userId', type: 'string'}, + {control: 'bool', description: 'Passes onDelete.', name: 'deletable', type: 'boolean'}, + ], + defaults: {deletable: true, source: 'base', userId: ''}, + group: 'Custom rendering', + id: 'custom-userdetails-card', + live: true, + name: 'UserDetails → PrimeVue card', + render: (args: Args, action: Action): VNode => { + if (isLive(args) && !str(args, 'userId')) { + return narrow( + 420, + h('p', 'Set userId in Controls to an ID from your server (copy one from the UserList story).'), + ); + } + + return themed( + 480, + isLive(args) + ? h( + UserDetails, + { + deletable: bool(args, 'deletable'), + onDeleted: (): void => action('deleted'), + userId: str(args, 'userId'), + }, + {default: (state: BaseUserDetailsRenderProps) => userCard(state)}, + ) + : h( + BaseUserDetails, + { + groups: SAMPLE_MEMBERSHIPS, + onDelete: bool(args, 'deletable') + ? (): void => action('delete', quoted(SAMPLE_USERS[0].id)) + : undefined, + user: SAMPLE_USERS[0], + }, + {default: (state: BaseUserDetailsRenderProps) => userCard(state)}, + ), + ); + }, + stories: [ + {id: 'base', name: 'Sample user'}, + {args: {deletable: false}, id: 'readonly', name: 'Without delete'}, + {args: {source: 'live'}, id: 'live', name: 'Live (needs userId)'}, + ], + }, +]; diff --git a/playgrounds/nuxt/lib/management.ts b/playgrounds/nuxt/lib/management.ts new file mode 100644 index 00000000..b4e93f66 --- /dev/null +++ b/playgrounds/nuxt/lib/management.ts @@ -0,0 +1,281 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ThunderIDAPIError, type ApiFetcher, type ResourceEndpointKey} from '@thunderid/browser'; +import * as ThunderIDVue from '@thunderid/vue'; +import type {ManagementEndpoint, ManagementEndpointKey} from '@thunderid/vue'; +import {describeError} from './events'; +import { + AgentCreate, + AgentDetails, + AgentList, + BaseAgentCreate, + BaseAgentDetails, + BaseAgentList, + BaseGroupCreate, + BaseGroupList, + BaseGroupMembers, + BaseRoleAssignments, + BaseRoleCreate, + BaseRoleList, + BaseRolePermissions, + BaseUserCreate, + BaseUserDetails, + BaseUserList, + GroupCreate, + GroupList, + GroupMembers, + RoleAssignments, + RoleCreate, + RoleList, + RolePermissions, + UserCreate, + UserDetails, + UserList, +} from '#components'; +import { + useAddGroupMembers, + useAddRoleAssignments, + useCreateAgent, + useCreateGroup, + useCreateRole, + useCreateUser, + useDeleteAgent, + useDeleteGroup, + useDeleteRole, + useDeleteUser, + useGetAgent, + useGetAgentGroups, + useGetAgentRoles, + useGetAgents, + useGetAgentTypes, + useGetGroup, + useGetGroupMembers, + useGetGroups, + useGetRole, + useGetRoleAssignments, + useGetRoles, + useGetUser, + useGetUserGroups, + useGetUsers, + useGetUserTypes, + useGrantedPermissions, + useRemoveGroupMembers, + useRemoveRoleAssignments, + useUpdateAgent, + useUpdateGroup, + useUpdateRole, + useUpdateUser, +} from '#imports'; + +/** The route the module serves when `management` is on. */ +export const MANAGEMENT_ROUTE = '/api/auth/management'; + +/** + * The management collections the module's route relays, with their default path under `baseUrl` and the + * variable that overrides them (see nuxt.config.ts). + */ +export const MANAGEMENT_COLLECTIONS: {env: string; key: ManagementEndpointKey; path: string}[] = [ + {env: 'NUXT_PUBLIC_THUNDERID_ENDPOINTS_USERS', key: 'users', path: 'users'}, + {env: 'NUXT_PUBLIC_THUNDERID_ENDPOINTS_USER_TYPES', key: 'userTypes', path: 'user-types'}, + {env: 'NUXT_PUBLIC_THUNDERID_ENDPOINTS_GROUPS', key: 'groups', path: 'groups'}, + {env: 'NUXT_PUBLIC_THUNDERID_ENDPOINTS_ROLES', key: 'roles', path: 'roles'}, + {env: 'NUXT_PUBLIC_THUNDERID_ENDPOINTS_AGENTS', key: 'agents', path: 'agents'}, + {env: 'NUXT_PUBLIC_THUNDERID_ENDPOINTS_AGENT_TYPES', key: 'agentTypes', path: 'agent-types'}, +]; + +/** The resource endpoint keys this SDK version added. */ +export const NEW_ENDPOINT_KEYS: ResourceEndpointKey[] = ['agentTypes', 'groups', 'roles']; + +export type EndpointOverrides = Partial>; + +export interface ResolvedEndpoint { + key: ManagementEndpointKey; + /** Where the override came from, or `default` when the URL is derived from `baseUrl`. */ + source: string; + url: string; +} + +const trimSlash = (value: string | undefined): string => (value ?? '').replace(/\/+$/, ''); + +/** + * Resolves the URL of every management collection the way the management composables do: the + * `useManagementEndpoint` override, else `{baseUrl}/{path}`. + */ +export const resolveManagementEndpoints = ( + endpoint: (key: ManagementEndpointKey) => ManagementEndpoint, +): ResolvedEndpoint[] => + MANAGEMENT_COLLECTIONS.map(({env, key, path}: {env: string; key: ManagementEndpointKey; path: string}) => { + const {baseUrl, url} = endpoint(key); + + if (!url) return {key, source: 'default', url: `${trimSlash(baseUrl) || '(baseUrl not set)'}/${path}`}; + + return {key, source: env, url}; + }); + +const originOf = (url: string | undefined): string => { + try { + return new URL(url ?? '').origin; + } catch { + return ''; + } +}; + +/** Describes the auth server at `baseUrl` and the hosts the management collections resolve to. */ +export const describeHosts = (baseUrl: string | undefined, endpoints: ResolvedEndpoint[]): string[] => { + const base: string = originOf(baseUrl); + const hosts: string[] = Array.from(new Set(endpoints.map((item: ResolvedEndpoint) => originOf(item.url)))); + const overridden: boolean = endpoints.some((item: ResolvedEndpoint) => item.source !== 'default'); + + return [ + `baseUrl ${base || '(baseUrl not set)'} OAuth, flows, /users/me`, + `management ${hosts.join(', ') || '(not set)'} ${overridden ? 'overridden' : 'no overrides'}`, + ]; +}; + +/** + * Names the module auto-imports when `management` is on, mapped to what `#components` and `#imports` + * resolve them to. A check compares each one with the export of `@thunderid/vue`. + */ +export const AUTO_IMPORTED_COMPONENTS: Record = { + AgentCreate, + AgentDetails, + AgentList, + BaseAgentCreate, + BaseAgentDetails, + BaseAgentList, + BaseGroupCreate, + BaseGroupList, + BaseGroupMembers, + BaseRoleAssignments, + BaseRoleCreate, + BaseRoleList, + BaseRolePermissions, + BaseUserCreate, + BaseUserDetails, + BaseUserList, + GroupCreate, + GroupList, + GroupMembers, + RoleAssignments, + RoleCreate, + RoleList, + RolePermissions, + UserCreate, + UserDetails, + UserList, +}; + +export const AUTO_IMPORTED_COMPOSABLES: Record = { + useAddGroupMembers, + useAddRoleAssignments, + useCreateAgent, + useCreateGroup, + useCreateRole, + useCreateUser, + useDeleteAgent, + useDeleteGroup, + useDeleteRole, + useDeleteUser, + useGetAgent, + useGetAgentGroups, + useGetAgentRoles, + useGetAgents, + useGetAgentTypes, + useGetGroup, + useGetGroupMembers, + useGetGroups, + useGetRole, + useGetRoleAssignments, + useGetRoles, + useGetUser, + useGetUserGroups, + useGetUsers, + useGetUserTypes, + useGrantedPermissions, + useRemoveGroupMembers, + useRemoveRoleAssignments, + useUpdateAgent, + useUpdateGroup, + useUpdateRole, + useUpdateUser, +}; + +/** The names whose auto-import does not resolve to the `@thunderid/vue` export of the same name. */ +export const mismatchedAutoImports = (resolved: Record): string[] => + Object.entries(resolved) + .filter(([name, value]: [string, unknown]) => !value || value !== (ThunderIDVue as Record)[name]) + .map(([name]: [string, unknown]) => name); + +/** + * Explains a failed management request. The module's route answers with the upstream status, so a + * missing session, a missing permission and a refused URL each get their own reason. + */ +export const describeManagementError = (error: unknown, clientId: string | undefined): string => { + if (!(error instanceof ThunderIDAPIError)) return describeError(error); + + switch (error.statusCode) { + case 401: + return clientId + ? `HTTP 401 via ${MANAGEMENT_ROUTE}: no signed-in session, sign in first` + : `HTTP 401 via ${MANAGEMENT_ROUTE}: no session, NUXT_PUBLIC_THUNDERID_CLIENT_ID is not set`; + case 403: + return 'HTTP 403: the access token lacks the permission for this collection'; + case 400: + return `HTTP 400: ${MANAGEMENT_ROUTE} refused the URL (not a configured management collection)`; + case 404: + return 'HTTP 404: the management API has no such collection or resource'; + case 502: + return `HTTP 502: ${MANAGEMENT_ROUTE} could not reach the management API`; + default: + return `HTTP ${String(error.statusCode ?? 0)} ${error.message}`; + } +}; + +/** + * Runs `call` and returns the requests the browser made to the module's management route meanwhile, + * plus any made straight to another origin. Uses a PerformanceObserver, which still sees entries after + * the resource timing buffer is full (it fills quickly in dev, where every module is a request). + */ +export const observeManagementRequests = async ( + call: () => Promise, +): Promise<{direct: string[]; relayed: number; result: T | undefined; error: unknown}> => { + const names: string[] = []; + const observer: PerformanceObserver | null = + typeof PerformanceObserver === 'undefined' + ? null + : new PerformanceObserver((list: PerformanceObserverEntryList) => { + list.getEntries().forEach((entry: PerformanceEntry) => names.push(entry.name)); + }); + + observer?.observe({type: 'resource'}); + + let result: T | undefined; + let error: unknown; + + try { + result = await call(); + } catch (caught) { + error = caught; + } + + // Resource entries are delivered after the response ends. + await new Promise((resolve: (value: unknown) => void) => { + setTimeout(resolve, 300); + }); + observer?.takeRecords().forEach((entry: PerformanceEntry) => names.push(entry.name)); + observer?.disconnect(); + + return { + direct: names.filter((name: string) => new URL(name).origin !== window.location.origin), + error, + relayed: names.filter((name: string) => new URL(name).pathname === MANAGEMENT_ROUTE).length, + result, + }; +}; + +/** The management API config the core functions take, with the fetcher the module installs. */ +export const requestConfig = ( + endpoint: ManagementEndpoint, + fetcher: ApiFetcher | undefined, +): ManagementEndpoint & {fetcher?: ApiFetcher} => ({...endpoint, ...(fetcher ? {fetcher} : {})}); diff --git a/playgrounds/nuxt/lib/primeRenderers.ts b/playgrounds/nuxt/lib/primeRenderers.ts new file mode 100644 index 00000000..1af3457e --- /dev/null +++ b/playgrounds/nuxt/lib/primeRenderers.ts @@ -0,0 +1,164 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import type {ManagedUser, MembershipGroup} from '@thunderid/browser'; +import { + type AdminListRenderProps, + type BaseGroupCreateRenderProps, + type BaseUserDetailsRenderProps, + type DataTableColumn, + useTheme, +} from '@thunderid/vue'; +import Avatar from 'primevue/avatar'; +import Button from 'primevue/button'; +import Card from 'primevue/card'; +import Chip from 'primevue/chip'; +import Column from 'primevue/column'; +import DataTable, {type DataTablePageEvent, type DataTableRowSelectEvent} from 'primevue/datatable'; +import InputText from 'primevue/inputtext'; +import Message from 'primevue/message'; +import Textarea from 'primevue/textarea'; +import {defineComponent, h, onBeforeUnmount, watchEffect, type Component, type VNode, type VNodeChild} from 'vue'; + +/** + * Follows the playground's light or dark preview with PrimeVue's palette. PrimeVue resolves its + * component tokens on the root element, so the dark mode class goes on ``. + */ +export const PrimeTheme: Component = defineComponent({ + name: 'PrimeTheme', + setup(_props: Record, {slots}: {slots: {default?: () => VNodeChild}}): () => VNode { + const {colorScheme} = useTheme(); + + watchEffect(() => document.documentElement.classList.toggle('pg-prime-dark', colorScheme.value === 'dark')); + onBeforeUnmount(() => document.documentElement.classList.remove('pg-prime-dark')); + + return (): VNode => h('div', {class: 'pg-prime-root'}, slots.default?.() as VNode[]); + }, +}); + +/** + * The user list's default scoped slot drawn as a PrimeVue DataTable: the SDK's columns become table + * columns, and the table's lazy paging maps onto the SDK's offset-based paging. + */ +export const userTable = (state: AdminListRenderProps): VNode => + h('div', {class: 'pg-prime-stack'}, [ + state.error ? h(Message, {severity: 'error'}, {default: () => state.error}) : null, + h( + DataTable, + { + dataKey: 'id', + first: state.offset, + lazy: true, + loading: state.isLoading, + onPage: (event: DataTablePageEvent) => state.onPageChange?.(event.first), + onRowSelect: (event: DataTableRowSelectEvent) => state.onSelect?.(event.data as ManagedUser), + paginator: Boolean(state.onPageChange), + rows: state.limit, + selection: state.rows.find((row: ManagedUser) => row.id === state.selectedRowId), + selectionMode: state.onSelect ? 'single' : undefined, + size: 'small', + totalRecords: state.totalResults, + value: state.rows, + }, + { + default: () => + state.columns.map((column: DataTableColumn) => + h( + Column, + {field: column.id, header: column.header, key: column.id}, + { + body: ({data}: {data: ManagedUser}) => column.render(data), + }, + ), + ), + }, + ), + ]); + +/** The group form's default scoped slot drawn with PrimeVue inputs. */ +export const groupForm = (state: BaseGroupCreateRenderProps): VNode => + h( + Card, + {}, + { + content: () => + h('div', {class: 'pg-prime-stack'}, [ + state.error ? h(Message, {severity: 'error'}, {default: () => state.error}) : null, + h('label', {class: 'pg-prime-field'}, [ + h('span', 'Name *'), + h(InputText, { + modelValue: state.values.name, + 'onUpdate:modelValue': (value: string | undefined) => state.handleInputChange('name', value ?? ''), + }), + ]), + h('label', {class: 'pg-prime-field'}, [ + h('span', 'Description'), + h(Textarea, { + autoResize: true, + modelValue: state.values.description, + 'onUpdate:modelValue': (value: string) => state.handleInputChange('description', value), + rows: 2, + }), + ]), + ]), + footer: () => + h('div', {class: 'pg-prime-actions'}, [ + state.onCancel + ? h(Button, {label: 'Cancel', onClick: state.onCancel, severity: 'secondary', text: true}) + : null, + h(Button, { + disabled: !state.isValid || state.isSubmitting, + label: 'Create group', + loading: state.isSubmitting, + onClick: state.handleSubmit, + }), + ]), + subtitle: () => "Rendered with PrimeVue from the SDK's default scoped slot", + title: () => 'New group', + }, + ); + +/** The user details' default scoped slot drawn as a PrimeVue card. */ +export const userCard = (state: BaseUserDetailsRenderProps): VNode => + h( + Card, + {}, + { + content: () => + h('div', {class: 'pg-prime-stack'}, [ + state.error ? h(Message, {severity: 'error'}, {default: () => state.error}) : null, + h( + 'div', + {class: 'pg-prime-chips'}, + Object.entries(state.attributes).map(([name, value]: [string, unknown]) => + h(Chip, {key: name, label: `${name}: ${String(value)}`}), + ), + ), + h( + 'div', + {class: 'pg-prime-chips'}, + state.groups.map((group: MembershipGroup) => + h(Chip, {class: 'pg-prime-group', key: group.id, label: group.name}), + ), + ), + ]), + footer: () => + state.onDelete + ? h('div', {class: 'pg-prime-actions'}, [ + h(Button, { + label: 'Delete', + loading: state.isDeleting, + onClick: state.onDelete, + severity: 'danger', + text: true, + }), + ]) + : null, + subtitle: () => (state.user ? `${state.user.type} · ${state.user.ouHandle ?? state.user.ouId}` : ''), + title: () => + h('div', {class: 'pg-prime-title'}, [ + h(Avatar, {label: state.displayName.slice(0, 1), shape: 'circle'}), + state.isLoading && !state.user ? 'Loading…' : state.displayName, + ]), + }, + ); diff --git a/playgrounds/nuxt/lib/serverRoutes.ts b/playgrounds/nuxt/lib/serverRoutes.ts index c8857a3e..10ca43df 100644 --- a/playgrounds/nuxt/lib/serverRoutes.ts +++ b/playgrounds/nuxt/lib/serverRoutes.ts @@ -30,7 +30,7 @@ const moduleRoute = (route: Omit & {file: string return {...rest, group: 'Module routes', handler: `${MODULE} · runtime/server/routes/auth/${file}`}; }; -export const buildRoutes = (applicationId: string | undefined): RouteSpec[] => [ +export const buildRoutes = (applicationId: string | undefined, groupsUrl: string): RouteSpec[] => [ moduleRoute({ description: 'Verifies the session cookie and returns {isSignedIn, user, isLoading}. The client calls it to refresh auth state after an app-native sign-in.', @@ -160,6 +160,18 @@ export const buildRoutes = (applicationId: string | undefined): RouteSpec[] => [ path: '/api/auth/user-types', summary: 'User type picker options.', }), + moduleRoute({ + body: JSON.stringify({method: 'GET', url: `${groupsUrl}?limit=5`}, null, 2), + contentType: 'application/json', + description: + "The http.fetcher of the management composables and admin components. Performs one management request with the session's access token, only to a configured management collection (users, user types, groups, roles, agents, agent types, honoring endpoints overrides). Always 200 with {body, status, statusText}: a foreign URL reports 400, no session 401. On with management.", + file: 'management.post.ts', + id: 'management', + locked: true, + method: 'POST', + path: '/api/auth/management', + summary: 'Management relay.', + }), moduleRoute({ body: 'logout_token=not-a-real-token', contentType: 'application/x-www-form-urlencoded', diff --git a/playgrounds/nuxt/lib/stories.ts b/playgrounds/nuxt/lib/stories.ts index 763ee1fa..ba39d141 100644 --- a/playgrounds/nuxt/lib/stories.ts +++ b/playgrounds/nuxt/lib/stories.ts @@ -20,6 +20,9 @@ import { Typography, } from '@thunderid/vue'; import {defineComponent, h, ref, type Component, type PropType, type Ref, type VNode} from 'vue'; +import {ADMIN_COMPONENTS} from './adminStories'; +import {CUSTOM_RENDER_COMPONENTS} from './customRenderStories'; +import {tag} from './storyCode'; import type {Action, Args, ArgValue, ComponentDef, PropControl} from './storyTypes'; import {CheckboxStory, narrow, OtpFieldStory, PasswordFieldStory, SelectStory, TextFieldStory} from './storyWidgets'; import {getDisplayName} from './user'; @@ -44,43 +47,6 @@ import { const str = (args: Args, key: string): string => String(args[key] ?? ''); const bool = (args: Args, key: string): boolean => args[key] === true; -const kebab = (name: string): string => name.replace(/[A-Z]/g, (char: string) => `-${char.toLowerCase()}`); - -const attr = (key: string, value: ArgValue): string => { - const name: string = kebab(key); - - if (value === true) return name; - if (value === false || typeof value === 'number') return `:${name}="${String(value)}"`; - - return `${name}="${value.replace(/"/g, '"')}"`; -}; - -/** - * Builds a template element from the props that differ from the component's own defaults. - */ -export const tag = ( - name: string, - props: Args, - options: {children?: string; defaults?: Args; extra?: string[]} = {}, -): string => { - const parts: string[] = Object.entries(props) - .filter( - ([key, value]: [string, ArgValue]) => - value !== '' && - options.defaults?.[key] !== value && - !(value === false && options.defaults?.[key] === undefined), - ) - .map(([key, value]: [string, ArgValue]) => attr(key, value)) - .concat(options.extra ?? []); - const inline: string = parts.length ? ` ${parts.join(' ')}` : ''; - const multiline: boolean = inline.length > 70; - const attrs: string = multiline ? `\n ${parts.join('\n ')}\n` : inline; - - if (options.children) return `<${name}${attrs}>${options.children}`; - - return `<${name}${attrs}${multiline ? '' : ' '}/>`; -}; - const omit = (args: Args, ...keys: string[]): Args => Object.fromEntries(Object.entries(args).filter(([key]: [string, ArgValue]) => !keys.includes(key))); @@ -887,6 +853,8 @@ export const COMPONENTS: ComponentDef[] = [ 'GET /api/auth/user-types', 'userTypes', ), + ...ADMIN_COMPONENTS, + ...CUSTOM_RENDER_COMPONENTS, ]; export const STORY_COUNT: number = COMPONENTS.reduce( @@ -900,8 +868,12 @@ export const GROUPS: string[] = Array.from(new Set(COMPONENTS.map((component: Co export const buildCode = (component: ComponentDef, args: Args): string => { const snippet = component.code(args); const imports: string[] = Array.from(new Set(snippet.imports)).sort(); + const vueImports: string[] = ['computed', 'ref'].filter((name: string) => + (snippet.script ?? []).some((line: string) => line.includes(`${name}(`)), + ); const script: string[] = [ - ...(snippet.script?.length ? ["import {ref} from 'vue';"] : []), + ...(vueImports.length ? [`import {${vueImports.join(', ')}} from 'vue';`] : []), + ...(snippet.otherImports ?? []), ...(imports.length ? [`import {${imports.join(', ')}} from '@thunderid/vue';`] : []), ]; const body: string[] = snippet.script ?? []; @@ -910,7 +882,7 @@ export const buildCode = (component: ComponentDef, args: Args): string => { .split('\n') .map((line: string) => ` ${line}`) .join('\n'); - const autoImported = ''; + const autoImported = ''; return `${scriptBlock ? `\n\n` : ''}