diff --git a/packages/better-auth/package.json b/packages/better-auth/package.json index 5298097d..47a4ae58 100644 --- a/packages/better-auth/package.json +++ b/packages/better-auth/package.json @@ -51,7 +51,7 @@ "@thunderid/eslint-plugin": "catalog:", "@thunderid/prettier-config": "catalog:", "@types/node": "catalog:", - "better-auth": "^1.7.2", + "better-auth": "^1.7.13", "eslint": "catalog:", "prettier": "catalog:", "rimraf": "catalog:", diff --git a/packages/better-auth/src/__tests__/thunderid.test.ts b/packages/better-auth/src/__tests__/thunderid.test.ts index 3dedff1f..24297b97 100644 --- a/packages/better-auth/src/__tests__/thunderid.test.ts +++ b/packages/better-auth/src/__tests__/thunderid.test.ts @@ -15,7 +15,7 @@ describe('thunderid', () => { }); expect(config.providerId).toBe('thunderid'); - expect(config.accountIssuer).toBe('https://thunderid.example.com'); + expect(config.requireIdTokenVerification).toBe(true); expect(config.discoveryUrl).toBe('https://thunderid.example.com/.well-known/openid-configuration'); expect(config.scopes).toEqual(['openid', 'profile', 'email']); expect(config.clientId).toBe('thunderid-client-id'); @@ -30,7 +30,6 @@ describe('thunderid', () => { issuer: 'https://thunderid.example.com/', }); - expect(config.accountIssuer).toBe('https://thunderid.example.com'); expect(config.discoveryUrl).toBe('https://thunderid.example.com/.well-known/openid-configuration'); }); diff --git a/packages/better-auth/src/thunderid.ts b/packages/better-auth/src/thunderid.ts index 656ab091..1a5c9215 100644 --- a/packages/better-auth/src/thunderid.ts +++ b/packages/better-auth/src/thunderid.ts @@ -57,8 +57,10 @@ export function thunderid(options: ThunderIDOptions): GenericOAuthConfig<'thunde return { providerId: 'thunderid', - accountIssuer: issuer, discoveryUrl: `${issuer}/.well-known/openid-configuration`, + // Without the discovered issuer and keys, ID tokens could not be verified, so the provider is + // not registered rather than signing users in on unverified tokens. + requireIdTokenVerification: true, clientId: options.clientId, clientSecret: options.clientSecret, tokenEndpointAuth: options.tokenEndpointAuth, diff --git a/packages/javascript/src/api/management/__tests__/administrationApi.test.ts b/packages/javascript/src/api/management/__tests__/administrationApi.test.ts new file mode 100644 index 00000000..49d83945 --- /dev/null +++ b/packages/javascript/src/api/management/__tests__/administrationApi.test.ts @@ -0,0 +1,510 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {describe, it, expect, vi, type Mock} from 'vitest'; +import AdministrationFlowError from '../../../errors/AdministrationFlowError'; +import {ApiFetcher} from '../../../models/api'; +import findAdministrationFlows from '../administration/findAdministrationFlows'; +import getAgentType from '../agent-types/getAgentType'; +import updateAgentType from '../agent-types/updateAgentType'; +import regenerateAgentSecret from '../agents/regenerateAgentSecret'; +import deleteApplication from '../applications/deleteApplication'; +import previewCimdDocument from '../applications/previewCimdDocument'; +import regenerateClientSecret from '../applications/regenerateClientSecret'; +import regenerateFlowSecret from '../applications/regenerateFlowSecret'; +import deleteGroup from '../groups/deleteGroup'; +import removeGroupMembers from '../groups/removeGroupMembers'; +import deleteRole from '../roles/deleteRole'; +import removeRoleAssignments from '../roles/removeRoleAssignments'; +import updateRole from '../roles/updateRole'; +import createUserType from '../user-types/createUserType'; +import deleteUserType from '../user-types/deleteUserType'; +import getUserType from '../user-types/getUserType'; +import updateUserType from '../user-types/updateUserType'; +import deleteUser from '../users/deleteUser'; +import getUserUsages from '../users/getUserUsages'; +import updateUserCredentials from '../users/updateUserCredentials'; + +type FetcherMock = Mock; + +interface SentRequest { + body: unknown; + method: string | undefined; + url: string; +} + +const baseUrl = 'https://localhost:8090'; + +const respond = (body: unknown): Response => + ({ + json: () => Promise.resolve(body), + ok: true, + status: 200, + statusText: 'OK', + text: () => Promise.resolve(JSON.stringify(body)), + }) as Response; + +interface Server { + /** + * The `flow` server-config section: the configured handle for each operation. + */ + config?: Record; + /** + * The response of `POST /flow/execute`. + */ + execution?: unknown; + /** + * The administration flows the listing returns. + */ + flows?: {flowType: string; handle: string; id: string}[]; + /** + * The response of any other request. + */ + resource?: unknown; +} + +/** + * A fetcher that answers like the server: the flow configuration, the flow listing, flow execution, and + * a resource for every other request. + */ +const createServer = ({config = {}, execution, flows = [], resource = {}}: Server = {}): FetcherMock => + vi.fn((url: string) => { + if (url === `${baseUrl}/server-config/flow`) { + return Promise.resolve(respond({merged: config})); + } + if (url.startsWith(`${baseUrl}/flows?`)) { + return Promise.resolve(respond({flows})); + } + if (url === `${baseUrl}/flow/execute`) { + return Promise.resolve(respond(execution ?? {data: {additionalData: {}}, flowStatus: 'COMPLETE'})); + } + + return Promise.resolve(respond(resource)); + }); + +const requests = (fetcher: FetcherMock): SentRequest[] => + fetcher.mock.calls.map(([url, init]: Parameters) => ({ + body: init?.body ? (JSON.parse(init.body as string) as unknown) : undefined, + method: init?.method, + url, + })); + +const executions = (fetcher: FetcherMock): unknown[] => + requests(fetcher) + .filter((request: SentRequest) => request.url === `${baseUrl}/flow/execute`) + .map((request: SentRequest) => request.body); + +const deletionFlow = { + config: {userDeletionFlow: {defaultHandle: 'delete-user'}}, + flows: [{flowType: 'ADMINISTRATION', handle: 'delete-user', id: 'flow-1'}], +}; + +describe('management API additions', () => { + it.each<[string, (fetcher: ApiFetcher) => Promise, string, string, unknown]>([ + [ + 'getUserUsages', + (f) => getUserUsages({baseUrl, fetcher: f, userId: 'u-1'}), + 'GET', + '/users/u-1/usages', + undefined, + ], + [ + 'updateUserCredentials', + (f) => updateUserCredentials({baseUrl, fetcher: f, payload: {credentials: {password: 'p'}}, userId: 'u-1'}), + 'POST', + '/users/u-1/update-credentials', + {credentials: {password: 'p'}}, + ], + [ + 'getUserType', + (f) => getUserType({baseUrl, fetcher: f, userTypeId: 't-1'}), + 'GET', + '/user-types/t-1?include=display', + undefined, + ], + [ + 'createUserType', + (f) => + createUserType({ + baseUrl, + fetcher: f, + payload: {displayName: 'Staff', handle: 'staff', ouId: 'ou-1', schema: {}}, + }), + 'POST', + '/user-types', + {displayName: 'Staff', handle: 'staff', ouId: 'ou-1', schema: {}}, + ], + [ + 'updateUserType', + (f) => + updateUserType({ + baseUrl, + fetcher: f, + payload: {displayName: 'Staff', ouId: 'ou-1', schema: {}}, + userTypeId: 't-1', + }), + 'PUT', + '/user-types/t-1', + {displayName: 'Staff', ouId: 'ou-1', schema: {}}, + ], + [ + 'deleteUserType', + (f) => deleteUserType({baseUrl, fetcher: f, userTypeId: 't-1'}), + 'DELETE', + '/user-types/t-1', + undefined, + ], + [ + 'getAgentType', + (f) => getAgentType({agentTypeId: 'a-1', baseUrl, fetcher: f}), + 'GET', + '/agent-types/a-1?include=display', + undefined, + ], + [ + 'updateAgentType', + (f) => + updateAgentType({ + agentTypeId: 'a-1', + baseUrl, + fetcher: f, + payload: {displayName: 'Bot', ouId: 'ou-1', schema: {}}, + }), + 'PUT', + '/agent-types/a-1', + {displayName: 'Bot', ouId: 'ou-1', schema: {}}, + ], + [ + 'previewCimdDocument', + (f) => previewCimdDocument({baseUrl, clientId: 'https://client.example/cimd.json', fetcher: f}), + 'POST', + '/cimd/preview', + {clientId: 'https://client.example/cimd.json'}, + ], + // The applications collection URL a caller passes does not move the preview endpoint. + [ + 'previewCimdDocument with an applications url', + (f) => + previewCimdDocument({ + baseUrl, + clientId: 'https://client.example/cimd.json', + fetcher: f, + url: 'https://rs.example.com/applications', + }), + 'POST', + '/cimd/preview', + {clientId: 'https://client.example/cimd.json'}, + ], + ])('%s calls the right endpoint', async (_name, run, method, path, body) => { + const fetcher: FetcherMock = createServer(); + + await run(fetcher); + + expect(requests(fetcher)).toEqual([{body, method, url: `${baseUrl}${path}`}]); + }); +}); + +describe('administration flows', () => { + it('calls the API directly by default, without reading the flow configuration', async () => { + const fetcher: FetcherMock = createServer(deletionFlow); + + await deleteUser({baseUrl, fetcher, userId: 'u-1'}); + + expect(requests(fetcher).map((request: SentRequest) => `${request.method} ${request.url}`)).toEqual([ + `DELETE ${baseUrl}/users/u-1`, + ]); + }); + + it('runs the configured flow in flow mode instead of calling the API', async () => { + const fetcher: FetcherMock = createServer(deletionFlow); + + await deleteUser({administrationMode: 'flow', baseUrl, fetcher, userId: 'u-1'}); + + expect(executions(fetcher)).toEqual([{flowId: 'flow-1', inputs: {subject: 'u-1'}}]); + expect(requests(fetcher).some((request: SentRequest) => request.method === 'DELETE')).toBe(false); + }); + + it('calls the API directly in flow mode when no flow is configured', async () => { + const fetcher: FetcherMock = createServer(); + + await deleteGroup({administrationMode: 'flow', baseUrl, fetcher, groupId: 'g-1'}); + + expect(executions(fetcher)).toEqual([]); + expect(requests(fetcher).at(-1)).toMatchObject({method: 'DELETE', url: `${baseUrl}/groups/g-1`}); + }); + + it('refuses when the configured flow no longer exists, rather than skipping the revocation', async () => { + const fetcher: FetcherMock = createServer({config: {roleDeletionFlow: {defaultHandle: 'gone'}}}); + + await expect(deleteRole({administrationMode: 'flow', baseUrl, fetcher, roleId: 'r-1'})).rejects.toBeInstanceOf( + AdministrationFlowError, + ); + expect(requests(fetcher).some((request: SentRequest) => request.method === 'DELETE')).toBe(false); + }); + + it("reports a refused step with the step's own code", async () => { + const fetcher: FetcherMock = createServer({ + config: {applicationDeletionFlow: {defaultHandle: 'delete-app'}}, + execution: {error: {code: 'FLW-4001', message: {defaultValue: 'Not allowed'}}, flowStatus: 'ERROR'}, + flows: [{flowType: 'ADMINISTRATION', handle: 'delete-app', id: 'flow-2'}], + }); + + const error: unknown = await deleteApplication({ + administrationMode: 'flow', + applicationId: 'app-1', + baseUrl, + fetcher, + }).catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(AdministrationFlowError); + expect(error).toMatchObject({code: 'FLW-4001', flowError: {code: 'FLW-4001'}}); + expect((error as AdministrationFlowError).message).toContain('Not allowed'); + }); + + it('refuses a flow that asks for more input', async () => { + const fetcher: FetcherMock = createServer({...deletionFlow, execution: {flowStatus: 'INCOMPLETE'}}); + + await expect(deleteUser({administrationMode: 'flow', baseUrl, fetcher, userId: 'u-1'})).rejects.toBeInstanceOf( + AdministrationFlowError, + ); + }); + + it('needs the server URL to run a flow', async () => { + const fetcher: FetcherMock = createServer(deletionFlow); + + await expect( + deleteUser({administrationMode: 'flow', fetcher, url: `${baseUrl}/users`, userId: 'u-1'}), + ).rejects.toMatchObject({code: 'resolveAdministrationFlowHandles-ValidationError-003'}); + }); + + it('runs the removal flow once for each member, and for each assignee', async () => { + const fetcher: FetcherMock = createServer({ + config: { + groupMembershipRemovalFlow: {defaultHandle: 'remove-member'}, + roleAssignmentRemovalFlow: {defaultHandle: 'remove-assignment'}, + }, + flows: [ + {flowType: 'ADMINISTRATION', handle: 'remove-member', id: 'flow-m'}, + {flowType: 'ADMINISTRATION', handle: 'remove-assignment', id: 'flow-a'}, + ], + }); + + await removeGroupMembers({ + administrationMode: 'flow', + baseUrl, + fetcher, + groupId: 'g-1', + members: [ + {id: 'u-1', type: 'user'}, + {id: 'u-2', type: 'user'}, + ], + }); + await removeRoleAssignments({ + administrationMode: 'flow', + assignments: [{id: 'g-1', type: 'group'}], + baseUrl, + fetcher, + roleId: 'r-1', + }); + + expect( + requests(fetcher).find((request: SentRequest) => request.url.startsWith(`${baseUrl}/groups/g-1`)), + ).toMatchObject({method: 'GET'}); + expect(executions(fetcher)).toEqual([ + {flowId: 'flow-m', inputs: {targetGroupId: 'g-1', targetMemberId: 'u-1'}}, + {flowId: 'flow-m', inputs: {targetGroupId: 'g-1', targetMemberId: 'u-2'}}, + {flowId: 'flow-a', inputs: {targetAssigneeId: 'g-1', targetRoleId: 'r-1'}}, + ]); + }); + + it('walks the flow listing page by page until the handle is found', async () => { + const firstPage: {flowType: string; handle: string; id: string}[] = Array.from( + {length: 100}, + (_, index: number) => ({ + flowType: 'ADMINISTRATION', + handle: `flow-${index}`, + id: `id-${index}`, + }), + ); + const fetcher: FetcherMock = vi.fn((url: string) => + Promise.resolve( + respond({ + flows: url.includes('offset=0') ? firstPage : [{flowType: 'ADMINISTRATION', handle: 'late', id: 'id-late'}], + }), + ), + ); + + const found: Record = await findAdministrationFlows({baseUrl, fetcher}, ['late']); + + expect(found['late'].id).toBe('id-late'); + expect(fetcher).toHaveBeenCalledTimes(2); + }); +}); + +describe('updateRole in flow mode', () => { + const role = { + id: 'r-1', + name: 'Viewer', + ouId: 'ou-1', + permissions: [{permissions: ['read', 'write'], resourceServerId: 'rs-1'}], + }; + const flow = { + config: {rolePermissionRemovalFlow: {defaultHandle: 'remove-permission'}}, + flows: [{flowType: 'ADMINISTRATION', handle: 'remove-permission', id: 'flow-p'}], + resource: role, + }; + + it('saves directly when no permission is removed', async () => { + const fetcher: FetcherMock = createServer(flow); + + await updateRole({ + administrationMode: 'flow', + baseUrl, + current: role, + fetcher, + payload: {...role, permissions: [{permissions: ['read', 'write', 'admin'], resourceServerId: 'rs-1'}]}, + roleId: 'r-1', + }); + + expect(executions(fetcher)).toEqual([]); + expect(requests(fetcher).at(-1)).toMatchObject({method: 'PUT', url: `${baseUrl}/roles/r-1`}); + }); + + it('runs the flow for a removed permission, after saving the other fields with the old permissions', async () => { + const fetcher: FetcherMock = createServer(flow); + const permissions = [{permissions: ['read'], resourceServerId: 'rs-1'}]; + + const updated: unknown = await updateRole({ + administrationMode: 'flow', + baseUrl, + fetcher, + payload: {name: 'Reader', ouId: 'ou-1', permissions}, + roleId: 'r-1', + }); + + const sent: SentRequest[] = requests(fetcher); + const put: SentRequest | undefined = sent.find((request: SentRequest) => request.method === 'PUT'); + expect(sent[0]).toMatchObject({method: 'GET', url: `${baseUrl}/roles/r-1?include=display`}); + expect(put?.body).toEqual({name: 'Reader', ouId: 'ou-1', permissions: role.permissions}); + expect(executions(fetcher)).toEqual([ + {flowId: 'flow-p', inputs: {targetPermissions: JSON.stringify(permissions), targetRoleId: 'r-1'}}, + ]); + expect(updated).toMatchObject({permissions}); + }); +}); + +describe('secret regeneration', () => { + const application = { + createdAt: 'yesterday', + id: 'app-1', + inboundAuthConfig: [{config: {clientId: 'client', clientSecret: 'old'}, type: 'oauth2'}], + name: 'Shop', + }; + + it('returns the secret a regeneration flow produced', async () => { + const fetcher: FetcherMock = createServer({ + config: {secretRegenerationFlow: {defaultHandle: 'rotate'}}, + execution: {data: {additionalData: {clientSecret: 'from-flow'}}, flowStatus: 'COMPLETE'}, + flows: [{flowType: 'ADMINISTRATION', handle: 'rotate', id: 'flow-s'}], + }); + + await expect( + regenerateClientSecret({administrationMode: 'flow', applicationId: 'app-1', baseUrl, fetcher}), + ).resolves.toEqual({clientSecret: 'from-flow'}); + }); + + it('refuses a regeneration flow that returns no secret', async () => { + const fetcher: FetcherMock = createServer({ + config: {secretRegenerationFlow: {defaultHandle: 'rotate'}}, + flows: [{flowType: 'ADMINISTRATION', handle: 'rotate', id: 'flow-s'}], + }); + + await expect( + regenerateClientSecret({administrationMode: 'flow', applicationId: 'app-1', baseUrl, fetcher}), + ).rejects.toBeInstanceOf(AdministrationFlowError); + }); + + it('otherwise saves a newly generated client secret on the application', async () => { + const fetcher: FetcherMock = createServer({resource: application}); + + const {clientSecret} = await regenerateClientSecret({applicationId: 'app-1', baseUrl, fetcher}); + + const put: SentRequest | undefined = requests(fetcher).find((request: SentRequest) => request.method === 'PUT'); + expect(clientSecret).toMatch(/^[A-Za-z0-9_-]{43}$/); + expect(put?.body).toEqual({ + inboundAuthConfig: [{config: {clientId: 'client', clientSecret}, type: 'oauth2'}], + name: 'Shop', + }); + }); + + it('refuses to regenerate a client secret for an application without an OAuth client', async () => { + const fetcher: FetcherMock = createServer({resource: {id: 'app-1', inboundAuthConfig: [], name: 'Shop'}}); + + await expect(regenerateClientSecret({applicationId: 'app-1', baseUrl, fetcher})).rejects.toMatchObject({ + code: 'regenerateClientSecret-ValidationError-001', + }); + expect(requests(fetcher).some((request: SentRequest) => request.method === 'PUT')).toBe(false); + }); + + it('saves a newly generated flow secret on the application', async () => { + const fetcher: FetcherMock = createServer({resource: application}); + + const {flowSecret} = await regenerateFlowSecret({applicationId: 'app-1', baseUrl, fetcher}); + + const put: SentRequest | undefined = requests(fetcher).find((request: SentRequest) => request.method === 'PUT'); + expect(put?.body).toMatchObject({flowSecret, name: 'Shop'}); + expect(put?.body).not.toHaveProperty('id'); + }); + + it("saves a newly generated secret on the agent's OAuth client", async () => { + const fetcher: FetcherMock = createServer({ + resource: { + clientId: 'client', + id: 'a-1', + inboundAuthConfig: [{config: {grantTypes: ['client_credentials']}, type: 'oauth2'}], + name: 'Bot', + ouHandle: 'default', + ouId: 'ou-1', + type: 'bot', + }, + }); + + const {clientSecret} = await regenerateAgentSecret({agentId: 'a-1', baseUrl, fetcher}); + + const put: SentRequest | undefined = requests(fetcher).find((request: SentRequest) => request.method === 'PUT'); + // The display-only organization unit handle stays out of the update. + expect(put?.body).toEqual({ + delegated: false, + inboundAuthConfig: [{config: {clientSecret, grantTypes: ['client_credentials']}, type: 'oauth2'}], + name: 'Bot', + ouId: 'ou-1', + type: 'bot', + }); + }); + + // The server does not store the delegated flag, so the update restates it from the grant types. + it('keeps a delegated agent held to the delegated grant rules', async () => { + const fetcher: FetcherMock = createServer({ + resource: { + id: 'a-1', + inboundAuthConfig: [{config: {grantTypes: ['authorization_code', 'refresh_token']}, type: 'oauth2'}], + name: 'Assistant', + ouId: 'ou-1', + type: 'bot', + }, + }); + + await regenerateAgentSecret({agentId: 'a-1', baseUrl, fetcher}); + + const put: SentRequest | undefined = requests(fetcher).find((request: SentRequest) => request.method === 'PUT'); + expect(put?.body).toMatchObject({delegated: true}); + }); + + it('refuses to regenerate the secret of an agent with no OAuth client', async () => { + const fetcher: FetcherMock = createServer({resource: {id: 'a-1', name: 'Bot', ouId: 'ou-1', type: 'bot'}}); + + await expect(regenerateAgentSecret({agentId: 'a-1', baseUrl, fetcher})).rejects.toMatchObject({ + code: 'regenerateAgentSecret-ValidationError-001', + }); + }); +}); diff --git a/packages/javascript/src/api/management/__tests__/configurationApi.test.ts b/packages/javascript/src/api/management/__tests__/configurationApi.test.ts new file mode 100644 index 00000000..8d1b9730 --- /dev/null +++ b/packages/javascript/src/api/management/__tests__/configurationApi.test.ts @@ -0,0 +1,513 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {describe, expect, it} from 'vitest'; +import { + baseUrl, + createFetcher, + FetcherMock, + itMapsForbiddenAndNotFound, + itSendsRequests, + requests, +} from './helpers/managementRequests'; +import createConfigurationVersion from '../configuration/createConfigurationVersion'; +import deleteImportedConfiguration from '../configuration/deleteImportedConfiguration'; +import exportConfiguration from '../configuration/exportConfiguration'; +import getConfigurationVersion from '../configuration/getConfigurationVersion'; +import getConfigurationVersions from '../configuration/getConfigurationVersions'; +import importConfiguration from '../configuration/importConfiguration'; +import createDesignTheme from '../design/createDesignTheme'; +import createLayout from '../design/createLayout'; +import deleteDesignTheme from '../design/deleteDesignTheme'; +import deleteLayout from '../design/deleteLayout'; +import getDesignTheme from '../design/getDesignTheme'; +import getDesignThemes from '../design/getDesignThemes'; +import getDesignThemeUsages from '../design/getDesignThemeUsages'; +import getLayout from '../design/getLayout'; +import getLayouts from '../design/getLayouts'; +import getLayoutUsages from '../design/getLayoutUsages'; +import resolveDesign from '../design/resolveDesign'; +import updateDesignTheme from '../design/updateDesignTheme'; +import updateLayout from '../design/updateLayout'; +import applyGatewayConfiguration from '../gateways/applyGatewayConfiguration'; +import createGateway from '../gateways/createGateway'; +import createGatewaySecret from '../gateways/createGatewaySecret'; +import createGatewayVariable from '../gateways/createGatewayVariable'; +import deleteGateway from '../gateways/deleteGateway'; +import deleteGatewaySecret from '../gateways/deleteGatewaySecret'; +import deleteGatewayVariable from '../gateways/deleteGatewayVariable'; +import getGateway from '../gateways/getGateway'; +import getGatewayAppliedConfiguration from '../gateways/getGatewayAppliedConfiguration'; +import getGatewayAppliedVersion from '../gateways/getGatewayAppliedVersion'; +import getGatewayConfigurationDiff from '../gateways/getGatewayConfigurationDiff'; +import getGateways from '../gateways/getGateways'; +import getGatewaySecret from '../gateways/getGatewaySecret'; +import getGatewaySecrets from '../gateways/getGatewaySecrets'; +import getGatewayVariable from '../gateways/getGatewayVariable'; +import getGatewayVariables from '../gateways/getGatewayVariables'; +import revertGatewayConfiguration from '../gateways/revertGatewayConfiguration'; +import updateGateway from '../gateways/updateGateway'; +import updateGatewaySecret from '../gateways/updateGatewaySecret'; +import updateGatewayVariable from '../gateways/updateGatewayVariable'; +import deleteTranslation from '../i18n/deleteTranslation'; +import deleteTranslations from '../i18n/deleteTranslations'; +import getLanguages from '../i18n/getLanguages'; +import resolveTranslation from '../i18n/resolveTranslation'; +import resolveTranslations from '../i18n/resolveTranslations'; +import setTranslation from '../i18n/setTranslation'; +import setTranslations from '../i18n/setTranslations'; +import createNotificationTemplate from '../notification-templates/createNotificationTemplate'; +import deleteNotificationTemplate from '../notification-templates/deleteNotificationTemplate'; +import getNotificationTemplate from '../notification-templates/getNotificationTemplate'; +import getNotificationTemplates from '../notification-templates/getNotificationTemplates'; +import updateNotificationTemplate from '../notification-templates/updateNotificationTemplate'; +import createSecret from '../secrets/createSecret'; +import deleteSecret from '../secrets/deleteSecret'; +import getSecret from '../secrets/getSecret'; +import getSecrets from '../secrets/getSecrets'; +import updateSecret from '../secrets/updateSecret'; +import getServerConfig from '../server-config/getServerConfig'; +import getServerConfigs from '../server-config/getServerConfigs'; +import updateServerConfig from '../server-config/updateServerConfig'; +import createVariable from '../variables/createVariable'; +import deleteVariable from '../variables/deleteVariable'; +import getVariable from '../variables/getVariable'; +import getVariables from '../variables/getVariables'; +import updateVariable from '../variables/updateVariable'; + +const sms = {content: {body: 'Code {{otp}}'}, displayName: 'OTP', handle: 'otp', ouId: 'ou-1'}; +const theme = {displayName: 'Brand', handle: 'brand', theme: {primary: '#000'}}; +const layout = {displayName: 'Centered', handle: 'centered', layout: {}}; +const gatewayId = 'gw-1'; + +describe('notification templates API', () => { + itSendsRequests([ + [ + 'getNotificationTemplates', + (fetcher) => getNotificationTemplates({baseUrl, channel: 'email', fetcher, limit: 5}), + 'GET', + '/notification-templates/email/templates?limit=5', + ], + [ + 'getNotificationTemplate', + (fetcher) => getNotificationTemplate({baseUrl, channel: 'sms', fetcher, templateId: 't-1'}), + 'GET', + '/notification-templates/sms/templates/t-1', + ], + [ + 'createNotificationTemplate', + (fetcher) => createNotificationTemplate({baseUrl, channel: 'sms', fetcher, payload: sms}), + 'POST', + '/notification-templates/sms/templates', + sms, + ], + [ + 'updateNotificationTemplate', + (fetcher) => + updateNotificationTemplate({ + baseUrl, + channel: 'sms', + fetcher, + payload: {content: sms.content, displayName: 'OTP'}, + templateId: 't-1', + }), + 'PUT', + '/notification-templates/sms/templates/t-1', + {content: sms.content, displayName: 'OTP'}, + ], + [ + 'deleteNotificationTemplate', + (fetcher) => deleteNotificationTemplate({baseUrl, channel: 'email', fetcher, templateId: 't-1'}), + 'DELETE', + '/notification-templates/email/templates/t-1', + ], + ]); + + itMapsForbiddenAndNotFound('getNotificationTemplate', (fetcher) => + getNotificationTemplate({baseUrl, channel: 'email', fetcher, templateId: 't-1'}), + ); +}); + +describe('design API', () => { + itSendsRequests([ + ['getDesignThemes', (fetcher) => getDesignThemes({baseUrl, fetcher, offset: 5}), 'GET', '/design/themes?offset=5'], + ['getDesignTheme', (fetcher) => getDesignTheme({baseUrl, fetcher, themeId: 'th-1'}), 'GET', '/design/themes/th-1'], + [ + 'createDesignTheme', + (fetcher) => createDesignTheme({baseUrl, fetcher, payload: theme}), + 'POST', + '/design/themes', + theme, + ], + [ + 'updateDesignTheme', + (fetcher) => updateDesignTheme({baseUrl, fetcher, payload: theme, themeId: 'th-1'}), + 'PUT', + '/design/themes/th-1', + theme, + ], + [ + 'deleteDesignTheme', + (fetcher) => deleteDesignTheme({baseUrl, fetcher, themeId: 'th-1'}), + 'DELETE', + '/design/themes/th-1', + ], + [ + 'getDesignThemeUsages', + (fetcher) => getDesignThemeUsages({baseUrl, fetcher, limit: 1, themeId: 'th-1'}), + 'GET', + '/design/themes/th-1/usages?limit=1', + ], + ['getLayouts', (fetcher) => getLayouts({baseUrl, fetcher}), 'GET', '/design/layouts'], + ['getLayout', (fetcher) => getLayout({baseUrl, fetcher, layoutId: 'l-1'}), 'GET', '/design/layouts/l-1'], + ['createLayout', (fetcher) => createLayout({baseUrl, fetcher, payload: layout}), 'POST', '/design/layouts', layout], + [ + 'updateLayout', + (fetcher) => updateLayout({baseUrl, fetcher, layoutId: 'l-1', payload: layout}), + 'PUT', + '/design/layouts/l-1', + layout, + ], + ['deleteLayout', (fetcher) => deleteLayout({baseUrl, fetcher, layoutId: 'l-1'}), 'DELETE', '/design/layouts/l-1'], + [ + 'getLayoutUsages', + (fetcher) => getLayoutUsages({baseUrl, fetcher, layoutId: 'l-1'}), + 'GET', + '/design/layouts/l-1/usages', + ], + [ + 'resolveDesign', + (fetcher) => resolveDesign({baseUrl, fetcher, id: 'app-1', type: 'APP'}), + 'GET', + '/design/resolve?id=app-1&type=APP', + ], + ]); + + itMapsForbiddenAndNotFound('getLayout', (fetcher) => getLayout({baseUrl, fetcher, layoutId: 'l-1'})); + + it('uses the design endpoint override as the collection URL', async () => { + const fetcher: FetcherMock = createFetcher(); + + await getDesignTheme({baseUrl, fetcher, themeId: 'th-1', url: 'https://rs.example.com/design'}); + + expect(requests(fetcher)[0].url).toBe('https://rs.example.com/design/themes/th-1'); + }); +}); + +describe('translations API', () => { + itSendsRequests([ + ['getLanguages', (fetcher) => getLanguages({baseUrl, fetcher}), 'GET', '/i18n/languages'], + [ + 'resolveTranslations', + (fetcher) => resolveTranslations({baseUrl, fetcher, language: 'en-US', namespace: 'auth'}), + 'GET', + '/i18n/languages/en-US/translations/resolve?namespace=auth', + ], + [ + 'resolveTranslation', + (fetcher) => resolveTranslation({baseUrl, fetcher, key: 'signin.title', language: 'en-US', namespace: 'auth'}), + 'GET', + '/i18n/languages/en-US/translations/ns/auth/keys/signin.title/resolve', + ], + [ + 'setTranslations', + (fetcher) => setTranslations({baseUrl, fetcher, language: 'fr-FR', payload: {translations: {auth: {a: 'b'}}}}), + 'POST', + '/i18n/languages/fr-FR/translations', + {translations: {auth: {a: 'b'}}}, + ], + [ + 'deleteTranslations', + (fetcher) => deleteTranslations({baseUrl, fetcher, language: 'fr-FR'}), + 'DELETE', + '/i18n/languages/fr-FR/translations', + ], + [ + 'setTranslation', + (fetcher) => + setTranslation({baseUrl, fetcher, key: 'a b', language: 'fr-FR', namespace: 'auth', payload: {value: 'c'}}), + 'POST', + '/i18n/languages/fr-FR/translations/ns/auth/keys/a%20b', + {value: 'c'}, + ], + [ + 'deleteTranslation', + (fetcher) => deleteTranslation({baseUrl, fetcher, key: 'a', language: 'fr-FR', namespace: 'auth'}), + 'DELETE', + '/i18n/languages/fr-FR/translations/ns/auth/keys/a', + ], + ]); + + itMapsForbiddenAndNotFound('resolveTranslations', (fetcher) => + resolveTranslations({baseUrl, fetcher, language: 'xx'}), + ); +}); + +describe('server configuration API', () => { + itSendsRequests([ + ['getServerConfigs', (fetcher) => getServerConfigs({baseUrl, fetcher}), 'GET', '/server-config'], + ['getServerConfig', (fetcher) => getServerConfig({baseUrl, fetcher, name: 'flow'}), 'GET', '/server-config/flow'], + [ + 'updateServerConfig', + (fetcher) => updateServerConfig({baseUrl, fetcher, name: 'notification', payload: {defaultEmailSenderId: 'c-1'}}), + 'PUT', + '/server-config/notification', + {defaultEmailSenderId: 'c-1'}, + ], + ]); + + itMapsForbiddenAndNotFound('getServerConfig', (fetcher) => getServerConfig({baseUrl, fetcher, name: 'flow'})); +}); + +describe('gateways API', () => { + itSendsRequests([ + ['getGateways', (fetcher) => getGateways({baseUrl, fetcher}), 'GET', '/gateways'], + ['getGateway', (fetcher) => getGateway({baseUrl, fetcher, gatewayId}), 'GET', '/gateways/gw-1'], + [ + 'createGateway', + (fetcher) => createGateway({baseUrl, fetcher, payload: {baseUrl: 'https://gw', name: 'Edge'}}), + 'POST', + '/gateways', + {baseUrl: 'https://gw', name: 'Edge'}, + ], + [ + 'updateGateway', + (fetcher) => updateGateway({baseUrl, fetcher, gatewayId, payload: {name: 'Edge'}}), + 'PUT', + '/gateways/gw-1', + {name: 'Edge'}, + ], + ['deleteGateway', (fetcher) => deleteGateway({baseUrl, fetcher, gatewayId}), 'DELETE', '/gateways/gw-1'], + [ + 'applyGatewayConfiguration', + (fetcher) => applyGatewayConfiguration({baseUrl, fetcher, gatewayId, payload: {dryRun: true}}), + 'POST', + '/gateways/gw-1/apply', + {dryRun: true}, + ], + [ + 'applyGatewayConfiguration without a payload', + (fetcher) => applyGatewayConfiguration({baseUrl, fetcher, gatewayId}), + 'POST', + '/gateways/gw-1/apply', + ], + [ + 'revertGatewayConfiguration', + (fetcher) => revertGatewayConfiguration({baseUrl, fetcher, gatewayId, payload: {confirmRemovals: true}}), + 'POST', + '/gateways/gw-1/revert', + {confirmRemovals: true}, + ], + [ + 'getGatewayConfigurationDiff', + (fetcher) => getGatewayConfigurationDiff({baseUrl, fetcher, gatewayId, version: 'latest'}), + 'GET', + '/gateways/gw-1/diff?version=latest', + ], + [ + 'getGatewayAppliedConfiguration', + (fetcher) => getGatewayAppliedConfiguration({baseUrl, fetcher, gatewayId}), + 'GET', + '/gateways/gw-1/applied-configuration', + ], + [ + 'getGatewayAppliedVersion', + (fetcher) => getGatewayAppliedVersion({baseUrl, fetcher, gatewayId}), + 'GET', + '/gateways/gw-1/applied-version', + ], + [ + 'getGatewayVariables', + (fetcher) => getGatewayVariables({baseUrl, fetcher, gatewayId, limit: 5, names: ['A', 'B']}), + 'GET', + '/gateways/gw-1/variables?limit=5&names=A%2CB', + ], + [ + 'getGatewayVariable', + (fetcher) => getGatewayVariable({baseUrl, fetcher, gatewayId, name: 'A'}), + 'GET', + '/gateways/gw-1/variables/A', + ], + [ + 'createGatewayVariable', + (fetcher) => createGatewayVariable({baseUrl, fetcher, gatewayId, payload: {name: 'A', value: '1'}}), + 'POST', + '/gateways/gw-1/variables', + {name: 'A', value: '1'}, + ], + [ + 'updateGatewayVariable', + (fetcher) => updateGatewayVariable({baseUrl, fetcher, gatewayId, name: 'A', payload: {value: '2'}}), + 'PUT', + '/gateways/gw-1/variables/A', + {value: '2'}, + ], + [ + 'deleteGatewayVariable', + (fetcher) => deleteGatewayVariable({baseUrl, fetcher, gatewayId, name: 'A'}), + 'DELETE', + '/gateways/gw-1/variables/A', + ], + [ + 'getGatewaySecrets', + (fetcher) => getGatewaySecrets({baseUrl, fetcher, filter: 'name sw "S"', gatewayId}), + 'GET', + '/gateways/gw-1/secrets?filter=name+sw+%22S%22', + ], + [ + 'getGatewaySecret', + (fetcher) => getGatewaySecret({baseUrl, fetcher, gatewayId, name: 'S'}), + 'GET', + '/gateways/gw-1/secrets/S', + ], + [ + 'createGatewaySecret', + (fetcher) => createGatewaySecret({baseUrl, fetcher, gatewayId, payload: {name: 'S', value: 'x'}}), + 'POST', + '/gateways/gw-1/secrets', + {name: 'S', value: 'x'}, + ], + [ + 'updateGatewaySecret', + (fetcher) => updateGatewaySecret({baseUrl, fetcher, gatewayId, name: 'S', payload: {value: 'y'}}), + 'PUT', + '/gateways/gw-1/secrets/S', + {value: 'y'}, + ], + [ + 'deleteGatewaySecret', + (fetcher) => deleteGatewaySecret({baseUrl, fetcher, gatewayId, name: 'S'}), + 'DELETE', + '/gateways/gw-1/secrets/S', + ], + ]); + + itMapsForbiddenAndNotFound('getGatewayVariable', (fetcher) => + getGatewayVariable({baseUrl, fetcher, gatewayId, name: 'A'}), + ); + + it('uses the gateways endpoint override as the collection URL', async () => { + const fetcher: FetcherMock = createFetcher(); + + await getGatewaySecret({baseUrl, fetcher, gatewayId, name: 'S', url: 'https://rs.example.com/gateways'}); + + expect(requests(fetcher)[0].url).toBe('https://rs.example.com/gateways/gw-1/secrets/S'); + }); +}); + +describe('variables and secrets API', () => { + itSendsRequests([ + [ + 'getVariables', + (fetcher) => getVariables({baseUrl, fetcher, filter: 'name sw "A"', limit: 2, names: ['A'], offset: 1}), + 'GET', + '/variables?filter=name+sw+%22A%22&limit=2&names=A&offset=1', + ], + ['getVariable', (fetcher) => getVariable({baseUrl, fetcher, name: 'A B'}), 'GET', '/variables/A%20B'], + [ + 'createVariable', + (fetcher) => createVariable({baseUrl, fetcher, payload: {name: 'A', value: '1'}}), + 'POST', + '/variables', + {name: 'A', value: '1'}, + ], + [ + 'updateVariable', + (fetcher) => updateVariable({baseUrl, fetcher, name: 'A', payload: {value: '2'}}), + 'PUT', + '/variables/A', + {value: '2'}, + ], + ['deleteVariable', (fetcher) => deleteVariable({baseUrl, fetcher, name: 'A'}), 'DELETE', '/variables/A'], + ['getSecrets', (fetcher) => getSecrets({baseUrl, fetcher, names: ['S', 'T']}), 'GET', '/secrets?names=S%2CT'], + ['getSecret', (fetcher) => getSecret({baseUrl, fetcher, name: 'S'}), 'GET', '/secrets/S'], + [ + 'createSecret', + (fetcher) => createSecret({baseUrl, fetcher, payload: {name: 'S', value: 'x'}}), + 'POST', + '/secrets', + {name: 'S', value: 'x'}, + ], + [ + 'updateSecret', + (fetcher) => updateSecret({baseUrl, fetcher, name: 'S', payload: {value: 'y'}}), + 'PUT', + '/secrets/S', + {value: 'y'}, + ], + ['deleteSecret', (fetcher) => deleteSecret({baseUrl, fetcher, name: 'S'}), 'DELETE', '/secrets/S'], + ]); + + itMapsForbiddenAndNotFound('getSecret', (fetcher) => getSecret({baseUrl, fetcher, name: 'S'})); + + it('uses the secrets endpoint override as the collection URL', async () => { + const fetcher: FetcherMock = createFetcher(); + + await getSecret({baseUrl, fetcher, name: 'S', url: 'https://vault.example.com/secrets'}); + + expect(requests(fetcher)[0].url).toBe('https://vault.example.com/secrets/S'); + }); +}); + +describe('configuration versions, import and export API', () => { + itSendsRequests([ + [ + 'getConfigurationVersions', + (fetcher) => getConfigurationVersions({baseUrl, fetcher}), + 'GET', + '/configuration-versions', + ], + [ + 'getConfigurationVersion', + (fetcher) => getConfigurationVersion({baseUrl, fetcher, version: 'a1b2c3d'}), + 'GET', + '/configuration-versions/a1b2c3d', + ], + [ + 'createConfigurationVersion', + (fetcher) => createConfigurationVersion({baseUrl, fetcher, payload: {name: 'v1'}}), + 'POST', + '/configuration-versions', + {name: 'v1'}, + ], + [ + 'exportConfiguration', + (fetcher) => exportConfiguration({baseUrl, fetcher, payload: {applications: ['*']}}), + 'POST', + '/export', + {applications: ['*']}, + ], + [ + 'importConfiguration', + (fetcher) => importConfiguration({baseUrl, fetcher, payload: {content: 'kind: Application', dryRun: true}}), + 'POST', + '/import', + {content: 'kind: Application', dryRun: true}, + ], + [ + 'deleteImportedConfiguration', + (fetcher) => + deleteImportedConfiguration({baseUrl, fetcher, payload: {resourceKey: 'my-app', resourceType: 'application'}}), + 'POST', + '/import/delete', + {resourceKey: 'my-app', resourceType: 'application'}, + ], + ]); + + itMapsForbiddenAndNotFound('getConfigurationVersion', (fetcher) => + getConfigurationVersion({baseUrl, fetcher, version: 'v'}), + ); + + it('uses the import endpoint override as the collection URL', async () => { + const fetcher: FetcherMock = createFetcher(); + + await deleteImportedConfiguration({ + baseUrl, + fetcher, + payload: {resourceKey: 'k', resourceType: 'application'}, + url: 'https://rs.example.com/import', + }); + + expect(requests(fetcher)[0].url).toBe('https://rs.example.com/import/delete'); + }); +}); diff --git a/packages/javascript/src/api/management/__tests__/connectionsAndFlowsApi.test.ts b/packages/javascript/src/api/management/__tests__/connectionsAndFlowsApi.test.ts new file mode 100644 index 00000000..4eea47f5 --- /dev/null +++ b/packages/javascript/src/api/management/__tests__/connectionsAndFlowsApi.test.ts @@ -0,0 +1,162 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {describe, expect, it} from 'vitest'; +import { + baseUrl, + createFetcher, + FetcherMock, + itMapsForbiddenAndNotFound, + itSendsRequests, + requests, +} from './helpers/managementRequests'; +import {ConnectionListResponse, ConnectionSummary} from '../../../models/connection'; +import createConnection from '../connections/createConnection'; +import deleteConnection from '../connections/deleteConnection'; +import getConnection from '../connections/getConnection'; +import getConnections from '../connections/getConnections'; +import getConnectionsMeta from '../connections/getConnectionsMeta'; +import getConnectionUsages from '../connections/getConnectionUsages'; +import updateConnection from '../connections/updateConnection'; +import createFlow from '../flows/createFlow'; +import deleteFlow from '../flows/deleteFlow'; +import getFlow from '../flows/getFlow'; +import getFlowUsages from '../flows/getFlowUsages'; +import getFlowVersion from '../flows/getFlowVersion'; +import getFlowVersions from '../flows/getFlowVersions'; +import restoreFlowVersion from '../flows/restoreFlowVersion'; +import updateFlow from '../flows/updateFlow'; + +const connection = {clientId: 'c', name: 'Google', redirectUri: 'https://app.example.com/cb'}; +const flow = {flowType: 'AUTHENTICATION', handle: 'basic', name: 'Basic', nodes: [{id: 'start', type: 'START'}]}; + +describe('connections API', () => { + itSendsRequests([ + [ + 'getConnections across every type', + (fetcher) => getConnections({baseUrl, category: 'identity-provider', fetcher, limit: 10, offset: 0}), + 'GET', + '/connections?category=identity-provider&limit=10&offset=0', + ], + [ + 'getConnections of one type', + (fetcher) => getConnections({baseUrl, fetcher, limit: 10, type: 'email-smtp'}), + 'GET', + '/connections/email-smtp', + ], + [ + 'getConnection', + (fetcher) => getConnection({baseUrl, connectionId: 'c-1', fetcher, type: 'google'}), + 'GET', + '/connections/google/c-1', + ], + [ + 'createConnection', + (fetcher) => createConnection({baseUrl, fetcher, payload: connection, type: 'google'}), + 'POST', + '/connections/google', + connection, + ], + [ + 'updateConnection', + (fetcher) => updateConnection({baseUrl, connectionId: 'c-1', fetcher, payload: connection, type: 'oidc'}), + 'PUT', + '/connections/oidc/c-1', + connection, + ], + [ + 'deleteConnection', + (fetcher) => deleteConnection({baseUrl, connectionId: 'c 1', fetcher, type: 'http'}), + 'DELETE', + '/connections/http/c%201', + ], + [ + 'getConnectionUsages', + (fetcher) => getConnectionUsages({baseUrl, connectionId: 'c-1', fetcher, type: 'twilio'}), + 'GET', + '/connections/twilio/c-1/usages', + ], + ['getConnectionsMeta', (fetcher) => getConnectionsMeta({baseUrl, fetcher}), 'GET', '/connections/meta'], + [ + 'getConnectionsMeta for a vendor', + (fetcher) => getConnectionsMeta({baseUrl, fetcher, vendor: 'sms-gateway'}), + 'GET', + '/connections/meta?vendor=sms-gateway', + ], + ]); + + itMapsForbiddenAndNotFound('getConnection', (fetcher) => + getConnection({baseUrl, connectionId: 'c-1', fetcher, type: 'google'}), + ); + + it('returns the listing the server returned, typed by whether a type is given', async () => { + const all: ConnectionListResponse = await getConnections({ + baseUrl, + fetcher: createFetcher({connections: [], count: 0, links: [], startIndex: 1, totalResults: 0}), + }); + const google: ConnectionSummary[] = await getConnections({ + baseUrl, + fetcher: createFetcher([{id: 'c-1', name: 'Google'}]), + type: 'google', + }); + + expect(all.connections).toEqual([]); + expect(google).toEqual([{id: 'c-1', name: 'Google'}]); + }); + + it('uses the connections endpoint override as the collection URL', async () => { + const fetcher: FetcherMock = createFetcher(); + + await getConnectionUsages({ + baseUrl, + connectionId: 'c-1', + fetcher, + type: 'google', + url: 'https://rs.example.com/connections', + }); + + expect(requests(fetcher)[0].url).toBe('https://rs.example.com/connections/google/c-1/usages'); + }); +}); + +describe('flows API', () => { + itSendsRequests([ + ['getFlow', (fetcher) => getFlow({baseUrl, fetcher, flowId: 'f-1'}), 'GET', '/flows/f-1'], + ['createFlow', (fetcher) => createFlow({baseUrl, fetcher, payload: flow}), 'POST', '/flows', flow], + [ + 'updateFlow', + (fetcher) => updateFlow({baseUrl, fetcher, flowId: 'f-1', payload: flow}), + 'PUT', + '/flows/f-1', + flow, + ], + ['deleteFlow', (fetcher) => deleteFlow({baseUrl, fetcher, flowId: 'f-1'}), 'DELETE', '/flows/f-1'], + ['getFlowVersions', (fetcher) => getFlowVersions({baseUrl, fetcher, flowId: 'f-1'}), 'GET', '/flows/f-1/versions'], + [ + 'getFlowVersion', + (fetcher) => getFlowVersion({baseUrl, fetcher, flowId: 'f-1', version: 3}), + 'GET', + '/flows/f-1/versions/3', + ], + [ + 'restoreFlowVersion', + (fetcher) => restoreFlowVersion({baseUrl, fetcher, flowId: 'f-1', version: 2}), + 'POST', + '/flows/f-1/restore', + {version: 2}, + ], + ['getFlowUsages', (fetcher) => getFlowUsages({baseUrl, fetcher, flowId: 'f-1'}), 'GET', '/flows/f-1/usages'], + ]); + + itMapsForbiddenAndNotFound('getFlowVersion', (fetcher) => + getFlowVersion({baseUrl, fetcher, flowId: 'f-1', version: 1}), + ); + + it('uses the flows endpoint override as the collection URL', async () => { + const fetcher: FetcherMock = createFetcher(); + + await restoreFlowVersion({baseUrl, fetcher, flowId: 'f-1', url: 'https://rs.example.com/flows/', version: 1}); + + expect(requests(fetcher)[0].url).toBe('https://rs.example.com/flows/f-1/restore'); + }); +}); diff --git a/packages/javascript/src/api/management/__tests__/helpers/managementRequests.ts b/packages/javascript/src/api/management/__tests__/helpers/managementRequests.ts new file mode 100644 index 00000000..1c64695d --- /dev/null +++ b/packages/javascript/src/api/management/__tests__/helpers/managementRequests.ts @@ -0,0 +1,76 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {expect, it, vi, type Mock} from 'vitest'; +import ThunderIDAPIError from '../../../../errors/ThunderIDAPIError'; +import {ApiFetcher} from '../../../../models/api'; + +export type FetcherMock = Mock; + +/** + * Runs one management operation against the given fetcher. + */ +export type Run = (fetcher: ApiFetcher) => Promise; + +/** + * An operation, the method it must send, the path it must address under `baseUrl` (with its query), and + * the body it must send. + */ +export type RequestCase = [name: string, run: Run, method: string, path: string, body?: unknown]; + +export interface SentRequest { + body: unknown; + method: string | undefined; + url: string; +} + +export const baseUrl = 'https://localhost:8090'; + +export const respond = (body: unknown, status = 200, statusText = 'OK'): Response => + ({ + json: () => Promise.resolve(body), + ok: status >= 200 && status < 300, + status, + statusText, + text: () => Promise.resolve(JSON.stringify(body)), + }) as Response; + +export const createFetcher = (body: unknown = {}, status = 200): FetcherMock => + vi.fn().mockResolvedValue(respond(body, status, status === 200 ? 'OK' : 'Error')); + +export const requests = (fetcher: FetcherMock): SentRequest[] => + fetcher.mock.calls.map(([url, init]: Parameters) => ({ + body: init?.body ? (JSON.parse(init.body as string) as unknown) : undefined, + method: init?.method, + url, + })); + +/** + * Checks that each operation sends one request with the given method, URL and body. + */ +export const itSendsRequests = (cases: RequestCase[]): void => { + it.each(cases)('%s sends %s %s', async (_name, run, method, path, body) => { + const fetcher: FetcherMock = createFetcher(); + + await run(fetcher); + + expect(requests(fetcher)).toEqual([{body, method, url: `${baseUrl}${path}`}]); + }); +}; + +/** + * Checks that an operation maps HTTP 403 and 404 to their own error codes and keeps the server's error. + */ +export const itMapsForbiddenAndNotFound = (name: string, run: Run): void => { + it.each<[number, string]>([ + [403, 'ForbiddenError'], + [404, 'NotFoundError'], + ])(`${name} maps HTTP %i to %s`, async (status: number, kind: string) => { + const error: unknown = await run( + createFetcher({code: 'SRV-1001', message: {defaultValue: 'Refused'}}, status), + ).catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(ThunderIDAPIError); + expect(error).toMatchObject({code: `${name}-${kind}-001`, serverError: {code: 'SRV-1001'}, statusCode: status}); + }); +}; diff --git a/packages/javascript/src/api/management/__tests__/managementApi.test.ts b/packages/javascript/src/api/management/__tests__/managementApi.test.ts index a747f0bf..d7d47c92 100644 --- a/packages/javascript/src/api/management/__tests__/managementApi.test.ts +++ b/packages/javascript/src/api/management/__tests__/managementApi.test.ts @@ -110,7 +110,7 @@ describe('management API', () => { 'getUserTypes', (fetcher: ApiFetcher) => getUserTypes({baseUrl, fetcher, limit: 30, offset: 30}), 'GET', - `${baseUrl}/user-types?limit=30&offset=30`, + `${baseUrl}/user-types?include=display&limit=30&offset=30`, ], ])('%s sends the expected request', async (_name: string, run: Run, method: string, url: string) => { const fetcher: FetcherMock = createFetcher(); @@ -169,7 +169,7 @@ describe('management API', () => { [ 'getUserTypes', (fetcher: ApiFetcher) => getUserTypes({fetcher, limit: 10, url: 'https://rs.example.com/user-types'}), - 'https://rs.example.com/user-types?limit=10', + 'https://rs.example.com/user-types?include=display&limit=10', ], ])('%s uses the collection URL override instead of the base URL', async (_name: string, run: Run, url: string) => { const fetcher: FetcherMock = createFetcher(); diff --git a/packages/javascript/src/api/management/__tests__/organizationUnitsApi.test.ts b/packages/javascript/src/api/management/__tests__/organizationUnitsApi.test.ts new file mode 100644 index 00000000..9b0191ec --- /dev/null +++ b/packages/javascript/src/api/management/__tests__/organizationUnitsApi.test.ts @@ -0,0 +1,171 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {describe, expect, it} from 'vitest'; +import { + baseUrl, + createFetcher, + FetcherMock, + itMapsForbiddenAndNotFound, + itSendsRequests, + requests, +} from './helpers/managementRequests'; +import createOrganizationUnit from '../organization-units/createOrganizationUnit'; +import deleteOrganizationUnit from '../organization-units/deleteOrganizationUnit'; +import deleteOrganizationUnitByPath from '../organization-units/deleteOrganizationUnitByPath'; +import getOrganizationUnit from '../organization-units/getOrganizationUnit'; +import getOrganizationUnitByPath from '../organization-units/getOrganizationUnitByPath'; +import getOrganizationUnitChildren from '../organization-units/getOrganizationUnitChildren'; +import getOrganizationUnitGroups from '../organization-units/getOrganizationUnitGroups'; +import getOrganizationUnitRoles from '../organization-units/getOrganizationUnitRoles'; +import getOrganizationUnits from '../organization-units/getOrganizationUnits'; +import getOrganizationUnitUsers from '../organization-units/getOrganizationUnitUsers'; +import updateOrganizationUnit from '../organization-units/updateOrganizationUnit'; +import updateOrganizationUnitByPath from '../organization-units/updateOrganizationUnitByPath'; + +const ou = {handle: 'platform', name: 'Platform', parent: 'ou-root'}; + +describe('organization units API', () => { + itSendsRequests([ + [ + 'getOrganizationUnits', + (fetcher) => getOrganizationUnits({baseUrl, fetcher, filter: 'handle sw "eng"', limit: 5, offset: 10}), + 'GET', + '/organization-units?filter=handle+sw+%22eng%22&limit=5&offset=10', + ], + [ + 'getOrganizationUnit', + (fetcher) => getOrganizationUnit({baseUrl, fetcher, ouId: 'ou 1'}), + 'GET', + '/organization-units/ou%201', + ], + [ + 'getOrganizationUnitByPath', + (fetcher) => getOrganizationUnitByPath({baseUrl, fetcher, path: 'engineering/plat form'}), + 'GET', + '/organization-units/tree/engineering/plat%20form', + ], + [ + 'createOrganizationUnit', + (fetcher) => createOrganizationUnit({baseUrl, fetcher, payload: ou}), + 'POST', + '/organization-units', + ou, + ], + [ + 'updateOrganizationUnit', + (fetcher) => updateOrganizationUnit({baseUrl, fetcher, ouId: 'ou-1', payload: ou}), + 'PUT', + '/organization-units/ou-1', + ou, + ], + [ + 'updateOrganizationUnitByPath', + (fetcher) => + updateOrganizationUnitByPath({ + baseUrl, + fetcher, + path: '/engineering/platform/', + payload: {handle: 'platform', name: 'P'}, + }), + 'PUT', + '/organization-units/tree/engineering/platform', + {handle: 'platform', name: 'P'}, + ], + [ + 'deleteOrganizationUnit', + (fetcher) => deleteOrganizationUnit({baseUrl, fetcher, ouId: 'ou-1'}), + 'DELETE', + '/organization-units/ou-1', + ], + [ + 'deleteOrganizationUnitByPath', + (fetcher) => deleteOrganizationUnitByPath({baseUrl, fetcher, path: 'a/b?c'}), + 'DELETE', + '/organization-units/tree/a/b%3Fc', + ], + [ + 'getOrganizationUnitChildren by id', + (fetcher) => getOrganizationUnitChildren({baseUrl, fetcher, filter: 'name eq "x"', limit: 2, ouId: 'ou-1'}), + 'GET', + '/organization-units/ou-1/ous?filter=name+eq+%22x%22&limit=2', + ], + [ + 'getOrganizationUnitChildren by path', + (fetcher) => getOrganizationUnitChildren({baseUrl, fetcher, offset: 4, path: 'engineering/platform'}), + 'GET', + '/organization-units/tree/engineering/platform/ous?offset=4', + ], + [ + 'getOrganizationUnitUsers by id', + (fetcher) => getOrganizationUnitUsers({baseUrl, fetcher, limit: 5, ouId: 'ou-1'}), + 'GET', + '/organization-units/ou-1/users?include=display&limit=5', + ], + [ + 'getOrganizationUnitUsers by path', + (fetcher) => getOrganizationUnitUsers({baseUrl, fetcher, path: 'engineering/platform'}), + 'GET', + '/organization-units/tree/engineering/platform/users?include=display', + ], + [ + 'getOrganizationUnitGroups by id', + (fetcher) => getOrganizationUnitGroups({baseUrl, fetcher, offset: 3, ouId: 'ou-1'}), + 'GET', + '/organization-units/ou-1/groups?offset=3', + ], + [ + 'getOrganizationUnitGroups by path', + (fetcher) => getOrganizationUnitGroups({baseUrl, fetcher, path: 'engineering'}), + 'GET', + '/organization-units/tree/engineering/groups', + ], + [ + 'getOrganizationUnitRoles by id', + (fetcher) => getOrganizationUnitRoles({baseUrl, fetcher, ouId: 'ou-1'}), + 'GET', + '/organization-units/ou-1/roles', + ], + [ + 'getOrganizationUnitRoles by path', + (fetcher) => getOrganizationUnitRoles({baseUrl, fetcher, limit: 1, path: 'engineering/platform'}), + 'GET', + '/organization-units/tree/engineering/platform/roles?limit=1', + ], + ]); + + itMapsForbiddenAndNotFound('getOrganizationUnitByPath', (fetcher) => + getOrganizationUnitByPath({baseUrl, fetcher, path: 'engineering'}), + ); + + it('uses the organizationUnits endpoint override as the collection URL', async () => { + const fetcher: FetcherMock = createFetcher(); + + await getOrganizationUnitUsers({ + baseUrl, + fetcher, + path: 'a/b', + url: 'https://rs.example.com/api/organization-units/', + }); + + expect(requests(fetcher)[0].url).toBe( + 'https://rs.example.com/api/organization-units/tree/a/b/users?include=display', + ); + }); + + it('keeps the earlier children endpoint for organizationUnitId', async () => { + const fetcher: FetcherMock = createFetcher({organizationUnits: []}); + + await getOrganizationUnitChildren({baseUrl, fetcher, limit: 10, organizationUnitId: 'ou-1'}); + + expect(requests(fetcher)[0].url).toBe(`${baseUrl}/api/server/v1/organization-units/ou-1/children?limit=10`); + }); + + it.each<[string, () => Promise]>([ + ['an empty path', () => getOrganizationUnitByPath({baseUrl, fetcher: createFetcher(), path: '//'})], + ['no organization unit', () => getOrganizationUnitRoles({baseUrl, fetcher: createFetcher()} as never)], + ['no parent', () => getOrganizationUnitChildren({baseUrl, fetcher: createFetcher()})], + ])('refuses %s before sending anything', async (_label: string, run: () => Promise) => { + await expect(run()).rejects.toMatchObject({code: expect.stringMatching(/-ValidationError-002$/) as string}); + }); +}); diff --git a/packages/javascript/src/api/management/__tests__/resourceServersApi.test.ts b/packages/javascript/src/api/management/__tests__/resourceServersApi.test.ts new file mode 100644 index 00000000..c3e4e5f9 --- /dev/null +++ b/packages/javascript/src/api/management/__tests__/resourceServersApi.test.ts @@ -0,0 +1,255 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {describe, expect, it, vi} from 'vitest'; +import { + baseUrl, + createFetcher, + FetcherMock, + itMapsForbiddenAndNotFound, + itSendsRequests, + requests, + respond, +} from './helpers/managementRequests'; +import {ApiFetcher} from '../../../models/api'; +import createAction from '../resource-servers/createAction'; +import createResource from '../resource-servers/createResource'; +import createResourceServer from '../resource-servers/createResourceServer'; +import deleteAction from '../resource-servers/deleteAction'; +import deleteResource from '../resource-servers/deleteResource'; +import deleteResourceServer from '../resource-servers/deleteResourceServer'; +import getAction from '../resource-servers/getAction'; +import getActions from '../resource-servers/getActions'; +import getResource from '../resource-servers/getResource'; +import getResources from '../resource-servers/getResources'; +import getResourceServer from '../resource-servers/getResourceServer'; +import getResourceServers from '../resource-servers/getResourceServers'; +import updateAction from '../resource-servers/updateAction'; +import updateResource from '../resource-servers/updateResource'; +import updateResourceServer from '../resource-servers/updateResourceServer'; + +const rs = {identifier: 'https://api.example.com', name: 'Orders', ouId: 'ou-1'}; +const resource = {handle: 'orders', name: 'Orders'}; +const action = {handle: 'read', name: 'Read'}; +const resourceServerId = 'rs-1'; + +describe('resource servers API', () => { + itSendsRequests([ + [ + 'getResourceServers', + (fetcher) => getResourceServers({baseUrl, fetcher, limit: 5}), + 'GET', + '/resource-servers?limit=5', + ], + [ + 'getResourceServer', + (fetcher) => getResourceServer({baseUrl, fetcher, resourceServerId}), + 'GET', + '/resource-servers/rs-1', + ], + [ + 'createResourceServer', + (fetcher) => createResourceServer({baseUrl, fetcher, payload: rs}), + 'POST', + '/resource-servers', + rs, + ], + [ + 'updateResourceServer', + (fetcher) => updateResourceServer({baseUrl, fetcher, payload: rs, resourceServerId}), + 'PUT', + '/resource-servers/rs-1', + rs, + ], + [ + 'deleteResourceServer', + (fetcher) => deleteResourceServer({baseUrl, fetcher, resourceServerId}), + 'DELETE', + '/resource-servers/rs-1', + ], + [ + 'getResources', + (fetcher) => getResources({baseUrl, fetcher, limit: 2, offset: 0, parentId: 'r-0', resourceServerId}), + 'GET', + '/resource-servers/rs-1/resources?limit=2&offset=0&parentId=r-0', + ], + [ + 'getResource', + (fetcher) => getResource({baseUrl, fetcher, resourceId: 'r 1', resourceServerId}), + 'GET', + '/resource-servers/rs-1/resources/r%201', + ], + [ + 'createResource', + (fetcher) => createResource({baseUrl, fetcher, payload: resource, resourceServerId}), + 'POST', + '/resource-servers/rs-1/resources', + resource, + ], + [ + 'updateResource', + (fetcher) => updateResource({baseUrl, fetcher, payload: {name: 'O'}, resourceId: 'r-1', resourceServerId}), + 'PUT', + '/resource-servers/rs-1/resources/r-1', + {name: 'O'}, + ], + [ + 'deleteResource', + (fetcher) => deleteResource({baseUrl, fetcher, resourceId: 'r-1', resourceServerId}), + 'DELETE', + '/resource-servers/rs-1/resources/r-1', + ], + [ + 'getActions on the server', + (fetcher) => getActions({baseUrl, fetcher, kind: 'tool', resourceServerId}), + 'GET', + '/resource-servers/rs-1/actions?kind=tool', + ], + [ + 'getActions on a resource', + (fetcher) => getActions({baseUrl, fetcher, limit: 3, resourceId: 'r-1', resourceServerId}), + 'GET', + '/resource-servers/rs-1/resources/r-1/actions?limit=3', + ], + [ + 'getAction on the server', + (fetcher) => getAction({actionId: 'a-1', baseUrl, fetcher, resourceServerId}), + 'GET', + '/resource-servers/rs-1/actions/a-1', + ], + [ + 'getAction on a resource', + (fetcher) => getAction({actionId: 'a-1', baseUrl, fetcher, resourceId: 'r-1', resourceServerId}), + 'GET', + '/resource-servers/rs-1/resources/r-1/actions/a-1', + ], + [ + 'createAction on the server', + (fetcher) => createAction({baseUrl, fetcher, payload: action, resourceServerId}), + 'POST', + '/resource-servers/rs-1/actions', + action, + ], + [ + 'createAction on a resource', + (fetcher) => createAction({baseUrl, fetcher, payload: action, resourceId: 'r-1', resourceServerId}), + 'POST', + '/resource-servers/rs-1/resources/r-1/actions', + action, + ], + [ + 'updateAction on the server', + (fetcher) => updateAction({actionId: 'a-1', baseUrl, fetcher, payload: {name: 'R'}, resourceServerId}), + 'PUT', + '/resource-servers/rs-1/actions/a-1', + {name: 'R'}, + ], + [ + 'updateAction on a resource', + (fetcher) => + updateAction({actionId: 'a-1', baseUrl, fetcher, payload: {name: 'R'}, resourceId: 'r-1', resourceServerId}), + 'PUT', + '/resource-servers/rs-1/resources/r-1/actions/a-1', + {name: 'R'}, + ], + [ + 'deleteAction on the server', + (fetcher) => deleteAction({actionId: 'a-1', baseUrl, fetcher, resourceServerId}), + 'DELETE', + '/resource-servers/rs-1/actions/a-1', + ], + [ + 'deleteAction on a resource', + (fetcher) => deleteAction({actionId: 'a/1', baseUrl, fetcher, resourceId: 'r-1', resourceServerId}), + 'DELETE', + '/resource-servers/rs-1/resources/r-1/actions/a%2F1', + ], + ]); + + itMapsForbiddenAndNotFound('getAction', (fetcher) => + getAction({actionId: 'a-1', baseUrl, fetcher, resourceServerId}), + ); + + it('uses the resourceServers endpoint override as the collection URL', async () => { + const fetcher: FetcherMock = createFetcher(); + + await getActions({ + baseUrl, + fetcher, + resourceId: 'r-1', + resourceServerId, + url: 'https://rs.example.com/resource-servers', + }); + + expect(requests(fetcher)[0].url).toBe('https://rs.example.com/resource-servers/rs-1/resources/r-1/actions'); + }); +}); + +/** + * A fetcher that answers like a server whose action deletion flow is `configured`, or unset. + */ +const createServer = (configured: boolean): FetcherMock => + vi.fn((url: string) => { + if (url === `${baseUrl}/server-config/flow`) { + return Promise.resolve( + respond({merged: configured ? {actionDeletionFlow: {defaultHandle: 'delete-action'}} : {}}), + ); + } + if (url.startsWith(`${baseUrl}/flows?`)) { + return Promise.resolve(respond({flows: [{flowType: 'ADMINISTRATION', handle: 'delete-action', id: 'flow-1'}]})); + } + + return Promise.resolve(respond({data: {additionalData: {}}, flowStatus: 'COMPLETE'})); + }); + +describe('deleteAction in flow mode', () => { + it.each<[string, string | undefined, Record]>([ + ['on the server', undefined, {targetActionId: 'a-1', targetResourceServerId: 'rs-1'}], + ['on a resource', 'r-1', {targetActionId: 'a-1', targetResourceId: 'r-1', targetResourceServerId: 'rs-1'}], + ])('runs the action deletion flow for an action %s instead of the DELETE', async (_label, resourceId, inputs) => { + const fetcher: FetcherMock = createServer(true); + + await deleteAction({actionId: 'a-1', administrationMode: 'flow', baseUrl, fetcher, resourceId, resourceServerId}); + + const sent = requests(fetcher); + expect(sent.find((request) => request.url === `${baseUrl}/flow/execute`)?.body).toEqual({flowId: 'flow-1', inputs}); + expect(sent.some((request) => request.method === 'DELETE')).toBe(false); + }); + + it('deletes the action directly when no flow is configured', async () => { + const fetcher: FetcherMock = createServer(false); + + await deleteAction({ + actionId: 'a-1', + administrationMode: 'flow', + baseUrl, + fetcher, + resourceId: 'r-1', + resourceServerId, + }); + + expect(requests(fetcher).map(({method, url}) => `${method} ${url}`)).toEqual([ + `GET ${baseUrl}/server-config/flow`, + `DELETE ${baseUrl}/resource-servers/rs-1/resources/r-1/actions/a-1`, + ]); + }); + + it('deletes the action directly in native mode, without reading the flow configuration', async () => { + const fetcher: FetcherMock = createServer(true); + + await deleteAction({actionId: 'a-1', baseUrl, fetcher, resourceServerId}); + + expect(requests(fetcher).map(({method, url}) => `${method} ${url}`)).toEqual([ + `DELETE ${baseUrl}/resource-servers/rs-1/actions/a-1`, + ]); + }); + + it('refuses a blank action identifier before running a flow', async () => { + const fetcher: FetcherMock = createServer(true); + + await expect( + deleteAction({actionId: '', administrationMode: 'flow', baseUrl, fetcher, resourceServerId}), + ).rejects.toMatchObject({code: 'deleteAction-ValidationError-002'}); + expect(fetcher).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/javascript/src/api/management/__tests__/rolesAndGroupsApi.test.ts b/packages/javascript/src/api/management/__tests__/rolesAndGroupsApi.test.ts index 11fd797a..675e72aa 100644 --- a/packages/javascript/src/api/management/__tests__/rolesAndGroupsApi.test.ts +++ b/packages/javascript/src/api/management/__tests__/rolesAndGroupsApi.test.ts @@ -52,8 +52,18 @@ const sentRequest = (fetcher: FetcherMock): SentRequest => { 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`], + [ + 'getRoles', + (fetcher: ApiFetcher) => getRoles({baseUrl, fetcher, limit: 5}), + 'GET', + `${baseUrl}/roles?include=display&limit=5`, + ], + [ + 'getRole', + (fetcher: ApiFetcher) => getRole({baseUrl, fetcher, roleId: 'r-1'}), + 'GET', + `${baseUrl}/roles/r-1?include=display`, + ], [ 'deleteRole', (fetcher: ApiFetcher) => deleteRole({baseUrl, fetcher, roleId: 'r-1'}), @@ -112,7 +122,7 @@ describe('roles and groups management API', () => { 'getAgentTypes', (fetcher: ApiFetcher) => getAgentTypes({baseUrl, fetcher, limit: 50}), 'GET', - `${baseUrl}/agent-types?limit=50`, + `${baseUrl}/agent-types?include=display&limit=50`, ], ])('%s sends the expected request', async (_name: string, run: Run, method: string, url: string) => { const fetcher: FetcherMock = createFetcher(); diff --git a/packages/javascript/src/api/management/__tests__/usersAndApplicationsApi.test.ts b/packages/javascript/src/api/management/__tests__/usersAndApplicationsApi.test.ts new file mode 100644 index 00000000..6c7b9838 --- /dev/null +++ b/packages/javascript/src/api/management/__tests__/usersAndApplicationsApi.test.ts @@ -0,0 +1,297 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {describe, expect, it, vi} from 'vitest'; +import { + baseUrl, + FetcherMock, + itMapsForbiddenAndNotFound, + itSendsRequests, + requests, + respond, + Run, +} from './helpers/managementRequests'; +import AdministrationFlowError from '../../../errors/AdministrationFlowError'; +import {ApiFetcher} from '../../../models/api'; +import createAgentType from '../agent-types/createAgentType'; +import deleteAgentType from '../agent-types/deleteAgentType'; +import suspendAgent from '../agents/suspendAgent'; +import unlockAgent from '../agents/unlockAgent'; +import unsuspendAgent from '../agents/unsuspendAgent'; +import createApplicationSharingPolicy from '../applications/createApplicationSharingPolicy'; +import deleteApplicationSharingPolicy from '../applications/deleteApplicationSharingPolicy'; +import getApplicationOverlayRules from '../applications/getApplicationOverlayRules'; +import getApplicationSharingPolicies from '../applications/getApplicationSharingPolicies'; +import getApplicationSharingPolicy from '../applications/getApplicationSharingPolicy'; +import suspendApplication from '../applications/suspendApplication'; +import unsuspendApplication from '../applications/unsuspendApplication'; +import updateApplicationSharingPolicy from '../applications/updateApplicationSharingPolicy'; +import createUserByPath from '../users/createUserByPath'; +import createUserSharingPolicy from '../users/createUserSharingPolicy'; +import deleteUserSharingPolicy from '../users/deleteUserSharingPolicy'; +import getUserOverlayRules from '../users/getUserOverlayRules'; +import getUsersByPath from '../users/getUsersByPath'; +import getUserSharingPolicies from '../users/getUserSharingPolicies'; +import getUserSharingPolicy from '../users/getUserSharingPolicy'; +import suspendUser from '../users/suspendUser'; +import unlockUser from '../users/unlockUser'; +import unsuspendUser from '../users/unsuspendUser'; +import updateUserSharingPolicy from '../users/updateUserSharingPolicy'; + +const policy = {childUnits: {mode: 'all' as const}}; +const policyUpdate = {childUnits: {excludedOuIds: ['ou-2'], mode: 'all' as const}, version: 1}; +const agentType = {displayName: 'Default', handle: 'default', ouId: 'ou-1', schema: {}}; + +describe('users, applications and agent types API', () => { + itSendsRequests([ + [ + 'createAgentType', + (fetcher) => createAgentType({baseUrl, fetcher, payload: agentType}), + 'POST', + '/agent-types', + agentType, + ], + [ + 'deleteAgentType', + (fetcher) => deleteAgentType({agentTypeId: 't-1', baseUrl, fetcher}), + 'DELETE', + '/agent-types/t-1', + ], + [ + 'getUsersByPath', + (fetcher) => getUsersByPath({baseUrl, fetcher, limit: 5, offset: 5, path: 'engineering/plat form'}), + 'GET', + '/users/tree/engineering/plat%20form?include=display&limit=5&offset=5', + ], + [ + 'createUserByPath', + (fetcher) => createUserByPath({baseUrl, fetcher, path: 'engineering', payload: {type: 'customer'}}), + 'POST', + '/users/tree/engineering', + {type: 'customer'}, + ], + [ + 'getUserSharingPolicies', + (fetcher) => getUserSharingPolicies({baseUrl, fetcher, limit: 2, userId: 'u-1'}), + 'GET', + '/users/u-1/sharing-policies?limit=2', + ], + [ + 'createUserSharingPolicy', + (fetcher) => createUserSharingPolicy({baseUrl, fetcher, payload: policy, userId: 'u-1'}), + 'POST', + '/users/u-1/sharing-policies', + policy, + ], + [ + 'getUserSharingPolicy', + (fetcher) => getUserSharingPolicy({baseUrl, fetcher, policyId: 'p/1', userId: 'u-1'}), + 'GET', + '/users/u-1/sharing-policies/p%2F1', + ], + [ + 'updateUserSharingPolicy', + (fetcher) => updateUserSharingPolicy({baseUrl, fetcher, payload: policyUpdate, policyId: 'p-1', userId: 'u-1'}), + 'PUT', + '/users/u-1/sharing-policies/p-1', + policyUpdate, + ], + [ + 'deleteUserSharingPolicy', + (fetcher) => deleteUserSharingPolicy({baseUrl, fetcher, policyId: 'p-1', userId: 'u-1'}), + 'DELETE', + '/users/u-1/sharing-policies/p-1', + ], + [ + 'getUserOverlayRules', + (fetcher) => getUserOverlayRules({baseUrl, fetcher, ouId: 'ou-2', userId: 'u-1'}), + 'GET', + '/users/u-1/overlay-rules?ouId=ou-2', + ], + [ + 'getApplicationSharingPolicies', + (fetcher) => getApplicationSharingPolicies({applicationId: 'app-1', baseUrl, fetcher, offset: 2}), + 'GET', + '/applications/app-1/sharing-policies?offset=2', + ], + [ + 'createApplicationSharingPolicy', + (fetcher) => createApplicationSharingPolicy({applicationId: 'app-1', baseUrl, fetcher, payload: policy}), + 'POST', + '/applications/app-1/sharing-policies', + policy, + ], + [ + 'getApplicationSharingPolicy', + (fetcher) => getApplicationSharingPolicy({applicationId: 'app-1', baseUrl, fetcher, policyId: 'p-1'}), + 'GET', + '/applications/app-1/sharing-policies/p-1', + ], + [ + 'updateApplicationSharingPolicy', + (fetcher) => + updateApplicationSharingPolicy({ + applicationId: 'app-1', + baseUrl, + fetcher, + payload: policyUpdate, + policyId: 'p-1', + }), + 'PUT', + '/applications/app-1/sharing-policies/p-1', + policyUpdate, + ], + [ + 'deleteApplicationSharingPolicy', + (fetcher) => deleteApplicationSharingPolicy({applicationId: 'app-1', baseUrl, fetcher, policyId: 'p-1'}), + 'DELETE', + '/applications/app-1/sharing-policies/p-1', + ], + [ + 'getApplicationOverlayRules', + (fetcher) => getApplicationOverlayRules({applicationId: 'app 1', baseUrl, fetcher, ouId: 'ou-2'}), + 'GET', + '/applications/app%201/overlay-rules?ouId=ou-2', + ], + ]); + + itMapsForbiddenAndNotFound('getUserSharingPolicy', (fetcher) => + getUserSharingPolicy({baseUrl, fetcher, policyId: 'p-1', userId: 'u-1'}), + ); + itMapsForbiddenAndNotFound('getApplicationSharingPolicies', (fetcher) => + getApplicationSharingPolicies({applicationId: 'app-1', baseUrl, fetcher}), + ); + + it('uses the users endpoint override as the collection URL', async () => { + const fetcher: FetcherMock = vi.fn().mockResolvedValue(respond({})); + + await getUserSharingPolicy({baseUrl, fetcher, policyId: 'p-1', url: 'https://rs.example.com/users', userId: 'u-1'}); + + expect(requests(fetcher)[0].url).toBe('https://rs.example.com/users/u-1/sharing-policies/p-1'); + }); + + it('refuses a blank policy identifier', async () => { + await expect( + getUserSharingPolicy({baseUrl, fetcher: vi.fn(), policyId: ' ', userId: 'u-1'}), + ).rejects.toMatchObject({code: 'getUserSharingPolicy-ValidationError-002'}); + }); +}); + +/** + * A fetcher that answers like a server with the given administration flows configured: the flow + * configuration, the flow listing, and flow execution. + */ +const createFlowServer = ( + config: Record, + additionalData: Record = {}, +): FetcherMock => + vi.fn((url: string) => { + if (url === `${baseUrl}/server-config/flow`) { + return Promise.resolve(respond({merged: config})); + } + if (url.startsWith(`${baseUrl}/flows?`)) { + return Promise.resolve( + respond({ + flows: Object.values(config).map(({defaultHandle}) => ({ + flowType: 'ADMINISTRATION', + handle: defaultHandle, + id: `flow-${defaultHandle}`, + })), + }), + ); + } + if (url === `${baseUrl}/flow/execute`) { + return Promise.resolve(respond({data: {additionalData}, flowStatus: 'COMPLETE'})); + } + + return Promise.reject(new Error(`unexpected request to ${url}`)); + }); + +describe('account lifecycle', () => { + it.each<[string, string, (fetcher: ApiFetcher, operatorNote?: string) => Promise]>([ + [ + 'suspendUser', + 'userSuspendFlow', + (fetcher, operatorNote) => suspendUser({baseUrl, fetcher, operatorNote, userId: 's-1'}), + ], + [ + 'unsuspendUser', + 'userUnsuspendFlow', + (fetcher, operatorNote) => unsuspendUser({baseUrl, fetcher, operatorNote, userId: 's-1'}), + ], + [ + 'unlockUser', + 'userUnlockFlow', + (fetcher, operatorNote) => unlockUser({baseUrl, fetcher, operatorNote, userId: 's-1'}), + ], + [ + 'suspendAgent', + 'agentSuspendFlow', + (fetcher, operatorNote) => suspendAgent({agentId: 's-1', baseUrl, fetcher, operatorNote}), + ], + [ + 'unsuspendAgent', + 'agentUnsuspendFlow', + (fetcher, operatorNote) => unsuspendAgent({agentId: 's-1', baseUrl, fetcher, operatorNote}), + ], + [ + 'unlockAgent', + 'agentUnlockFlow', + (fetcher, operatorNote) => unlockAgent({agentId: 's-1', baseUrl, fetcher, operatorNote}), + ], + [ + 'suspendApplication', + 'applicationSuspendFlow', + (fetcher, operatorNote) => suspendApplication({applicationId: 's-1', baseUrl, fetcher, operatorNote}), + ], + [ + 'unsuspendApplication', + 'applicationUnsuspendFlow', + (fetcher, operatorNote) => unsuspendApplication({applicationId: 's-1', baseUrl, fetcher, operatorNote}), + ], + ])('%s runs the %s flow with the subject and the note', async (_name, configKey, run) => { + const fetcher: FetcherMock = createFlowServer({[configKey]: {defaultHandle: 'lifecycle'}}, {emailSent: 'true'}); + + await expect(run(fetcher, 'Ticket 1')).resolves.toEqual({emailSent: true}); + expect(requests(fetcher).find((request) => request.url === `${baseUrl}/flow/execute`)).toEqual({ + body: {flowId: 'flow-lifecycle', inputs: {operatorNote: 'Ticket 1', subject: 's-1'}}, + method: 'POST', + url: `${baseUrl}/flow/execute`, + }); + }); + + it.each<[string, Record, unknown]>([ + ['"false"', {emailSent: 'false'}, {emailSent: false}], + ['absent', {}, {}], + ])('reads emailSent when it is %s', async (_label, additionalData, expected) => { + const fetcher: FetcherMock = createFlowServer({userSuspendFlow: {defaultHandle: 'suspend'}}, additionalData); + + await expect(suspendUser({baseUrl, fetcher, userId: 'u-1'})).resolves.toEqual(expected); + expect(requests(fetcher).find((request) => request.url === `${baseUrl}/flow/execute`)?.body).toEqual({ + flowId: 'flow-suspend', + inputs: {subject: 'u-1'}, + }); + }); + + it.each<[string, Run]>([ + ['suspendUser', (fetcher) => suspendUser({baseUrl, fetcher, userId: 'u-1'})], + ['unlockAgent', (fetcher) => unlockAgent({agentId: 'a-1', baseUrl, fetcher})], + ['unsuspendApplication', (fetcher) => unsuspendApplication({applicationId: 'app-1', baseUrl, fetcher})], + ])('%s fails as not configured when no flow is configured, and calls nothing else', async (name, run) => { + const fetcher: FetcherMock = createFlowServer({}); + const error: unknown = await run(fetcher).catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(AdministrationFlowError); + expect(error).toMatchObject({code: `${name}-NotConfiguredError-001`}); + expect(requests(fetcher).map((request) => request.url)).toEqual([`${baseUrl}/server-config/flow`]); + }); + + it('refuses a blank subject before reading the configuration', async () => { + const fetcher: FetcherMock = createFlowServer({}); + + await expect(suspendUser({baseUrl, fetcher, userId: ''})).rejects.toMatchObject({ + code: 'suspendUser-ValidationError-002', + }); + expect(fetcher).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/javascript/src/api/management/administration/executeAdministrationFlow.ts b/packages/javascript/src/api/management/administration/executeAdministrationFlow.ts new file mode 100644 index 00000000..3f82bf7d --- /dev/null +++ b/packages/javascript/src/api/management/administration/executeAdministrationFlow.ts @@ -0,0 +1,54 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import toServerConfig from './toServerConfig'; +import AdministrationFlowError from '../../../errors/AdministrationFlowError'; +import {AdministrationFlowExecutionResponse} from '../../../models/administration'; +import {ManagementRequestConfig} from '../../../models/api'; +import requestManagementResource from '../../../utils/requestManagementResource'; + +/** + * Runs an administration flow to completion in one call and returns the values it produced. + * + * Every input the flow needs is given up front, so it should finish without pausing. A flow that asks + * for more input, or a step that refuses, raises {@link AdministrationFlowError}. + */ +const executeAdministrationFlow = async ( + config: ManagementRequestConfig, + flowId: string, + inputs: Record, + action: string, +): Promise> => { + const result: AdministrationFlowExecutionResponse | undefined = await requestManagementResource( + toServerConfig(config, 'executeAdministrationFlow'), + { + body: {flowId, inputs}, + collection: 'flow', + failureMessage: `Failed to run the ${action} flow`, + id: 'execute', + method: 'POST', + operation: 'executeAdministrationFlow', + }, + ); + + if (result?.flowStatus === 'COMPLETE') { + return result.data?.additionalData ?? {}; + } + + if (result?.flowStatus === 'INCOMPLETE') { + throw new AdministrationFlowError( + `The ${action} flow requires additional input and could not be completed.`, + 'executeAdministrationFlow-IncompleteError-001', + ); + } + + const flowError: AdministrationFlowExecutionResponse['error'] = result?.error ?? {}; + + throw new AdministrationFlowError( + flowError.message?.defaultValue ?? flowError.description?.defaultValue ?? `The ${action} flow did not complete.`, + flowError.code ?? 'executeAdministrationFlow-FlowError-001', + flowError, + ); +}; + +export default executeAdministrationFlow; diff --git a/packages/javascript/src/api/management/administration/findAdministrationFlows.ts b/packages/javascript/src/api/management/administration/findAdministrationFlows.ts new file mode 100644 index 00000000..3b35ce9b --- /dev/null +++ b/packages/javascript/src/api/management/administration/findAdministrationFlows.ts @@ -0,0 +1,58 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import toServerConfig from './toServerConfig'; +import {ADMINISTRATION_FLOW_TYPE} from '../../../constants/AdministrationConstants'; +import {ManagementRequestConfig} from '../../../models/api'; +import {ManagedFlow, ManagedFlowListResponse} from '../../../models/managed-flow'; +import requestManagementResource from '../../../utils/requestManagementResource'; + +/** + * Page size used when walking the flow listing to find a handle. It matches the server's maximum page + * size, so the usual handful of administration flows resolves in one request. + */ +const FLOW_PAGE_SIZE = 100; + +/** + * Finds the administration flows carrying the given handles, in one walk of the listing that stops once + * every handle is found. A handle with no flow is absent from the result. + * + * The listing is paged and newest first, which puts the bootstrap flows last, so pages are walked until + * the handles are found or the listing ends. + */ +const findAdministrationFlows = async ( + config: ManagementRequestConfig, + handles: readonly string[], +): Promise> => { + const wanted: Set = new Set(handles); + const found: Record = {}; + const serverConfig: ManagementRequestConfig = toServerConfig(config, 'findAdministrationFlows'); + + for (let offset = 0; wanted.size > 0; offset += FLOW_PAGE_SIZE) { + // Sequential: a page is requested only when the previous one did not complete the set. + const page: ManagedFlowListResponse | undefined = await requestManagementResource(serverConfig, { + collection: 'flows', + failureMessage: 'Failed to list the administration flows', + method: 'GET', + operation: 'findAdministrationFlows', + query: {flowType: ADMINISTRATION_FLOW_TYPE, limit: FLOW_PAGE_SIZE, offset}, + }); + const flows: ManagedFlow[] = page?.flows ?? []; + + flows.forEach((flow: ManagedFlow) => { + if (wanted.has(flow.handle) && flow.flowType === ADMINISTRATION_FLOW_TYPE) { + found[flow.handle] = flow; + wanted.delete(flow.handle); + } + }); + + // A short page is the last one, so the handles still wanted do not exist. + if (flows.length < FLOW_PAGE_SIZE) { + break; + } + } + + return found; +}; + +export default findAdministrationFlows; diff --git a/packages/javascript/src/api/management/administration/resolveAdministrationFlowHandles.ts b/packages/javascript/src/api/management/administration/resolveAdministrationFlowHandles.ts new file mode 100644 index 00000000..3fc77424 --- /dev/null +++ b/packages/javascript/src/api/management/administration/resolveAdministrationFlowHandles.ts @@ -0,0 +1,45 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import toServerConfig from './toServerConfig'; +import {AdministrationFlowConfig, AdministrationFlowConfigKeyValue} from '../../../models/administration'; +import {ManagementRequestConfig} from '../../../models/api'; +import requestManagementResource from '../../../utils/requestManagementResource'; + +/** + * Reads the handle configured for each of several operations in one request. An operation with no + * handle is absent from the result. + * + * Read failures propagate: treating one as "no flow configured" would perform the operation through + * the API directly, which revokes nothing, and report it as a success. + */ +const resolveAdministrationFlowHandles = async ( + config: ManagementRequestConfig, + configKeys: readonly AdministrationFlowConfigKeyValue[], +): Promise> => { + // The server returns the declarative, writable and merged layers; only the merged one is applied. + const layers: {merged?: Record} | undefined = await requestManagementResource( + toServerConfig(config, 'resolveAdministrationFlowHandles'), + { + collection: 'server-config', + failureMessage: 'Failed to read the administration flow configuration', + id: 'flow', + method: 'GET', + operation: 'resolveAdministrationFlowHandles', + }, + ); + const section: Record = layers?.merged ?? {}; + const handles: Record = {}; + + configKeys.forEach((configKey: AdministrationFlowConfigKeyValue) => { + const handle: string | undefined = section[configKey]?.defaultHandle; + + if (handle) { + handles[configKey] = handle; + } + }); + + return handles; +}; + +export default resolveAdministrationFlowHandles; diff --git a/packages/javascript/src/api/management/administration/resolveAdministrationFlowId.ts b/packages/javascript/src/api/management/administration/resolveAdministrationFlowId.ts new file mode 100644 index 00000000..82a75052 --- /dev/null +++ b/packages/javascript/src/api/management/administration/resolveAdministrationFlowId.ts @@ -0,0 +1,43 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import findAdministrationFlows from './findAdministrationFlows'; +import resolveAdministrationFlowHandles from './resolveAdministrationFlowHandles'; +import AdministrationFlowError from '../../../errors/AdministrationFlowError'; +import {AdministrationFlowConfigKeyValue} from '../../../models/administration'; +import {ManagementRequestConfig} from '../../../models/api'; +import {ManagedFlow} from '../../../models/managed-flow'; + +/** + * Finds the administration flow the server configures for an operation, or returns `null` when none is + * configured so the caller calls the API directly. + * + * Only an unset handle yields `null`: that is the deployment asking for the direct call. A handle that + * names a flow that no longer exists raises instead, since falling back would make the change without + * the revocation the configuration asks for. + */ +const resolveAdministrationFlowId = async ( + config: ManagementRequestConfig, + configKey: AdministrationFlowConfigKeyValue, + action: string, +): Promise => { + const handle: string | undefined = (await resolveAdministrationFlowHandles(config, [configKey]))[configKey]; + + if (!handle) { + return null; + } + + const flow: ManagedFlow | undefined = (await findAdministrationFlows(config, [handle]))[handle]; + + if (!flow) { + throw new AdministrationFlowError( + `The configured ${action} flow "${handle}" no longer exists, so the action was not performed. ` + + 'Restore the flow or clear the handle from the server configuration.', + 'resolveAdministrationFlowId-NotFoundError-001', + ); + } + + return flow.id; +}; + +export default resolveAdministrationFlowId; diff --git a/packages/javascript/src/api/management/administration/resolveAdministrationFlows.ts b/packages/javascript/src/api/management/administration/resolveAdministrationFlows.ts new file mode 100644 index 00000000..8c93f514 --- /dev/null +++ b/packages/javascript/src/api/management/administration/resolveAdministrationFlows.ts @@ -0,0 +1,31 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import findAdministrationFlows from './findAdministrationFlows'; +import resolveAdministrationFlowHandles from './resolveAdministrationFlowHandles'; +import {AdministrationFlowConfigKeyValue} from '../../../models/administration'; +import {ManagementRequestConfig} from '../../../models/api'; +import {ManagedFlow} from '../../../models/managed-flow'; + +/** + * Resolves each operation whose configured handle names an existing administration flow to that flow's + * identifier, in one configuration read and one listing walk. An operation with no flow is absent. + */ +const resolveAdministrationFlows = async ( + config: ManagementRequestConfig, + configKeys: readonly AdministrationFlowConfigKeyValue[], +): Promise> => { + const handles: Record = await resolveAdministrationFlowHandles(config, configKeys); + const flows: Record = await findAdministrationFlows(config, Object.values(handles)); + const resolved: Record = {}; + + Object.entries(handles).forEach(([configKey, handle]: [string, string]) => { + if (flows[handle]) { + resolved[configKey] = flows[handle].id; + } + }); + + return resolved; +}; + +export default resolveAdministrationFlows; diff --git a/packages/javascript/src/api/management/administration/runAccountLifecycleFlow.ts b/packages/javascript/src/api/management/administration/runAccountLifecycleFlow.ts new file mode 100644 index 00000000..5808ee4e --- /dev/null +++ b/packages/javascript/src/api/management/administration/runAccountLifecycleFlow.ts @@ -0,0 +1,84 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import runAdministrationFlow from './runAdministrationFlow'; +import {AdministrationFlowData, AdministrationFlowInput} from '../../../constants/AdministrationConstants'; +import AdministrationFlowError from '../../../errors/AdministrationFlowError'; +import ThunderIDAPIError from '../../../errors/ThunderIDAPIError'; +import {AccountLifecycleResult, AdministrationFlowConfigKeyValue} from '../../../models/administration'; +import {ManagementRequestConfig} from '../../../models/api'; + +/** + * Describes one account lifecycle operation, such as suspending a user. + */ +export interface AccountLifecycleOperation { + /** + * What the flow does, for messages, such as `user suspension`. + */ + action: string; + /** + * The `flow` server-config key that names the operation's flow. + */ + configKey: AdministrationFlowConfigKeyValue; + /** + * Name of the calling operation, used as the prefix of every error code it raises. + */ + operation: string; + /** + * The operator's note, passed to the flow when given. + */ + operatorNote?: string; + /** + * Identifier of the account the operation acts on. + */ + subject: string; +} + +/** + * Runs an account lifecycle operation, such as suspending, unsuspending or unlocking an account, through + * the administration flow the server configures for it. + * + * These operations have no management API endpoint: the flow is the only way to carry them out. So, + * unlike a deletion, an operation with no flow configured is refused with + * `{operation}-NotConfiguredError-001` rather than falling back to a direct call. + * + * @returns Whether the flow's email step sent its notification, when the flow has one. + */ +const runAccountLifecycleFlow = async ( + config: ManagementRequestConfig, + {action, configKey, operation, operatorNote, subject}: AccountLifecycleOperation, +): Promise => { + if (typeof subject !== 'string' || subject.trim() === '') { + throw new ThunderIDAPIError( + 'A resource identifier is required.', + `${operation}-ValidationError-002`, + 'javascript', + 400, + 'The provided resource identifier is empty.', + ); + } + + const result: Record | null = await runAdministrationFlow( + config, + configKey, + { + [AdministrationFlowInput.SUBJECT]: subject, + ...(operatorNote !== undefined ? {[AdministrationFlowInput.OPERATOR_NOTE]: operatorNote} : {}), + }, + action, + ); + + if (result === null) { + throw new AdministrationFlowError( + `No ${action} flow is configured, so the action was not performed. ` + + `Configure a flow for "${configKey}" in the server's flow configuration.`, + `${operation}-NotConfiguredError-001`, + ); + } + + const emailSent: string | undefined = result[AdministrationFlowData.EMAIL_SENT]; + + return emailSent === undefined ? {} : {emailSent: emailSent === 'true'}; +}; + +export default runAccountLifecycleFlow; diff --git a/packages/javascript/src/api/management/administration/runAdministrationFlow.ts b/packages/javascript/src/api/management/administration/runAdministrationFlow.ts new file mode 100644 index 00000000..3491686d --- /dev/null +++ b/packages/javascript/src/api/management/administration/runAdministrationFlow.ts @@ -0,0 +1,25 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import executeAdministrationFlow from './executeAdministrationFlow'; +import resolveAdministrationFlowId from './resolveAdministrationFlowId'; +import {AdministrationFlowConfigKeyValue} from '../../../models/administration'; +import {ManagementRequestConfig} from '../../../models/api'; + +/** + * Runs the administration flow the server configures for an operation and returns the values it + * produced, or returns `null` when none is configured so the caller calls the API directly. See + * {@link resolveAdministrationFlowId}. + */ +const runAdministrationFlow = async ( + config: ManagementRequestConfig, + configKey: AdministrationFlowConfigKeyValue, + inputs: Record, + action: string, +): Promise | null> => { + const flowId: string | null = await resolveAdministrationFlowId(config, configKey, action); + + return flowId === null ? null : executeAdministrationFlow(config, flowId, inputs, action); +}; + +export default runAdministrationFlow; diff --git a/packages/javascript/src/api/management/administration/toServerConfig.ts b/packages/javascript/src/api/management/administration/toServerConfig.ts new file mode 100644 index 00000000..1386761d --- /dev/null +++ b/packages/javascript/src/api/management/administration/toServerConfig.ts @@ -0,0 +1,25 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import ThunderIDAPIError from '../../../errors/ThunderIDAPIError'; +import {ManagementRequestConfig} from '../../../models/api'; + +/** + * The request config of a server-level call: the administration flows and their configuration live on + * the server at `baseUrl`, whatever collection URL the operation itself was given. + */ +const toServerConfig = (config: ManagementRequestConfig, operation: string): ManagementRequestConfig => { + if (!config.baseUrl) { + throw new ThunderIDAPIError( + 'A `baseUrl` is required to run an administration flow.', + `${operation}-ValidationError-003`, + 'javascript', + 400, + 'Running an administration flow needs the server URL, not only a collection URL.', + ); + } + + return {...config, url: undefined}; +}; + +export default toServerConfig; diff --git a/packages/javascript/src/api/management/agent-types/createAgentType.ts b/packages/javascript/src/api/management/agent-types/createAgentType.ts new file mode 100644 index 00000000..0490d1b6 --- /dev/null +++ b/packages/javascript/src/api/management/agent-types/createAgentType.ts @@ -0,0 +1,41 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ManagementRequestConfig} from '../../../models/api'; +import {AgentTypeDetails, CreateAgentTypeRequest} from '../../../models/entity-type'; +import requestManagementResource from '../../../utils/requestManagementResource'; + +/** + * Configuration for the createAgentType request. + */ +export interface CreateAgentTypeConfig extends ManagementRequestConfig { + /** + * The agent type to create. + */ + payload: CreateAgentTypeRequest; +} + +/** + * Creates the agent type. The server holds a single agent type, whose handle is `default`. + * + * @param config - Request configuration object. + * @returns A promise that resolves with the agent type as the server stored it. + * + * @example + * ```typescript + * const agentType = await createAgentType({ + * baseUrl: 'https://localhost:8090', + * payload: {displayName: 'Default', handle: 'default', ouId, schema: {}}, + * }); + * ``` + */ +const createAgentType = async ({payload, ...config}: CreateAgentTypeConfig): Promise => + requestManagementResource(config, { + body: payload, + collection: 'agent-types', + failureMessage: 'Failed to create agent type', + method: 'POST', + operation: 'createAgentType', + }); + +export default createAgentType; diff --git a/packages/javascript/src/api/management/agent-types/deleteAgentType.ts b/packages/javascript/src/api/management/agent-types/deleteAgentType.ts new file mode 100644 index 00000000..05584641 --- /dev/null +++ b/packages/javascript/src/api/management/agent-types/deleteAgentType.ts @@ -0,0 +1,39 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ManagementRequestConfig} from '../../../models/api'; +import requestManagementResource from '../../../utils/requestManagementResource'; + +/** + * Configuration for the deleteAgentType request. + */ +export interface DeleteAgentTypeConfig extends ManagementRequestConfig { + /** + * Identifier of the agent type to delete. + */ + agentTypeId: string; +} + +/** + * Deletes an agent type. The server currently refuses this for its single `default` agent type + * (`USRS-1015`), which agent creation depends on; update it in place instead. + * + * @param config - Request configuration object. + * @returns A promise that resolves once the server has made the change. + * + * @example + * ```typescript + * await deleteAgentType({baseUrl: 'https://localhost:8090', agentTypeId}); + * ``` + */ +const deleteAgentType = async ({agentTypeId, ...config}: DeleteAgentTypeConfig): Promise => { + await requestManagementResource(config, { + collection: 'agent-types', + failureMessage: 'Failed to delete agent type', + id: agentTypeId, + method: 'DELETE', + operation: 'deleteAgentType', + }); +}; + +export default deleteAgentType; diff --git a/packages/javascript/src/api/management/agent-types/getAgentType.ts b/packages/javascript/src/api/management/agent-types/getAgentType.ts new file mode 100644 index 00000000..6a361246 --- /dev/null +++ b/packages/javascript/src/api/management/agent-types/getAgentType.ts @@ -0,0 +1,28 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ManagementRequestConfig} from '../../../models/api'; +import {AgentTypeDetails} from '../../../models/entity-type'; +import requestManagementResource from '../../../utils/requestManagementResource'; + +export interface GetAgentTypeConfig extends ManagementRequestConfig { + /** + * Identifier of the agent type. + */ + agentTypeId: string; +} + +/** + * Fetches an agent type with its attribute schema. + */ +const getAgentType = async ({agentTypeId, ...config}: GetAgentTypeConfig): Promise => + requestManagementResource(config, { + collection: 'agent-types', + failureMessage: 'Failed to fetch agent type', + id: agentTypeId, + method: 'GET', + operation: 'getAgentType', + query: {include: 'display'}, + }); + +export default getAgentType; diff --git a/packages/javascript/src/api/management/agent-types/updateAgentType.ts b/packages/javascript/src/api/management/agent-types/updateAgentType.ts new file mode 100644 index 00000000..aa957189 --- /dev/null +++ b/packages/javascript/src/api/management/agent-types/updateAgentType.ts @@ -0,0 +1,32 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ManagementRequestConfig} from '../../../models/api'; +import {AgentTypeDetails, UpdateAgentTypeRequest} from '../../../models/entity-type'; +import requestManagementResource from '../../../utils/requestManagementResource'; + +export interface UpdateAgentTypeConfig extends ManagementRequestConfig { + /** + * Identifier of the agent type. + */ + agentTypeId: string; + /** + * The agent type's new state. + */ + payload: UpdateAgentTypeRequest; +} + +/** + * Updates an agent type. + */ +const updateAgentType = async ({agentTypeId, payload, ...config}: UpdateAgentTypeConfig): Promise => + requestManagementResource(config, { + body: payload, + collection: 'agent-types', + failureMessage: 'Failed to update agent type', + id: agentTypeId, + method: 'PUT', + operation: 'updateAgentType', + }); + +export default updateAgentType; diff --git a/packages/javascript/src/api/management/agents/getAgentTypes.ts b/packages/javascript/src/api/management/agents/getAgentTypes.ts index d185e49f..6bdf3833 100644 --- a/packages/javascript/src/api/management/agents/getAgentTypes.ts +++ b/packages/javascript/src/api/management/agents/getAgentTypes.ts @@ -27,7 +27,7 @@ const getAgentTypes = async ({limit, offset, ...config}: GetAgentTypesConfig): P failureMessage: 'Failed to fetch agent types', method: 'GET', operation: 'getAgentTypes', - query: {limit, offset}, + query: {include: 'display', limit, offset}, }); export default getAgentTypes; diff --git a/packages/javascript/src/api/management/agents/regenerateAgentSecret.ts b/packages/javascript/src/api/management/agents/regenerateAgentSecret.ts new file mode 100644 index 00000000..99ec6d6d --- /dev/null +++ b/packages/javascript/src/api/management/agents/regenerateAgentSecret.ts @@ -0,0 +1,74 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import getAgent from './getAgent'; +import updateAgent from './updateAgent'; +import ThunderIDAPIError from '../../../errors/ThunderIDAPIError'; +import {Agent, AgentInboundAuthConfig} from '../../../models/agent-resource'; +import {ManagementRequestConfig} from '../../../models/api'; +import generateSecret from '../../../utils/generateSecret'; +import isDelegatedAgent from '../../../utils/isDelegatedAgent'; + +export interface RegenerateAgentSecretConfig extends ManagementRequestConfig { + /** + * Identifier of the agent. + */ + agentId: string; +} + +export interface RegenerateAgentSecretResult { + /** + * The agent after the change. + */ + agent: Agent; + /** + * The new client secret. This is the only time it can be read. + */ + clientSecret: string; +} + +/** + * Gives an agent's OAuth client a new secret, generated here and saved on the agent. Fails when the + * agent has no OAuth client. + */ +const regenerateAgentSecret = async ({ + agentId, + ...config +}: RegenerateAgentSecretConfig): Promise => { + // The server sets these, and `ouHandle` is only display data, so they do not go back in the update. + const {clientId, id, ouHandle, ...current}: Agent = await getAgent({...config, agentId}); + void clientId; + void id; + void ouHandle; + const inboundAuthConfig: AgentInboundAuthConfig[] = current.inboundAuthConfig ?? []; + + if (!inboundAuthConfig.some((inbound: AgentInboundAuthConfig) => inbound.type === 'oauth2')) { + throw new ThunderIDAPIError( + 'The agent has no OAuth client, so it has no client secret to regenerate.', + 'regenerateAgentSecret-ValidationError-001', + 'javascript', + 400, + 'The agent has no OAuth client.', + ); + } + + const clientSecret: string = generateSecret(); + const updatedInboundAuthConfig: AgentInboundAuthConfig[] = inboundAuthConfig.map((inbound: AgentInboundAuthConfig) => + inbound.type === 'oauth2' + ? {...inbound, config: {...(inbound.config ?? {grantTypes: [], responseTypes: []}), clientSecret}} + : inbound, + ); + const agent: Agent = await updateAgent({ + ...config, + agentId, + payload: { + ...current, + delegated: isDelegatedAgent(updatedInboundAuthConfig), + inboundAuthConfig: updatedInboundAuthConfig, + }, + }); + + return {agent, clientSecret}; +}; + +export default regenerateAgentSecret; diff --git a/packages/javascript/src/api/management/agents/suspendAgent.ts b/packages/javascript/src/api/management/agents/suspendAgent.ts new file mode 100644 index 00000000..6311417b --- /dev/null +++ b/packages/javascript/src/api/management/agents/suspendAgent.ts @@ -0,0 +1,43 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {AdministrationFlowConfigKey} from '../../../constants/AdministrationConstants'; +import {AccountLifecycleOptions, AccountLifecycleResult} from '../../../models/administration'; +import {ManagementRequestConfig} from '../../../models/api'; +import runAccountLifecycleFlow from '../administration/runAccountLifecycleFlow'; + +/** + * Configuration for the suspendAgent request. + */ +export interface SuspendAgentConfig extends ManagementRequestConfig, AccountLifecycleOptions { + /** + * Identifier of the agent. + */ + agentId: string; +} + +/** + * Suspends an agent, so it can no longer authenticate, through the agent suspension flow the server + * configures. There is no management API endpoint for this, so when no flow is configured the call fails + * with `suspendAgent-NotConfiguredError-001`. + * + * @param config - Request configuration object. Running a flow needs `baseUrl`, and a token that may read + * the server configuration and run administration flows. + * @returns A promise that resolves with whether the flow's email step sent its notification, absent when + * the flow has no email step. + * + * @example + * ```typescript + * const {emailSent} = await suspendAgent({baseUrl: 'https://localhost:8090', agentId, operatorNote: 'Ticket 4521'}); + * ``` + */ +const suspendAgent = async ({agentId, operatorNote, ...config}: SuspendAgentConfig): Promise => + runAccountLifecycleFlow(config, { + action: 'agent suspension', + configKey: AdministrationFlowConfigKey.AGENT_SUSPEND, + operation: 'suspendAgent', + operatorNote, + subject: agentId, + }); + +export default suspendAgent; diff --git a/packages/javascript/src/api/management/agents/unlockAgent.ts b/packages/javascript/src/api/management/agents/unlockAgent.ts new file mode 100644 index 00000000..24516324 --- /dev/null +++ b/packages/javascript/src/api/management/agents/unlockAgent.ts @@ -0,0 +1,43 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {AdministrationFlowConfigKey} from '../../../constants/AdministrationConstants'; +import {AccountLifecycleOptions, AccountLifecycleResult} from '../../../models/administration'; +import {ManagementRequestConfig} from '../../../models/api'; +import runAccountLifecycleFlow from '../administration/runAccountLifecycleFlow'; + +/** + * Configuration for the unlockAgent request. + */ +export interface UnlockAgentConfig extends ManagementRequestConfig, AccountLifecycleOptions { + /** + * Identifier of the agent. + */ + agentId: string; +} + +/** + * Unlocks an agent locked out after repeated failed authentication attempts, through the agent unlock flow + * the server configures. There is no management API endpoint for this, so when no flow is configured the + * call fails with `unlockAgent-NotConfiguredError-001`. + * + * @param config - Request configuration object. Running a flow needs `baseUrl`, and a token that may read + * the server configuration and run administration flows. + * @returns A promise that resolves with whether the flow's email step sent its notification, absent when + * the flow has no email step. + * + * @example + * ```typescript + * const {emailSent} = await unlockAgent({baseUrl: 'https://localhost:8090', agentId, operatorNote: 'Ticket 4521'}); + * ``` + */ +const unlockAgent = async ({agentId, operatorNote, ...config}: UnlockAgentConfig): Promise => + runAccountLifecycleFlow(config, { + action: 'agent unlock', + configKey: AdministrationFlowConfigKey.AGENT_UNLOCK, + operation: 'unlockAgent', + operatorNote, + subject: agentId, + }); + +export default unlockAgent; diff --git a/packages/javascript/src/api/management/agents/unsuspendAgent.ts b/packages/javascript/src/api/management/agents/unsuspendAgent.ts new file mode 100644 index 00000000..7baefa57 --- /dev/null +++ b/packages/javascript/src/api/management/agents/unsuspendAgent.ts @@ -0,0 +1,47 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {AdministrationFlowConfigKey} from '../../../constants/AdministrationConstants'; +import {AccountLifecycleOptions, AccountLifecycleResult} from '../../../models/administration'; +import {ManagementRequestConfig} from '../../../models/api'; +import runAccountLifecycleFlow from '../administration/runAccountLifecycleFlow'; + +/** + * Configuration for the unsuspendAgent request. + */ +export interface UnsuspendAgentConfig extends ManagementRequestConfig, AccountLifecycleOptions { + /** + * Identifier of the agent. + */ + agentId: string; +} + +/** + * Lifts the suspension of an agent through the agent unsuspension flow the server configures. There is no + * management API endpoint for this, so when no flow is configured the call fails with + * `unsuspendAgent-NotConfiguredError-001`. + * + * @param config - Request configuration object. Running a flow needs `baseUrl`, and a token that may read + * the server configuration and run administration flows. + * @returns A promise that resolves with whether the flow's email step sent its notification, absent when + * the flow has no email step. + * + * @example + * ```typescript + * const {emailSent} = await unsuspendAgent({baseUrl: 'https://localhost:8090', agentId, operatorNote: 'Ticket 4521'}); + * ``` + */ +const unsuspendAgent = async ({ + agentId, + operatorNote, + ...config +}: UnsuspendAgentConfig): Promise => + runAccountLifecycleFlow(config, { + action: 'agent unsuspension', + configKey: AdministrationFlowConfigKey.AGENT_UNSUSPEND, + operation: 'unsuspendAgent', + operatorNote, + subject: agentId, + }); + +export default unsuspendAgent; diff --git a/packages/javascript/src/api/management/applications/createApplicationSharingPolicy.ts b/packages/javascript/src/api/management/applications/createApplicationSharingPolicy.ts new file mode 100644 index 00000000..f938d6a4 --- /dev/null +++ b/packages/javascript/src/api/management/applications/createApplicationSharingPolicy.ts @@ -0,0 +1,52 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ManagementRequestConfig} from '../../../models/api'; +import {SharingPolicy, SharingPolicyRequest} from '../../../models/sharing-policy'; +import requestManagementResource from '../../../utils/requestManagementResource'; + +/** + * Configuration for the createApplicationSharingPolicy request. + */ +export interface CreateApplicationSharingPolicyConfig extends ManagementRequestConfig { + /** + * Identifier of the application. + */ + applicationId: string; + /** + * The policy to create. + */ + payload: SharingPolicyRequest; +} + +/** + * Creates a sharing policy for an application: the organization units it reaches beyond its owner. + * + * @param config - Request configuration object. + * @returns A promise that resolves with the policy as the server stored it. + * + * @example + * ```typescript + * const policy = await createApplicationSharingPolicy({ + * baseUrl: 'https://localhost:8090', + * applicationId, + * payload: {childUnits: {mode: 'all'}}, + * }); + * ``` + */ +const createApplicationSharingPolicy = async ({ + applicationId, + payload, + ...config +}: CreateApplicationSharingPolicyConfig): Promise => + requestManagementResource(config, { + body: payload, + collection: 'applications', + failureMessage: 'Failed to create sharing policy', + id: applicationId, + method: 'POST', + operation: 'createApplicationSharingPolicy', + subPath: 'sharing-policies', + }); + +export default createApplicationSharingPolicy; diff --git a/packages/javascript/src/api/management/applications/deleteApplication.ts b/packages/javascript/src/api/management/applications/deleteApplication.ts index a9a9f90d..443c8525 100644 --- a/packages/javascript/src/api/management/applications/deleteApplication.ts +++ b/packages/javascript/src/api/management/applications/deleteApplication.ts @@ -1,13 +1,16 @@ -// Copyright 2025-2026 The ThunderID Authors +// Copyright 2026 The ThunderID Authors // SPDX-License-Identifier: Apache-2.0 +import AdministrationModes, { + AdministrationFlowConfigKey, + AdministrationFlowInput, +} from '../../../constants/AdministrationConstants'; +import {AdministrationOptions} from '../../../models/administration'; import {ManagementRequestConfig} from '../../../models/api'; import requestManagementResource from '../../../utils/requestManagementResource'; +import runAdministrationFlow from '../administration/runAdministrationFlow'; -/** - * Configuration for the deleteApplication request. - */ -export interface DeleteApplicationConfig extends ManagementRequestConfig { +export interface DeleteApplicationConfig extends ManagementRequestConfig, AdministrationOptions { /** * Identifier of the application to delete. */ @@ -15,26 +18,34 @@ export interface DeleteApplicationConfig extends ManagementRequestConfig { } /** - * Deletes an application. - * - * @param config - Request configuration object. - * @returns A promise that resolves once the application is deleted. - * - * @example - * ```typescript - * await deleteApplication({ - * baseUrl: 'https://localhost:8090', - * applicationId: '550e8400-e29b-41d4-a716-446655440000', - * }); - * ``` + * Deletes an application. In `flow` mode the configured application deletion flow runs instead, + * revoking what the application was granted before the record goes. */ -const deleteApplication = async ({applicationId, ...config}: DeleteApplicationConfig): Promise => - requestManagementResource(config, { +const deleteApplication = async ({ + administrationMode, + applicationId, + ...config +}: DeleteApplicationConfig): Promise => { + if (administrationMode === AdministrationModes.FLOW) { + const result: Record | null = await runAdministrationFlow( + config, + AdministrationFlowConfigKey.APPLICATION_DELETION, + {[AdministrationFlowInput.APPLICATION]: applicationId}, + 'application deletion', + ); + + if (result !== null) { + return; + } + } + + await requestManagementResource(config, { collection: 'applications', failureMessage: 'Failed to delete application', id: applicationId, method: 'DELETE', operation: 'deleteApplication', }); +}; export default deleteApplication; diff --git a/packages/javascript/src/api/management/applications/deleteApplicationSharingPolicy.ts b/packages/javascript/src/api/management/applications/deleteApplicationSharingPolicy.ts new file mode 100644 index 00000000..aafc0f1d --- /dev/null +++ b/packages/javascript/src/api/management/applications/deleteApplicationSharingPolicy.ts @@ -0,0 +1,48 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ManagementRequestConfig} from '../../../models/api'; +import encodePathSegments from '../../../utils/encodePathSegments'; +import requestManagementResource from '../../../utils/requestManagementResource'; + +/** + * Configuration for the deleteApplicationSharingPolicy request. + */ +export interface DeleteApplicationSharingPolicyConfig extends ManagementRequestConfig { + /** + * Identifier of the application. + */ + applicationId: string; + /** + * Identifier of the sharing policy. + */ + policyId: string; +} + +/** + * Deletes a sharing policy of an application. + * + * @param config - Request configuration object. + * @returns A promise that resolves once the server has made the change. + * + * @example + * ```typescript + * await deleteApplicationSharingPolicy({baseUrl: 'https://localhost:8090', applicationId, policyId}); + * ``` + */ +const deleteApplicationSharingPolicy = async ({ + applicationId, + policyId, + ...config +}: DeleteApplicationSharingPolicyConfig): Promise => { + await requestManagementResource(config, { + collection: 'applications', + failureMessage: 'Failed to delete sharing policy', + id: applicationId, + method: 'DELETE', + operation: 'deleteApplicationSharingPolicy', + subPath: `sharing-policies/${encodePathSegments('deleteApplicationSharingPolicy', [policyId])}`, + }); +}; + +export default deleteApplicationSharingPolicy; diff --git a/packages/javascript/src/api/management/applications/getApplicationOverlayRules.ts b/packages/javascript/src/api/management/applications/getApplicationOverlayRules.ts new file mode 100644 index 00000000..577846fb --- /dev/null +++ b/packages/javascript/src/api/management/applications/getApplicationOverlayRules.ts @@ -0,0 +1,48 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ManagementRequestConfig} from '../../../models/api'; +import {ResolvedOverlay} from '../../../models/sharing-policy'; +import requestManagementResource from '../../../utils/requestManagementResource'; + +/** + * Configuration for the getApplicationOverlayRules request. + */ +export interface GetApplicationOverlayRulesConfig extends ManagementRequestConfig { + /** + * Identifier of the application. + */ + applicationId: string; + /** + * Identifier of the organization unit to resolve the rules for. + */ + ouId: string; +} + +/** + * Retrieves the overlay rules that apply to an application in an organization unit. + * + * @param config - Request configuration object. + * @returns A promise that resolves with the resolved overlay. + * + * @example + * ```typescript + * const {rules} = await getApplicationOverlayRules({baseUrl: 'https://localhost:8090', applicationId, ouId}); + * ``` + */ +const getApplicationOverlayRules = async ({ + applicationId, + ouId, + ...config +}: GetApplicationOverlayRulesConfig): Promise => + requestManagementResource(config, { + collection: 'applications', + failureMessage: 'Failed to fetch overlay rules', + id: applicationId, + method: 'GET', + operation: 'getApplicationOverlayRules', + query: {ouId}, + subPath: 'overlay-rules', + }); + +export default getApplicationOverlayRules; diff --git a/packages/javascript/src/api/management/applications/getApplicationSharingPolicies.ts b/packages/javascript/src/api/management/applications/getApplicationSharingPolicies.ts new file mode 100644 index 00000000..6389ac41 --- /dev/null +++ b/packages/javascript/src/api/management/applications/getApplicationSharingPolicies.ts @@ -0,0 +1,47 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ApiFilteringParams, ManagementRequestConfig} from '../../../models/api'; +import {SharingPolicyListResponse} from '../../../models/sharing-policy'; +import requestManagementResource from '../../../utils/requestManagementResource'; + +/** + * Configuration for the getApplicationSharingPolicies request. + */ +export interface GetApplicationSharingPoliciesConfig + extends ManagementRequestConfig, + Pick { + /** + * Identifier of the application. + */ + applicationId: string; +} + +/** + * Retrieves a page of the sharing policies of an application. + * + * @param config - Request configuration object. + * @returns A promise that resolves with the page the server returned. + * + * @example + * ```typescript + * const {policies} = await getApplicationSharingPolicies({baseUrl: 'https://localhost:8090', applicationId}); + * ``` + */ +const getApplicationSharingPolicies = async ({ + applicationId, + limit, + offset, + ...config +}: GetApplicationSharingPoliciesConfig): Promise => + requestManagementResource(config, { + collection: 'applications', + failureMessage: 'Failed to fetch sharing policies', + id: applicationId, + method: 'GET', + operation: 'getApplicationSharingPolicies', + query: {limit, offset}, + subPath: 'sharing-policies', + }); + +export default getApplicationSharingPolicies; diff --git a/packages/javascript/src/api/management/applications/getApplicationSharingPolicy.ts b/packages/javascript/src/api/management/applications/getApplicationSharingPolicy.ts new file mode 100644 index 00000000..f8b59d7f --- /dev/null +++ b/packages/javascript/src/api/management/applications/getApplicationSharingPolicy.ts @@ -0,0 +1,48 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ManagementRequestConfig} from '../../../models/api'; +import {SharingPolicy} from '../../../models/sharing-policy'; +import encodePathSegments from '../../../utils/encodePathSegments'; +import requestManagementResource from '../../../utils/requestManagementResource'; + +/** + * Configuration for the getApplicationSharingPolicy request. + */ +export interface GetApplicationSharingPolicyConfig extends ManagementRequestConfig { + /** + * Identifier of the application. + */ + applicationId: string; + /** + * Identifier of the sharing policy. + */ + policyId: string; +} + +/** + * Retrieves a sharing policy of an application. + * + * @param config - Request configuration object. + * @returns A promise that resolves with the policy. + * + * @example + * ```typescript + * const policy = await getApplicationSharingPolicy({baseUrl: 'https://localhost:8090', applicationId, policyId}); + * ``` + */ +const getApplicationSharingPolicy = async ({ + applicationId, + policyId, + ...config +}: GetApplicationSharingPolicyConfig): Promise => + requestManagementResource(config, { + collection: 'applications', + failureMessage: 'Failed to fetch sharing policy', + id: applicationId, + method: 'GET', + operation: 'getApplicationSharingPolicy', + subPath: `sharing-policies/${encodePathSegments('getApplicationSharingPolicy', [policyId])}`, + }); + +export default getApplicationSharingPolicy; diff --git a/packages/javascript/src/api/management/applications/previewCimdDocument.ts b/packages/javascript/src/api/management/applications/previewCimdDocument.ts new file mode 100644 index 00000000..ef084006 --- /dev/null +++ b/packages/javascript/src/api/management/applications/previewCimdDocument.ts @@ -0,0 +1,34 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ManagementRequestConfig} from '../../../models/api'; +import {CimdPreviewResponse} from '../../../models/application'; +import requestManagementResource from '../../../utils/requestManagementResource'; + +export interface PreviewCimdDocumentConfig extends ManagementRequestConfig { + /** + * The client ID: the URL of the client's metadata document. + */ + clientId: string; +} + +/** + * Reads a client ID metadata document (CIMD) through the server and returns what it declares, so an + * application can be registered from it. + */ +const previewCimdDocument = async ({clientId, ...config}: PreviewCimdDocumentConfig): Promise => + // The preview is a server endpoint of its own, not part of a collection, so a collection `url` + // the caller passes does not apply. + requestManagementResource( + {...config, url: undefined}, + { + body: {clientId}, + collection: 'cimd', + failureMessage: 'Failed to read the client ID metadata document', + id: 'preview', + method: 'POST', + operation: 'previewCimdDocument', + }, + ); + +export default previewCimdDocument; diff --git a/packages/javascript/src/api/management/applications/regenerateClientSecret.ts b/packages/javascript/src/api/management/applications/regenerateClientSecret.ts new file mode 100644 index 00000000..b99bf915 --- /dev/null +++ b/packages/javascript/src/api/management/applications/regenerateClientSecret.ts @@ -0,0 +1,106 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import getApplication from './getApplication'; +import updateApplication from './updateApplication'; +import AdministrationModes, { + AdministrationFlowConfigKey, + AdministrationFlowData, + AdministrationFlowInput, +} from '../../../constants/AdministrationConstants'; +import AdministrationFlowError from '../../../errors/AdministrationFlowError'; +import ThunderIDAPIError from '../../../errors/ThunderIDAPIError'; +import {AdministrationOptions} from '../../../models/administration'; +import {ManagementRequestConfig} from '../../../models/api'; +import {Application, InboundAuthConfig} from '../../../models/application'; +import generateSecret from '../../../utils/generateSecret'; +import runAdministrationFlow from '../administration/runAdministrationFlow'; + +export interface RegenerateClientSecretConfig extends ManagementRequestConfig, AdministrationOptions { + /** + * Identifier of the application. + */ + applicationId: string; +} + +export interface RegenerateClientSecretResult { + /** + * The application after the change. Absent when a flow rotated the secret, since the flow does not + * return the application. + */ + application?: Application; + /** + * The new client secret. This is the only time it can be read. + */ + clientSecret: string; +} + +/** + * Gives an application a new OAuth client secret. In `flow` mode the configured secret regeneration + * flow rotates it, revoking what the old secret was used for; otherwise a new secret is generated here + * and saved on the application. + */ +const regenerateClientSecret = async ({ + administrationMode, + applicationId, + ...config +}: RegenerateClientSecretConfig): Promise => { + if (administrationMode === AdministrationModes.FLOW) { + const result: Record | null = await runAdministrationFlow( + config, + AdministrationFlowConfigKey.SECRET_REGENERATION, + {[AdministrationFlowInput.APPLICATION]: applicationId}, + 'client secret regeneration', + ); + + if (result !== null) { + const clientSecret: string | undefined = result[AdministrationFlowData.CLIENT_SECRET]; + + if (!clientSecret) { + throw new AdministrationFlowError( + 'The client secret regeneration flow completed without returning a secret.', + 'regenerateClientSecret-FlowError-001', + ); + } + + return {clientSecret}; + } + } + + // The server sets these, so they do not go back in the update. + const {createdAt, id, status, updatedAt, ...current}: Application = await getApplication({ + ...config, + applicationId, + }); + void createdAt; + void id; + void status; + void updatedAt; + const inboundAuthConfig: InboundAuthConfig[] = current.inboundAuthConfig ?? []; + + if (!inboundAuthConfig.some((inbound: InboundAuthConfig) => inbound.type === 'oauth2')) { + throw new ThunderIDAPIError( + 'The application has no OAuth client, so it has no client secret to regenerate.', + 'regenerateClientSecret-ValidationError-001', + 'javascript', + 400, + 'The application has no OAuth client.', + ); + } + + const clientSecret: string = generateSecret(); + const application: Application = await updateApplication({ + ...config, + applicationId, + payload: { + ...current, + inboundAuthConfig: inboundAuthConfig.map((inbound: InboundAuthConfig) => + inbound.type === 'oauth2' ? {...inbound, config: {...inbound.config, clientSecret}} : inbound, + ), + }, + }); + + return {application, clientSecret}; +}; + +export default regenerateClientSecret; diff --git a/packages/javascript/src/api/management/applications/regenerateFlowSecret.ts b/packages/javascript/src/api/management/applications/regenerateFlowSecret.ts new file mode 100644 index 00000000..3e54b5fd --- /dev/null +++ b/packages/javascript/src/api/management/applications/regenerateFlowSecret.ts @@ -0,0 +1,54 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import getApplication from './getApplication'; +import updateApplication from './updateApplication'; +import {ManagementRequestConfig} from '../../../models/api'; +import {Application} from '../../../models/application'; +import generateSecret from '../../../utils/generateSecret'; + +export interface RegenerateFlowSecretConfig extends ManagementRequestConfig { + /** + * Identifier of the application. + */ + applicationId: string; +} + +export interface RegenerateFlowSecretResult { + /** + * The application after the change. + */ + application: Application; + /** + * The new flow secret. This is the only time it can be read. + */ + flowSecret: string; +} + +/** + * Gives an application a new flow secret, generated here and saved on the application. + */ +const regenerateFlowSecret = async ({ + applicationId, + ...config +}: RegenerateFlowSecretConfig): Promise => { + // The server sets these, so they do not go back in the update. + const {createdAt, id, status, updatedAt, ...current}: Application = await getApplication({ + ...config, + applicationId, + }); + void createdAt; + void id; + void status; + void updatedAt; + const flowSecret: string = generateSecret(); + const application: Application = await updateApplication({ + ...config, + applicationId, + payload: {...current, flowSecret}, + }); + + return {application, flowSecret}; +}; + +export default regenerateFlowSecret; diff --git a/packages/javascript/src/api/management/applications/suspendApplication.ts b/packages/javascript/src/api/management/applications/suspendApplication.ts new file mode 100644 index 00000000..32c2b54f --- /dev/null +++ b/packages/javascript/src/api/management/applications/suspendApplication.ts @@ -0,0 +1,47 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {AdministrationFlowConfigKey} from '../../../constants/AdministrationConstants'; +import {AccountLifecycleOptions, AccountLifecycleResult} from '../../../models/administration'; +import {ManagementRequestConfig} from '../../../models/api'; +import runAccountLifecycleFlow from '../administration/runAccountLifecycleFlow'; + +/** + * Configuration for the suspendApplication request. + */ +export interface SuspendApplicationConfig extends ManagementRequestConfig, AccountLifecycleOptions { + /** + * Identifier of the application. + */ + applicationId: string; +} + +/** + * Suspends an application, so it can no longer be used to sign in or obtain tokens, through the application + * suspension flow the server configures. There is no management API endpoint for this, so when no flow is + * configured the call fails with `suspendApplication-NotConfiguredError-001`. + * + * @param config - Request configuration object. Running a flow needs `baseUrl`, and a token that may read + * the server configuration and run administration flows. + * @returns A promise that resolves with whether the flow's email step sent its notification, absent when + * the flow has no email step. + * + * @example + * ```typescript + * const {emailSent} = await suspendApplication({baseUrl: 'https://localhost:8090', applicationId, operatorNote: 'Ticket 4521'}); + * ``` + */ +const suspendApplication = async ({ + applicationId, + operatorNote, + ...config +}: SuspendApplicationConfig): Promise => + runAccountLifecycleFlow(config, { + action: 'application suspension', + configKey: AdministrationFlowConfigKey.APPLICATION_SUSPEND, + operation: 'suspendApplication', + operatorNote, + subject: applicationId, + }); + +export default suspendApplication; diff --git a/packages/javascript/src/api/management/applications/unsuspendApplication.ts b/packages/javascript/src/api/management/applications/unsuspendApplication.ts new file mode 100644 index 00000000..625125b1 --- /dev/null +++ b/packages/javascript/src/api/management/applications/unsuspendApplication.ts @@ -0,0 +1,47 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {AdministrationFlowConfigKey} from '../../../constants/AdministrationConstants'; +import {AccountLifecycleOptions, AccountLifecycleResult} from '../../../models/administration'; +import {ManagementRequestConfig} from '../../../models/api'; +import runAccountLifecycleFlow from '../administration/runAccountLifecycleFlow'; + +/** + * Configuration for the unsuspendApplication request. + */ +export interface UnsuspendApplicationConfig extends ManagementRequestConfig, AccountLifecycleOptions { + /** + * Identifier of the application. + */ + applicationId: string; +} + +/** + * Lifts the suspension of an application through the application unsuspension flow the server configures. + * There is no management API endpoint for this, so when no flow is configured the call fails with + * `unsuspendApplication-NotConfiguredError-001`. + * + * @param config - Request configuration object. Running a flow needs `baseUrl`, and a token that may read + * the server configuration and run administration flows. + * @returns A promise that resolves with whether the flow's email step sent its notification, absent when + * the flow has no email step. + * + * @example + * ```typescript + * const {emailSent} = await unsuspendApplication({baseUrl: 'https://localhost:8090', applicationId, operatorNote: 'Ticket 4521'}); + * ``` + */ +const unsuspendApplication = async ({ + applicationId, + operatorNote, + ...config +}: UnsuspendApplicationConfig): Promise => + runAccountLifecycleFlow(config, { + action: 'application unsuspension', + configKey: AdministrationFlowConfigKey.APPLICATION_UNSUSPEND, + operation: 'unsuspendApplication', + operatorNote, + subject: applicationId, + }); + +export default unsuspendApplication; diff --git a/packages/javascript/src/api/management/applications/updateApplicationSharingPolicy.ts b/packages/javascript/src/api/management/applications/updateApplicationSharingPolicy.ts new file mode 100644 index 00000000..28adc2ac --- /dev/null +++ b/packages/javascript/src/api/management/applications/updateApplicationSharingPolicy.ts @@ -0,0 +1,60 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ManagementRequestConfig} from '../../../models/api'; +import {SharingPolicy, SharingPolicyUpdateRequest} from '../../../models/sharing-policy'; +import encodePathSegments from '../../../utils/encodePathSegments'; +import requestManagementResource from '../../../utils/requestManagementResource'; + +/** + * Configuration for the updateApplicationSharingPolicy request. + */ +export interface UpdateApplicationSharingPolicyConfig extends ManagementRequestConfig { + /** + * Identifier of the application. + */ + applicationId: string; + /** + * The policy's new sections and the version being edited. + */ + payload: SharingPolicyUpdateRequest; + /** + * Identifier of the sharing policy. + */ + policyId: string; +} + +/** + * Replaces the sections of a sharing policy of an application. The payload names the version it edits, and + * an edit against any other version is refused. + * + * @param config - Request configuration object. + * @returns A promise that resolves with the policy as the server stored it. + * + * @example + * ```typescript + * const policy = await updateApplicationSharingPolicy({ + * baseUrl: 'https://localhost:8090', + * applicationId, + * policyId, + * payload: {childUnits: {mode: 'all', excludedOuIds: [ouId]}, version: 1}, + * }); + * ``` + */ +const updateApplicationSharingPolicy = async ({ + applicationId, + payload, + policyId, + ...config +}: UpdateApplicationSharingPolicyConfig): Promise => + requestManagementResource(config, { + body: payload, + collection: 'applications', + failureMessage: 'Failed to update sharing policy', + id: applicationId, + method: 'PUT', + operation: 'updateApplicationSharingPolicy', + subPath: `sharing-policies/${encodePathSegments('updateApplicationSharingPolicy', [policyId])}`, + }); + +export default updateApplicationSharingPolicy; diff --git a/packages/javascript/src/api/management/configuration/createConfigurationVersion.ts b/packages/javascript/src/api/management/configuration/createConfigurationVersion.ts new file mode 100644 index 00000000..899f680f --- /dev/null +++ b/packages/javascript/src/api/management/configuration/createConfigurationVersion.ts @@ -0,0 +1,41 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ManagementRequestConfig} from '../../../models/api'; +import {CaptureConfigurationVersionRequest, ConfigurationVersion} from '../../../models/configuration'; +import requestManagementResource from '../../../utils/requestManagementResource'; + +/** + * Configuration for the createConfigurationVersion request. + */ +export interface CreateConfigurationVersionConfig extends ManagementRequestConfig { + /** + * A name and note for the version. + */ + payload?: CaptureConfigurationVersionRequest; +} + +/** + * Captures the current configuration as a version. + * + * @param config - Request configuration object. + * @returns A promise that resolves with the version. + * + * @example + * ```typescript + * const version = await createConfigurationVersion({baseUrl: 'https://localhost:8090', payload: {name: 'Before launch'}}); + * ``` + */ +const createConfigurationVersion = async ({ + payload, + ...config +}: CreateConfigurationVersionConfig): Promise => + requestManagementResource(config, { + body: payload, + collection: 'configuration-versions', + failureMessage: 'Failed to capture configuration version', + method: 'POST', + operation: 'createConfigurationVersion', + }); + +export default createConfigurationVersion; diff --git a/packages/javascript/src/api/management/configuration/deleteImportedConfiguration.ts b/packages/javascript/src/api/management/configuration/deleteImportedConfiguration.ts new file mode 100644 index 00000000..a5281869 --- /dev/null +++ b/packages/javascript/src/api/management/configuration/deleteImportedConfiguration.ts @@ -0,0 +1,45 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ManagementRequestConfig} from '../../../models/api'; +import {DeleteImportedConfigurationRequest, DeleteImportedConfigurationResponse} from '../../../models/configuration'; +import requestManagementResource from '../../../utils/requestManagementResource'; + +/** + * Configuration for the deleteImportedConfiguration request. + */ +export interface DeleteImportedConfigurationConfig extends ManagementRequestConfig { + /** + * The resource whose file to delete. + */ + payload: DeleteImportedConfigurationRequest; +} + +/** + * Deletes an imported declarative resource file. + * + * @param config - Request configuration object. + * @returns A promise that resolves with the deleted file. + * + * @example + * ```typescript + * const {deletedFile} = await deleteImportedConfiguration({ + * baseUrl: 'https://localhost:8090', + * payload: {resourceKey: 'my-app', resourceType: 'application'}, + * }); + * ``` + */ +const deleteImportedConfiguration = async ({ + payload, + ...config +}: DeleteImportedConfigurationConfig): Promise => + requestManagementResource(config, { + body: payload, + collection: 'import', + failureMessage: 'Failed to delete imported configuration', + id: 'delete', + method: 'POST', + operation: 'deleteImportedConfiguration', + }); + +export default deleteImportedConfiguration; diff --git a/packages/javascript/src/api/management/configuration/exportConfiguration.ts b/packages/javascript/src/api/management/configuration/exportConfiguration.ts new file mode 100644 index 00000000..c2dd7381 --- /dev/null +++ b/packages/javascript/src/api/management/configuration/exportConfiguration.ts @@ -0,0 +1,41 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ManagementRequestConfig} from '../../../models/api'; +import {ConfigurationExportRequest, ConfigurationExportResponse} from '../../../models/configuration'; +import requestManagementResource from '../../../utils/requestManagementResource'; + +/** + * Configuration for the exportConfiguration request. + */ +export interface ExportConfigurationConfig extends ManagementRequestConfig { + /** + * The resources to export, by type. + */ + payload: ConfigurationExportRequest; +} + +/** + * Exports resources as a declarative document, with the environment variables it refers to. + * + * @param config - Request configuration object. + * @returns A promise that resolves with the exported document. + * + * @example + * ```typescript + * const {resources} = await exportConfiguration({baseUrl: 'https://localhost:8090', payload: {applications: ['*']}}); + * ``` + */ +const exportConfiguration = async ({ + payload, + ...config +}: ExportConfigurationConfig): Promise => + requestManagementResource(config, { + body: payload, + collection: 'export', + failureMessage: 'Failed to export configuration', + method: 'POST', + operation: 'exportConfiguration', + }); + +export default exportConfiguration; diff --git a/packages/javascript/src/api/management/configuration/getConfigurationVersion.ts b/packages/javascript/src/api/management/configuration/getConfigurationVersion.ts new file mode 100644 index 00000000..56fac330 --- /dev/null +++ b/packages/javascript/src/api/management/configuration/getConfigurationVersion.ts @@ -0,0 +1,41 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ManagementRequestConfig} from '../../../models/api'; +import {ConfigurationVersion} from '../../../models/configuration'; +import requestManagementResource from '../../../utils/requestManagementResource'; + +/** + * Configuration for the getConfigurationVersion request. + */ +export interface GetConfigurationVersionConfig extends ManagementRequestConfig { + /** + * The version, its hash, or a prefix of at least seven of its characters. + */ + version: string; +} + +/** + * Retrieves a captured configuration version. + * + * @param config - Request configuration object. + * @returns A promise that resolves with the version. + * + * @example + * ```typescript + * const {resources} = await getConfigurationVersion({baseUrl: 'https://localhost:8090', version: 'a1b2c3d'}); + * ``` + */ +const getConfigurationVersion = async ({ + version, + ...config +}: GetConfigurationVersionConfig): Promise => + requestManagementResource(config, { + collection: 'configuration-versions', + failureMessage: 'Failed to fetch configuration version', + id: version, + method: 'GET', + operation: 'getConfigurationVersion', + }); + +export default getConfigurationVersion; diff --git a/packages/javascript/src/api/management/configuration/getConfigurationVersions.ts b/packages/javascript/src/api/management/configuration/getConfigurationVersions.ts new file mode 100644 index 00000000..e2af17fa --- /dev/null +++ b/packages/javascript/src/api/management/configuration/getConfigurationVersions.ts @@ -0,0 +1,32 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ManagementRequestConfig} from '../../../models/api'; +import {ConfigurationVersion} from '../../../models/configuration'; +import requestManagementResource from '../../../utils/requestManagementResource'; + +/** + * Configuration for the getConfigurationVersions request. + */ +export type GetConfigurationVersionsConfig = ManagementRequestConfig; + +/** + * Retrieves the captured configuration versions. + * + * @param config - Request configuration object. + * @returns A promise that resolves with the versions. + * + * @example + * ```typescript + * const versions = await getConfigurationVersions({baseUrl: 'https://localhost:8090'}); + * ``` + */ +const getConfigurationVersions = async (config: GetConfigurationVersionsConfig): Promise => + requestManagementResource(config, { + collection: 'configuration-versions', + failureMessage: 'Failed to fetch configuration versions', + method: 'GET', + operation: 'getConfigurationVersions', + }); + +export default getConfigurationVersions; diff --git a/packages/javascript/src/api/management/configuration/importConfiguration.ts b/packages/javascript/src/api/management/configuration/importConfiguration.ts new file mode 100644 index 00000000..a1c1bb06 --- /dev/null +++ b/packages/javascript/src/api/management/configuration/importConfiguration.ts @@ -0,0 +1,42 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ManagementRequestConfig} from '../../../models/api'; +import {ConfigurationImportRequest, ConfigurationImportResponse} from '../../../models/configuration'; +import requestManagementResource from '../../../utils/requestManagementResource'; + +/** + * Configuration for the importConfiguration request. + */ +export interface ImportConfigurationConfig extends ManagementRequestConfig { + /** + * The documents to import and the resources to remove. + */ + payload: ConfigurationImportRequest; +} + +/** + * Imports declarative resources: upserts the documents in `content` and removes the resources in + * `deletions`. + * + * @param config - Request configuration object. + * @returns A promise that resolves with the outcome of each resource. + * + * @example + * ```typescript + * const {summary} = await importConfiguration({baseUrl: 'https://localhost:8090', payload: {content: yaml, dryRun: true}}); + * ``` + */ +const importConfiguration = async ({ + payload, + ...config +}: ImportConfigurationConfig): Promise => + requestManagementResource(config, { + body: payload, + collection: 'import', + failureMessage: 'Failed to import configuration', + method: 'POST', + operation: 'importConfiguration', + }); + +export default importConfiguration; diff --git a/packages/javascript/src/api/management/connections/createConnection.ts b/packages/javascript/src/api/management/connections/createConnection.ts new file mode 100644 index 00000000..27a69ba3 --- /dev/null +++ b/packages/javascript/src/api/management/connections/createConnection.ts @@ -0,0 +1,47 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ManagementRequestConfig} from '../../../models/api'; +import {Connection, ConnectionRequest, ConnectionType} from '../../../models/connection'; +import requestManagementResource from '../../../utils/requestManagementResource'; + +/** + * Configuration for the createConnection request. + */ +export interface CreateConnectionConfig extends ManagementRequestConfig { + /** + * The connection to create, in the type's shape. + */ + payload: ConnectionRequest; + /** + * The connection type, such as `google` or `email-smtp`. + */ + type: ConnectionType; +} + +/** + * Creates a connection of a type. + * + * @param config - Request configuration object. + * @returns A promise that resolves with the connection as the server stored it. + * + * @example + * ```typescript + * const connection = await createConnection({ + * baseUrl: 'https://localhost:8090', + * type: 'google', + * payload: {clientId, clientSecret, name: 'Google', redirectUri}, + * }); + * ``` + */ +const createConnection = async ({payload, type, ...config}: CreateConnectionConfig): Promise => + requestManagementResource(config, { + body: payload, + collection: 'connections', + failureMessage: 'Failed to create connection', + id: type, + method: 'POST', + operation: 'createConnection', + }); + +export default createConnection; diff --git a/packages/javascript/src/api/management/connections/deleteConnection.ts b/packages/javascript/src/api/management/connections/deleteConnection.ts new file mode 100644 index 00000000..bb709ae4 --- /dev/null +++ b/packages/javascript/src/api/management/connections/deleteConnection.ts @@ -0,0 +1,45 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ManagementRequestConfig} from '../../../models/api'; +import {ConnectionType} from '../../../models/connection'; +import encodePathSegments from '../../../utils/encodePathSegments'; +import requestManagementResource from '../../../utils/requestManagementResource'; + +/** + * Configuration for the deleteConnection request. + */ +export interface DeleteConnectionConfig extends ManagementRequestConfig { + /** + * Identifier of the connection. + */ + connectionId: string; + /** + * The connection type, such as `google` or `email-smtp`. + */ + type: ConnectionType; +} + +/** + * Deletes a connection. + * + * @param config - Request configuration object. + * @returns A promise that resolves once the server has made the change. + * + * @example + * ```typescript + * await deleteConnection({baseUrl: 'https://localhost:8090', connectionId, type: 'google'}); + * ``` + */ +const deleteConnection = async ({connectionId, type, ...config}: DeleteConnectionConfig): Promise => { + await requestManagementResource(config, { + collection: 'connections', + failureMessage: 'Failed to delete connection', + id: type, + method: 'DELETE', + operation: 'deleteConnection', + subPath: encodePathSegments('deleteConnection', [connectionId]), + }); +}; + +export default deleteConnection; diff --git a/packages/javascript/src/api/management/connections/getConnection.ts b/packages/javascript/src/api/management/connections/getConnection.ts new file mode 100644 index 00000000..49a6cdf1 --- /dev/null +++ b/packages/javascript/src/api/management/connections/getConnection.ts @@ -0,0 +1,44 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ManagementRequestConfig} from '../../../models/api'; +import {Connection, ConnectionType} from '../../../models/connection'; +import encodePathSegments from '../../../utils/encodePathSegments'; +import requestManagementResource from '../../../utils/requestManagementResource'; + +/** + * Configuration for the getConnection request. + */ +export interface GetConnectionConfig extends ManagementRequestConfig { + /** + * Identifier of the connection. + */ + connectionId: string; + /** + * The connection type, such as `google` or `email-smtp`. + */ + type: ConnectionType; +} + +/** + * Retrieves a connection with its configuration. + * + * @param config - Request configuration object. + * @returns A promise that resolves with the connection. + * + * @example + * ```typescript + * const connection = await getConnection({baseUrl: 'https://localhost:8090', connectionId, type: 'google'}); + * ``` + */ +const getConnection = async ({connectionId, type, ...config}: GetConnectionConfig): Promise => + requestManagementResource(config, { + collection: 'connections', + failureMessage: 'Failed to fetch connection', + id: type, + method: 'GET', + operation: 'getConnection', + subPath: encodePathSegments('getConnection', [connectionId]), + }); + +export default getConnection; diff --git a/packages/javascript/src/api/management/connections/getConnectionUsages.ts b/packages/javascript/src/api/management/connections/getConnectionUsages.ts new file mode 100644 index 00000000..99f1ef97 --- /dev/null +++ b/packages/javascript/src/api/management/connections/getConnectionUsages.ts @@ -0,0 +1,48 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ManagementRequestConfig} from '../../../models/api'; +import {ConnectionTypeWithUsages, ConnectionUsagesResponse} from '../../../models/connection'; +import encodePathSegments from '../../../utils/encodePathSegments'; +import requestManagementResource from '../../../utils/requestManagementResource'; + +/** + * Configuration for the getConnectionUsages request. + */ +export interface GetConnectionUsagesConfig extends ManagementRequestConfig { + /** + * Identifier of the connection. + */ + connectionId: string; + /** + * The connection type, such as `google` or `email-smtp`. + */ + type: ConnectionTypeWithUsages; +} + +/** + * Retrieves the flows that use a connection. Every connection type but `http` has usages. + * + * @param config - Request configuration object. + * @returns A promise that resolves with the usages. + * + * @example + * ```typescript + * const {usages} = await getConnectionUsages({baseUrl: 'https://localhost:8090', connectionId, type: 'google'}); + * ``` + */ +const getConnectionUsages = async ({ + connectionId, + type, + ...config +}: GetConnectionUsagesConfig): Promise => + requestManagementResource(config, { + collection: 'connections', + failureMessage: 'Failed to fetch connection usages', + id: type, + method: 'GET', + operation: 'getConnectionUsages', + subPath: `${encodePathSegments('getConnectionUsages', [connectionId])}/usages`, + }); + +export default getConnectionUsages; diff --git a/packages/javascript/src/api/management/connections/getConnections.ts b/packages/javascript/src/api/management/connections/getConnections.ts new file mode 100644 index 00000000..e68dc501 --- /dev/null +++ b/packages/javascript/src/api/management/connections/getConnections.ts @@ -0,0 +1,75 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ApiFilteringParams, ManagementRequestConfig} from '../../../models/api'; +import { + ConnectionCategory, + ConnectionListResponse, + ConnectionSummary, + ConnectionType, +} from '../../../models/connection'; +import requestManagementResource from '../../../utils/requestManagementResource'; + +/** + * Configuration for the getConnections request. + */ +export type GetConnectionsConfig = ManagementRequestConfig & + Pick & { + /** + * Lists only the connections in this category. Applies to the listing across every type. + */ + category?: ConnectionCategory; + /** + * Lists the connections of this type only. The server returns them as an array, without paging, so + * `category`, `limit` and `offset` do not apply. + */ + type?: T; + }; + +/** + * What {@link getConnections} resolves with: the connections of one type, or a page across every type. + */ +export type GetConnectionsResult = T extends ConnectionType + ? ConnectionSummary[] + : ConnectionListResponse; + +/** + * Retrieves the configured connections: a page across every type, or every connection of one type when + * `type` is given. + * + * @param config - Request configuration object. + * @returns A promise that resolves with a page of connections, or the connections of the given type. + * + * @example + * ```typescript + * const {connections} = await getConnections({baseUrl: 'https://localhost:8090', category: 'identity-provider'}); + * const googleConnections = await getConnections({baseUrl: 'https://localhost:8090', type: 'google'}); + * ``` + */ +const getConnections = async ({ + category, + limit, + offset, + type, + ...config +}: GetConnectionsConfig): Promise> => + requestManagementResource>( + config, + type === undefined + ? { + collection: 'connections', + failureMessage: 'Failed to fetch connections', + method: 'GET', + operation: 'getConnections', + query: {category, limit, offset}, + } + : { + collection: 'connections', + failureMessage: 'Failed to fetch connections', + id: type, + method: 'GET', + operation: 'getConnections', + }, + ); + +export default getConnections; diff --git a/packages/javascript/src/api/management/connections/getConnectionsMeta.ts b/packages/javascript/src/api/management/connections/getConnectionsMeta.ts new file mode 100644 index 00000000..895775d4 --- /dev/null +++ b/packages/javascript/src/api/management/connections/getConnectionsMeta.ts @@ -0,0 +1,40 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ManagementRequestConfig} from '../../../models/api'; +import {ConnectionMetaResponse, ConnectionType} from '../../../models/connection'; +import requestManagementResource from '../../../utils/requestManagementResource'; + +/** + * Configuration for the getConnectionsMeta request. + */ +export interface GetConnectionsMetaConfig extends ManagementRequestConfig { + /** + * Describes only this vendor. + */ + vendor?: ConnectionType; +} + +/** + * Describes the configurable options of the connection vendors, such as the outbound authentication methods + * each supports. + * + * @param config - Request configuration object. + * @returns A promise that resolves with the vendor descriptions. + * + * @example + * ```typescript + * const {vendors} = await getConnectionsMeta({baseUrl: 'https://localhost:8090'}); + * ``` + */ +const getConnectionsMeta = async ({vendor, ...config}: GetConnectionsMetaConfig): Promise => + requestManagementResource(config, { + collection: 'connections', + failureMessage: 'Failed to fetch connection metadata', + id: 'meta', + method: 'GET', + operation: 'getConnectionsMeta', + query: {vendor}, + }); + +export default getConnectionsMeta; diff --git a/packages/javascript/src/api/management/connections/updateConnection.ts b/packages/javascript/src/api/management/connections/updateConnection.ts new file mode 100644 index 00000000..afa5ca23 --- /dev/null +++ b/packages/javascript/src/api/management/connections/updateConnection.ts @@ -0,0 +1,59 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ManagementRequestConfig} from '../../../models/api'; +import {Connection, ConnectionRequest, ConnectionType} from '../../../models/connection'; +import encodePathSegments from '../../../utils/encodePathSegments'; +import requestManagementResource from '../../../utils/requestManagementResource'; + +/** + * Configuration for the updateConnection request. + */ +export interface UpdateConnectionConfig extends ManagementRequestConfig { + /** + * Identifier of the connection. + */ + connectionId: string; + /** + * The connection's new state, in the type's shape. + */ + payload: ConnectionRequest; + /** + * The connection type, such as `google` or `email-smtp`. + */ + type: ConnectionType; +} + +/** + * Updates a connection. + * + * @param config - Request configuration object. + * @returns A promise that resolves with the connection as the server stored it. + * + * @example + * ```typescript + * const connection = await updateConnection({ + * baseUrl: 'https://localhost:8090', + * connectionId, + * type: 'google', + * payload: {clientId, name: 'Google', redirectUri}, + * }); + * ``` + */ +const updateConnection = async ({ + connectionId, + payload, + type, + ...config +}: UpdateConnectionConfig): Promise => + requestManagementResource(config, { + body: payload, + collection: 'connections', + failureMessage: 'Failed to update connection', + id: type, + method: 'PUT', + operation: 'updateConnection', + subPath: encodePathSegments('updateConnection', [connectionId]), + }); + +export default updateConnection; diff --git a/packages/javascript/src/api/management/design/createDesignTheme.ts b/packages/javascript/src/api/management/design/createDesignTheme.ts new file mode 100644 index 00000000..be6b4269 --- /dev/null +++ b/packages/javascript/src/api/management/design/createDesignTheme.ts @@ -0,0 +1,42 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ManagementRequestConfig} from '../../../models/api'; +import {DesignTheme, DesignThemeRequest} from '../../../models/design'; +import requestManagementResource from '../../../utils/requestManagementResource'; + +/** + * Configuration for the createDesignTheme request. + */ +export interface CreateDesignThemeConfig extends ManagementRequestConfig { + /** + * The theme to create. + */ + payload: DesignThemeRequest; +} + +/** + * Creates a theme. + * + * @param config - Request configuration object. + * @returns A promise that resolves with the theme as the server stored it. + * + * @example + * ```typescript + * const theme = await createDesignTheme({ + * baseUrl: 'https://localhost:8090', + * payload: {handle: 'brand', displayName: 'Brand', theme: {}}, + * }); + * ``` + */ +const createDesignTheme = async ({payload, ...config}: CreateDesignThemeConfig): Promise => + requestManagementResource(config, { + body: payload, + collection: 'design', + failureMessage: 'Failed to create theme', + id: 'themes', + method: 'POST', + operation: 'createDesignTheme', + }); + +export default createDesignTheme; diff --git a/packages/javascript/src/api/management/design/createLayout.ts b/packages/javascript/src/api/management/design/createLayout.ts new file mode 100644 index 00000000..87943cb3 --- /dev/null +++ b/packages/javascript/src/api/management/design/createLayout.ts @@ -0,0 +1,42 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ManagementRequestConfig} from '../../../models/api'; +import {DesignLayout, DesignLayoutRequest} from '../../../models/design'; +import requestManagementResource from '../../../utils/requestManagementResource'; + +/** + * Configuration for the createLayout request. + */ +export interface CreateLayoutConfig extends ManagementRequestConfig { + /** + * The layout to create. + */ + payload: DesignLayoutRequest; +} + +/** + * Creates a layout. + * + * @param config - Request configuration object. + * @returns A promise that resolves with the layout as the server stored it. + * + * @example + * ```typescript + * const layout = await createLayout({ + * baseUrl: 'https://localhost:8090', + * payload: {handle: 'centered', displayName: 'Centered', layout: {}}, + * }); + * ``` + */ +const createLayout = async ({payload, ...config}: CreateLayoutConfig): Promise => + requestManagementResource(config, { + body: payload, + collection: 'design', + failureMessage: 'Failed to create layout', + id: 'layouts', + method: 'POST', + operation: 'createLayout', + }); + +export default createLayout; diff --git a/packages/javascript/src/api/management/design/deleteDesignTheme.ts b/packages/javascript/src/api/management/design/deleteDesignTheme.ts new file mode 100644 index 00000000..572f9df4 --- /dev/null +++ b/packages/javascript/src/api/management/design/deleteDesignTheme.ts @@ -0,0 +1,40 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ManagementRequestConfig} from '../../../models/api'; +import encodePathSegments from '../../../utils/encodePathSegments'; +import requestManagementResource from '../../../utils/requestManagementResource'; + +/** + * Configuration for the deleteDesignTheme request. + */ +export interface DeleteDesignThemeConfig extends ManagementRequestConfig { + /** + * Identifier of the theme. + */ + themeId: string; +} + +/** + * Deletes a theme. + * + * @param config - Request configuration object. + * @returns A promise that resolves once the server has made the change. + * + * @example + * ```typescript + * await deleteDesignTheme({baseUrl: 'https://localhost:8090', themeId}); + * ``` + */ +const deleteDesignTheme = async ({themeId, ...config}: DeleteDesignThemeConfig): Promise => { + await requestManagementResource(config, { + collection: 'design', + failureMessage: 'Failed to delete theme', + id: 'themes', + method: 'DELETE', + operation: 'deleteDesignTheme', + subPath: encodePathSegments('deleteDesignTheme', [themeId]), + }); +}; + +export default deleteDesignTheme; diff --git a/packages/javascript/src/api/management/design/deleteLayout.ts b/packages/javascript/src/api/management/design/deleteLayout.ts new file mode 100644 index 00000000..361ac243 --- /dev/null +++ b/packages/javascript/src/api/management/design/deleteLayout.ts @@ -0,0 +1,40 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ManagementRequestConfig} from '../../../models/api'; +import encodePathSegments from '../../../utils/encodePathSegments'; +import requestManagementResource from '../../../utils/requestManagementResource'; + +/** + * Configuration for the deleteLayout request. + */ +export interface DeleteLayoutConfig extends ManagementRequestConfig { + /** + * Identifier of the layout. + */ + layoutId: string; +} + +/** + * Deletes a layout. + * + * @param config - Request configuration object. + * @returns A promise that resolves once the server has made the change. + * + * @example + * ```typescript + * await deleteLayout({baseUrl: 'https://localhost:8090', layoutId}); + * ``` + */ +const deleteLayout = async ({layoutId, ...config}: DeleteLayoutConfig): Promise => { + await requestManagementResource(config, { + collection: 'design', + failureMessage: 'Failed to delete layout', + id: 'layouts', + method: 'DELETE', + operation: 'deleteLayout', + subPath: encodePathSegments('deleteLayout', [layoutId]), + }); +}; + +export default deleteLayout; diff --git a/packages/javascript/src/api/management/design/getDesignTheme.ts b/packages/javascript/src/api/management/design/getDesignTheme.ts new file mode 100644 index 00000000..49787366 --- /dev/null +++ b/packages/javascript/src/api/management/design/getDesignTheme.ts @@ -0,0 +1,40 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ManagementRequestConfig} from '../../../models/api'; +import {DesignTheme} from '../../../models/design'; +import encodePathSegments from '../../../utils/encodePathSegments'; +import requestManagementResource from '../../../utils/requestManagementResource'; + +/** + * Configuration for the getDesignTheme request. + */ +export interface GetDesignThemeConfig extends ManagementRequestConfig { + /** + * Identifier of the theme. + */ + themeId: string; +} + +/** + * Retrieves a theme with its configuration. + * + * @param config - Request configuration object. + * @returns A promise that resolves with the theme. + * + * @example + * ```typescript + * const theme = await getDesignTheme({baseUrl: 'https://localhost:8090', themeId}); + * ``` + */ +const getDesignTheme = async ({themeId, ...config}: GetDesignThemeConfig): Promise => + requestManagementResource(config, { + collection: 'design', + failureMessage: 'Failed to fetch theme', + id: 'themes', + method: 'GET', + operation: 'getDesignTheme', + subPath: encodePathSegments('getDesignTheme', [themeId]), + }); + +export default getDesignTheme; diff --git a/packages/javascript/src/api/management/design/getDesignThemeUsages.ts b/packages/javascript/src/api/management/design/getDesignThemeUsages.ts new file mode 100644 index 00000000..6df603ec --- /dev/null +++ b/packages/javascript/src/api/management/design/getDesignThemeUsages.ts @@ -0,0 +1,48 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ApiFilteringParams, ManagementRequestConfig} from '../../../models/api'; +import {ResourceUsagesResponse} from '../../../models/resource-usage'; +import encodePathSegments from '../../../utils/encodePathSegments'; +import requestManagementResource from '../../../utils/requestManagementResource'; + +/** + * Configuration for the getDesignThemeUsages request. + */ +export interface GetDesignThemeUsagesConfig + extends ManagementRequestConfig, + Pick { + /** + * Identifier of the theme. + */ + themeId: string; +} + +/** + * Retrieves a page of the applications and agents that use a theme. + * + * @param config - Request configuration object. + * @returns A promise that resolves with the usages. + * + * @example + * ```typescript + * const {usages} = await getDesignThemeUsages({baseUrl: 'https://localhost:8090', themeId}); + * ``` + */ +const getDesignThemeUsages = async ({ + limit, + offset, + themeId, + ...config +}: GetDesignThemeUsagesConfig): Promise => + requestManagementResource(config, { + collection: 'design', + failureMessage: 'Failed to fetch theme usages', + id: 'themes', + method: 'GET', + operation: 'getDesignThemeUsages', + query: {limit, offset}, + subPath: `${encodePathSegments('getDesignThemeUsages', [themeId])}/usages`, + }); + +export default getDesignThemeUsages; diff --git a/packages/javascript/src/api/management/design/getDesignThemes.ts b/packages/javascript/src/api/management/design/getDesignThemes.ts new file mode 100644 index 00000000..5beebb85 --- /dev/null +++ b/packages/javascript/src/api/management/design/getDesignThemes.ts @@ -0,0 +1,34 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ApiFilteringParams, ManagementRequestConfig} from '../../../models/api'; +import {DesignThemeListResponse} from '../../../models/design'; +import requestManagementResource from '../../../utils/requestManagementResource'; + +/** + * Configuration for the getDesignThemes request. + */ +export type GetDesignThemesConfig = ManagementRequestConfig & Pick; + +/** + * Retrieves a page of themes. + * + * @param config - Request configuration object. + * @returns A promise that resolves with the page the server returned. + * + * @example + * ```typescript + * const {themes} = await getDesignThemes({baseUrl: 'https://localhost:8090', limit: 20}); + * ``` + */ +const getDesignThemes = async ({limit, offset, ...config}: GetDesignThemesConfig): Promise => + requestManagementResource(config, { + collection: 'design', + failureMessage: 'Failed to fetch themes', + id: 'themes', + method: 'GET', + operation: 'getDesignThemes', + query: {limit, offset}, + }); + +export default getDesignThemes; diff --git a/packages/javascript/src/api/management/design/getLayout.ts b/packages/javascript/src/api/management/design/getLayout.ts new file mode 100644 index 00000000..6420eb3e --- /dev/null +++ b/packages/javascript/src/api/management/design/getLayout.ts @@ -0,0 +1,40 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ManagementRequestConfig} from '../../../models/api'; +import {DesignLayout} from '../../../models/design'; +import encodePathSegments from '../../../utils/encodePathSegments'; +import requestManagementResource from '../../../utils/requestManagementResource'; + +/** + * Configuration for the getLayout request. + */ +export interface GetLayoutConfig extends ManagementRequestConfig { + /** + * Identifier of the layout. + */ + layoutId: string; +} + +/** + * Retrieves a layout with its configuration. + * + * @param config - Request configuration object. + * @returns A promise that resolves with the layout. + * + * @example + * ```typescript + * const layout = await getLayout({baseUrl: 'https://localhost:8090', layoutId}); + * ``` + */ +const getLayout = async ({layoutId, ...config}: GetLayoutConfig): Promise => + requestManagementResource(config, { + collection: 'design', + failureMessage: 'Failed to fetch layout', + id: 'layouts', + method: 'GET', + operation: 'getLayout', + subPath: encodePathSegments('getLayout', [layoutId]), + }); + +export default getLayout; diff --git a/packages/javascript/src/api/management/design/getLayoutUsages.ts b/packages/javascript/src/api/management/design/getLayoutUsages.ts new file mode 100644 index 00000000..982ed486 --- /dev/null +++ b/packages/javascript/src/api/management/design/getLayoutUsages.ts @@ -0,0 +1,46 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ApiFilteringParams, ManagementRequestConfig} from '../../../models/api'; +import {ResourceUsagesResponse} from '../../../models/resource-usage'; +import encodePathSegments from '../../../utils/encodePathSegments'; +import requestManagementResource from '../../../utils/requestManagementResource'; + +/** + * Configuration for the getLayoutUsages request. + */ +export interface GetLayoutUsagesConfig extends ManagementRequestConfig, Pick { + /** + * Identifier of the layout. + */ + layoutId: string; +} + +/** + * Retrieves a page of the applications and agents that use a layout. + * + * @param config - Request configuration object. + * @returns A promise that resolves with the usages. + * + * @example + * ```typescript + * const {usages} = await getLayoutUsages({baseUrl: 'https://localhost:8090', layoutId}); + * ``` + */ +const getLayoutUsages = async ({ + layoutId, + limit, + offset, + ...config +}: GetLayoutUsagesConfig): Promise => + requestManagementResource(config, { + collection: 'design', + failureMessage: 'Failed to fetch layout usages', + id: 'layouts', + method: 'GET', + operation: 'getLayoutUsages', + query: {limit, offset}, + subPath: `${encodePathSegments('getLayoutUsages', [layoutId])}/usages`, + }); + +export default getLayoutUsages; diff --git a/packages/javascript/src/api/management/design/getLayouts.ts b/packages/javascript/src/api/management/design/getLayouts.ts new file mode 100644 index 00000000..54ae5533 --- /dev/null +++ b/packages/javascript/src/api/management/design/getLayouts.ts @@ -0,0 +1,34 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ApiFilteringParams, ManagementRequestConfig} from '../../../models/api'; +import {DesignLayoutListResponse} from '../../../models/design'; +import requestManagementResource from '../../../utils/requestManagementResource'; + +/** + * Configuration for the getLayouts request. + */ +export type GetLayoutsConfig = ManagementRequestConfig & Pick; + +/** + * Retrieves a page of layouts. + * + * @param config - Request configuration object. + * @returns A promise that resolves with the page the server returned. + * + * @example + * ```typescript + * const {layouts} = await getLayouts({baseUrl: 'https://localhost:8090', limit: 20}); + * ``` + */ +const getLayouts = async ({limit, offset, ...config}: GetLayoutsConfig): Promise => + requestManagementResource(config, { + collection: 'design', + failureMessage: 'Failed to fetch layouts', + id: 'layouts', + method: 'GET', + operation: 'getLayouts', + query: {limit, offset}, + }); + +export default getLayouts; diff --git a/packages/javascript/src/api/management/design/resolveDesign.ts b/packages/javascript/src/api/management/design/resolveDesign.ts new file mode 100644 index 00000000..cecf7518 --- /dev/null +++ b/packages/javascript/src/api/management/design/resolveDesign.ts @@ -0,0 +1,43 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ManagementRequestConfig} from '../../../models/api'; +import {DesignResolveResponse, DesignResolveType} from '../../../models/design'; +import requestManagementResource from '../../../utils/requestManagementResource'; + +/** + * Configuration for the resolveDesign request. + */ +export interface ResolveDesignConfig extends ManagementRequestConfig { + /** + * Identifier of the application or organization unit. + */ + id: string; + /** + * What to resolve the design for: `APP` or `OU`. + */ + type: DesignResolveType; +} + +/** + * Resolves the theme and layout that apply to an application or an organization unit. + * + * @param config - Request configuration object. + * @returns A promise that resolves with the resolved theme and layout. + * + * @example + * ```typescript + * const {theme, layout} = await resolveDesign({baseUrl: 'https://localhost:8090', id: applicationId, type: 'APP'}); + * ``` + */ +const resolveDesign = async ({id, type, ...config}: ResolveDesignConfig): Promise => + requestManagementResource(config, { + collection: 'design', + failureMessage: 'Failed to resolve design', + id: 'resolve', + method: 'GET', + operation: 'resolveDesign', + query: {id, type}, + }); + +export default resolveDesign; diff --git a/packages/javascript/src/api/management/design/updateDesignTheme.ts b/packages/javascript/src/api/management/design/updateDesignTheme.ts new file mode 100644 index 00000000..c74a088f --- /dev/null +++ b/packages/javascript/src/api/management/design/updateDesignTheme.ts @@ -0,0 +1,49 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ManagementRequestConfig} from '../../../models/api'; +import {DesignTheme, DesignThemeRequest} from '../../../models/design'; +import encodePathSegments from '../../../utils/encodePathSegments'; +import requestManagementResource from '../../../utils/requestManagementResource'; + +/** + * Configuration for the updateDesignTheme request. + */ +export interface UpdateDesignThemeConfig extends ManagementRequestConfig { + /** + * The theme's new state. + */ + payload: DesignThemeRequest; + /** + * Identifier of the theme. + */ + themeId: string; +} + +/** + * Updates a theme. + * + * @param config - Request configuration object. + * @returns A promise that resolves with the theme as the server stored it. + * + * @example + * ```typescript + * const theme = await updateDesignTheme({ + * baseUrl: 'https://localhost:8090', + * themeId, + * payload: {handle: 'brand', displayName: 'Brand', theme: {}}, + * }); + * ``` + */ +const updateDesignTheme = async ({payload, themeId, ...config}: UpdateDesignThemeConfig): Promise => + requestManagementResource(config, { + body: payload, + collection: 'design', + failureMessage: 'Failed to update theme', + id: 'themes', + method: 'PUT', + operation: 'updateDesignTheme', + subPath: encodePathSegments('updateDesignTheme', [themeId]), + }); + +export default updateDesignTheme; diff --git a/packages/javascript/src/api/management/design/updateLayout.ts b/packages/javascript/src/api/management/design/updateLayout.ts new file mode 100644 index 00000000..4620633b --- /dev/null +++ b/packages/javascript/src/api/management/design/updateLayout.ts @@ -0,0 +1,49 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ManagementRequestConfig} from '../../../models/api'; +import {DesignLayout, DesignLayoutRequest} from '../../../models/design'; +import encodePathSegments from '../../../utils/encodePathSegments'; +import requestManagementResource from '../../../utils/requestManagementResource'; + +/** + * Configuration for the updateLayout request. + */ +export interface UpdateLayoutConfig extends ManagementRequestConfig { + /** + * Identifier of the layout. + */ + layoutId: string; + /** + * The layout's new state. + */ + payload: DesignLayoutRequest; +} + +/** + * Updates a layout. + * + * @param config - Request configuration object. + * @returns A promise that resolves with the layout as the server stored it. + * + * @example + * ```typescript + * const layout = await updateLayout({ + * baseUrl: 'https://localhost:8090', + * layoutId, + * payload: {handle: 'centered', displayName: 'Centered', layout: {}}, + * }); + * ``` + */ +const updateLayout = async ({layoutId, payload, ...config}: UpdateLayoutConfig): Promise => + requestManagementResource(config, { + body: payload, + collection: 'design', + failureMessage: 'Failed to update layout', + id: 'layouts', + method: 'PUT', + operation: 'updateLayout', + subPath: encodePathSegments('updateLayout', [layoutId]), + }); + +export default updateLayout; diff --git a/packages/javascript/src/api/management/flows/createFlow.ts b/packages/javascript/src/api/management/flows/createFlow.ts new file mode 100644 index 00000000..5ea46167 --- /dev/null +++ b/packages/javascript/src/api/management/flows/createFlow.ts @@ -0,0 +1,41 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ManagementRequestConfig} from '../../../models/api'; +import {ManagedFlowDefinition, ManagedFlowRequest} from '../../../models/managed-flow'; +import requestManagementResource from '../../../utils/requestManagementResource'; + +/** + * Configuration for the createFlow request. + */ +export interface CreateFlowConfig extends ManagementRequestConfig { + /** + * The flow to create. + */ + payload: ManagedFlowRequest; +} + +/** + * Creates a flow. + * + * @param config - Request configuration object. + * @returns A promise that resolves with the flow as the server stored it. + * + * @example + * ```typescript + * const flow = await createFlow({ + * baseUrl: 'https://localhost:8090', + * payload: {flowType: 'AUTHENTICATION', handle: 'basic-login', name: 'Basic login', nodes}, + * }); + * ``` + */ +const createFlow = async ({payload, ...config}: CreateFlowConfig): Promise => + requestManagementResource(config, { + body: payload, + collection: 'flows', + failureMessage: 'Failed to create flow', + method: 'POST', + operation: 'createFlow', + }); + +export default createFlow; diff --git a/packages/javascript/src/api/management/flows/deleteFlow.ts b/packages/javascript/src/api/management/flows/deleteFlow.ts new file mode 100644 index 00000000..bf02d51a --- /dev/null +++ b/packages/javascript/src/api/management/flows/deleteFlow.ts @@ -0,0 +1,38 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ManagementRequestConfig} from '../../../models/api'; +import requestManagementResource from '../../../utils/requestManagementResource'; + +/** + * Configuration for the deleteFlow request. + */ +export interface DeleteFlowConfig extends ManagementRequestConfig { + /** + * Identifier of the flow. + */ + flowId: string; +} + +/** + * Deletes a flow. + * + * @param config - Request configuration object. + * @returns A promise that resolves once the server has made the change. + * + * @example + * ```typescript + * await deleteFlow({baseUrl: 'https://localhost:8090', flowId}); + * ``` + */ +const deleteFlow = async ({flowId, ...config}: DeleteFlowConfig): Promise => { + await requestManagementResource(config, { + collection: 'flows', + failureMessage: 'Failed to delete flow', + id: flowId, + method: 'DELETE', + operation: 'deleteFlow', + }); +}; + +export default deleteFlow; diff --git a/packages/javascript/src/api/management/flows/getFlow.ts b/packages/javascript/src/api/management/flows/getFlow.ts new file mode 100644 index 00000000..afb22dd9 --- /dev/null +++ b/packages/javascript/src/api/management/flows/getFlow.ts @@ -0,0 +1,38 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ManagementRequestConfig} from '../../../models/api'; +import {ManagedFlowDefinition} from '../../../models/managed-flow'; +import requestManagementResource from '../../../utils/requestManagementResource'; + +/** + * Configuration for the getFlow request. + */ +export interface GetFlowConfig extends ManagementRequestConfig { + /** + * Identifier of the flow. + */ + flowId: string; +} + +/** + * Retrieves a flow with its graph. + * + * @param config - Request configuration object. + * @returns A promise that resolves with the flow. + * + * @example + * ```typescript + * const flow = await getFlow({baseUrl: 'https://localhost:8090', flowId}); + * ``` + */ +const getFlow = async ({flowId, ...config}: GetFlowConfig): Promise => + requestManagementResource(config, { + collection: 'flows', + failureMessage: 'Failed to fetch flow', + id: flowId, + method: 'GET', + operation: 'getFlow', + }); + +export default getFlow; diff --git a/packages/javascript/src/api/management/flows/getFlowUsages.ts b/packages/javascript/src/api/management/flows/getFlowUsages.ts new file mode 100644 index 00000000..f43f908a --- /dev/null +++ b/packages/javascript/src/api/management/flows/getFlowUsages.ts @@ -0,0 +1,39 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ManagementRequestConfig} from '../../../models/api'; +import {ResourceUsagesResponse} from '../../../models/resource-usage'; +import requestManagementResource from '../../../utils/requestManagementResource'; + +/** + * Configuration for the getFlowUsages request. + */ +export interface GetFlowUsagesConfig extends ManagementRequestConfig { + /** + * Identifier of the flow. + */ + flowId: string; +} + +/** + * Retrieves the applications and agents that use a flow. + * + * @param config - Request configuration object. + * @returns A promise that resolves with the usages. + * + * @example + * ```typescript + * const {usages} = await getFlowUsages({baseUrl: 'https://localhost:8090', flowId}); + * ``` + */ +const getFlowUsages = async ({flowId, ...config}: GetFlowUsagesConfig): Promise => + requestManagementResource(config, { + collection: 'flows', + failureMessage: 'Failed to fetch flow usages', + id: flowId, + method: 'GET', + operation: 'getFlowUsages', + subPath: 'usages', + }); + +export default getFlowUsages; diff --git a/packages/javascript/src/api/management/flows/getFlowVersion.ts b/packages/javascript/src/api/management/flows/getFlowVersion.ts new file mode 100644 index 00000000..76b2fb51 --- /dev/null +++ b/packages/javascript/src/api/management/flows/getFlowVersion.ts @@ -0,0 +1,44 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ManagementRequestConfig} from '../../../models/api'; +import {ManagedFlowVersion} from '../../../models/managed-flow'; +import encodePathSegments from '../../../utils/encodePathSegments'; +import requestManagementResource from '../../../utils/requestManagementResource'; + +/** + * Configuration for the getFlowVersion request. + */ +export interface GetFlowVersionConfig extends ManagementRequestConfig { + /** + * Identifier of the flow. + */ + flowId: string; + /** + * The version number. + */ + version: number; +} + +/** + * Retrieves one version of a flow, with its graph. + * + * @param config - Request configuration object. + * @returns A promise that resolves with the version. + * + * @example + * ```typescript + * const flowVersion = await getFlowVersion({baseUrl: 'https://localhost:8090', flowId, version: 2}); + * ``` + */ +const getFlowVersion = async ({flowId, version, ...config}: GetFlowVersionConfig): Promise => + requestManagementResource(config, { + collection: 'flows', + failureMessage: 'Failed to fetch flow version', + id: flowId, + method: 'GET', + operation: 'getFlowVersion', + subPath: `versions/${encodePathSegments('getFlowVersion', [String(version)])}`, + }); + +export default getFlowVersion; diff --git a/packages/javascript/src/api/management/flows/getFlowVersions.ts b/packages/javascript/src/api/management/flows/getFlowVersions.ts new file mode 100644 index 00000000..9ad76f40 --- /dev/null +++ b/packages/javascript/src/api/management/flows/getFlowVersions.ts @@ -0,0 +1,39 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ManagementRequestConfig} from '../../../models/api'; +import {ManagedFlowVersionListResponse} from '../../../models/managed-flow'; +import requestManagementResource from '../../../utils/requestManagementResource'; + +/** + * Configuration for the getFlowVersions request. + */ +export interface GetFlowVersionsConfig extends ManagementRequestConfig { + /** + * Identifier of the flow. + */ + flowId: string; +} + +/** + * Retrieves a flow's version history. + * + * @param config - Request configuration object. + * @returns A promise that resolves with the versions. + * + * @example + * ```typescript + * const {versions} = await getFlowVersions({baseUrl: 'https://localhost:8090', flowId}); + * ``` + */ +const getFlowVersions = async ({flowId, ...config}: GetFlowVersionsConfig): Promise => + requestManagementResource(config, { + collection: 'flows', + failureMessage: 'Failed to fetch flow versions', + id: flowId, + method: 'GET', + operation: 'getFlowVersions', + subPath: 'versions', + }); + +export default getFlowVersions; diff --git a/packages/javascript/src/api/management/flows/restoreFlowVersion.ts b/packages/javascript/src/api/management/flows/restoreFlowVersion.ts new file mode 100644 index 00000000..a5e7921d --- /dev/null +++ b/packages/javascript/src/api/management/flows/restoreFlowVersion.ts @@ -0,0 +1,48 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ManagementRequestConfig} from '../../../models/api'; +import {ManagedFlowDefinition} from '../../../models/managed-flow'; +import requestManagementResource from '../../../utils/requestManagementResource'; + +/** + * Configuration for the restoreFlowVersion request. + */ +export interface RestoreFlowVersionConfig extends ManagementRequestConfig { + /** + * Identifier of the flow. + */ + flowId: string; + /** + * The version number to restore. + */ + version: number; +} + +/** + * Restores a flow to one of its previous versions. + * + * @param config - Request configuration object. + * @returns A promise that resolves with the flow as the server stored it. + * + * @example + * ```typescript + * const flow = await restoreFlowVersion({baseUrl: 'https://localhost:8090', flowId, version: 2}); + * ``` + */ +const restoreFlowVersion = async ({ + flowId, + version, + ...config +}: RestoreFlowVersionConfig): Promise => + requestManagementResource(config, { + body: {version}, + collection: 'flows', + failureMessage: 'Failed to restore flow version', + id: flowId, + method: 'POST', + operation: 'restoreFlowVersion', + subPath: 'restore', + }); + +export default restoreFlowVersion; diff --git a/packages/javascript/src/api/management/flows/updateFlow.ts b/packages/javascript/src/api/management/flows/updateFlow.ts new file mode 100644 index 00000000..e16752d1 --- /dev/null +++ b/packages/javascript/src/api/management/flows/updateFlow.ts @@ -0,0 +1,47 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ManagementRequestConfig} from '../../../models/api'; +import {ManagedFlowDefinition, ManagedFlowRequest} from '../../../models/managed-flow'; +import requestManagementResource from '../../../utils/requestManagementResource'; + +/** + * Configuration for the updateFlow request. + */ +export interface UpdateFlowConfig extends ManagementRequestConfig { + /** + * Identifier of the flow. + */ + flowId: string; + /** + * The flow's new state. + */ + payload: ManagedFlowRequest; +} + +/** + * Updates a flow. The server keeps the previous graph as a version. + * + * @param config - Request configuration object. + * @returns A promise that resolves with the flow as the server stored it. + * + * @example + * ```typescript + * const flow = await updateFlow({ + * baseUrl: 'https://localhost:8090', + * flowId, + * payload: {flowType: 'AUTHENTICATION', handle: 'basic-login', name: 'Basic login', nodes}, + * }); + * ``` + */ +const updateFlow = async ({flowId, payload, ...config}: UpdateFlowConfig): Promise => + requestManagementResource(config, { + body: payload, + collection: 'flows', + failureMessage: 'Failed to update flow', + id: flowId, + method: 'PUT', + operation: 'updateFlow', + }); + +export default updateFlow; diff --git a/packages/javascript/src/api/management/gateways/applyGatewayConfiguration.ts b/packages/javascript/src/api/management/gateways/applyGatewayConfiguration.ts new file mode 100644 index 00000000..501c9f4e --- /dev/null +++ b/packages/javascript/src/api/management/gateways/applyGatewayConfiguration.ts @@ -0,0 +1,49 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ManagementRequestConfig} from '../../../models/api'; +import {ApplyGatewayConfigurationRequest, GatewayApplyResult} from '../../../models/gateway'; +import requestManagementResource from '../../../utils/requestManagementResource'; + +/** + * Configuration for the applyGatewayConfiguration request. + */ +export interface ApplyGatewayConfigurationConfig extends ManagementRequestConfig { + /** + * Identifier of the gateway. + */ + gatewayId: string; + /** + * What to apply, and how. + */ + payload?: ApplyGatewayConfigurationRequest; +} + +/** + * Applies a configuration version to a gateway, the latest unless `payload.version` names one. A dry run + * reports what would change and records nothing. + * + * @param config - Request configuration object. + * @returns A promise that resolves with the outcome. + * + * @example + * ```typescript + * const {diff, missing} = await applyGatewayConfiguration({baseUrl: 'https://localhost:8090', gatewayId, payload: {dryRun: true}}); + * ``` + */ +const applyGatewayConfiguration = async ({ + gatewayId, + payload, + ...config +}: ApplyGatewayConfigurationConfig): Promise => + requestManagementResource(config, { + body: payload, + collection: 'gateways', + failureMessage: 'Failed to apply gateway configuration', + id: gatewayId, + method: 'POST', + operation: 'applyGatewayConfiguration', + subPath: 'apply', + }); + +export default applyGatewayConfiguration; diff --git a/packages/javascript/src/api/management/gateways/createGateway.ts b/packages/javascript/src/api/management/gateways/createGateway.ts new file mode 100644 index 00000000..c98e16b2 --- /dev/null +++ b/packages/javascript/src/api/management/gateways/createGateway.ts @@ -0,0 +1,41 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ManagementRequestConfig} from '../../../models/api'; +import {GatewayRegistration, RegisterGatewayRequest} from '../../../models/gateway'; +import requestManagementResource from '../../../utils/requestManagementResource'; + +/** + * Configuration for the createGateway request. + */ +export interface CreateGatewayConfig extends ManagementRequestConfig { + /** + * The gateway to register. + */ + payload: RegisterGatewayRequest; +} + +/** + * Registers a gateway. The result carries the gateway's key, which is returned only here. + * + * @param config - Request configuration object. + * @returns A promise that resolves with the registration. + * + * @example + * ```typescript + * const {id, key} = await createGateway({ + * baseUrl: 'https://localhost:8090', + * payload: {baseUrl: 'https://gw.example.com', name: 'Edge'}, + * }); + * ``` + */ +const createGateway = async ({payload, ...config}: CreateGatewayConfig): Promise => + requestManagementResource(config, { + body: payload, + collection: 'gateways', + failureMessage: 'Failed to register gateway', + method: 'POST', + operation: 'createGateway', + }); + +export default createGateway; diff --git a/packages/javascript/src/api/management/gateways/createGatewaySecret.ts b/packages/javascript/src/api/management/gateways/createGatewaySecret.ts new file mode 100644 index 00000000..a08abd84 --- /dev/null +++ b/packages/javascript/src/api/management/gateways/createGatewaySecret.ts @@ -0,0 +1,45 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ManagementRequestConfig} from '../../../models/api'; +import {Secret, SecretRequest} from '../../../models/variable-store'; +import requestManagementResource from '../../../utils/requestManagementResource'; + +/** + * Configuration for the createGatewaySecret request. + */ +export interface CreateGatewaySecretConfig extends ManagementRequestConfig { + /** + * Identifier of the gateway. + */ + gatewayId: string; + /** + * The secret to add. + */ + payload: SecretRequest; +} + +/** + * Adds a secret on a gateway. The value is write-only: the server only says whether one is held, and the + * SDK never logs it. + * + * @param config - Request configuration object. + * @returns A promise that resolves with the secret as the server stored it. + * + * @example + * ```typescript + * const secret = await createGatewaySecret({baseUrl: 'https://localhost:8090', gatewayId, payload: {name: 'SMTP_HOST', value}}); + * ``` + */ +const createGatewaySecret = async ({gatewayId, payload, ...config}: CreateGatewaySecretConfig): Promise => + requestManagementResource(config, { + body: payload, + collection: 'gateways', + failureMessage: 'Failed to add secret', + id: gatewayId, + method: 'POST', + operation: 'createGatewaySecret', + subPath: 'secrets', + }); + +export default createGatewaySecret; diff --git a/packages/javascript/src/api/management/gateways/createGatewayVariable.ts b/packages/javascript/src/api/management/gateways/createGatewayVariable.ts new file mode 100644 index 00000000..c6dd97fd --- /dev/null +++ b/packages/javascript/src/api/management/gateways/createGatewayVariable.ts @@ -0,0 +1,44 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ManagementRequestConfig} from '../../../models/api'; +import {Variable, VariableRequest} from '../../../models/variable-store'; +import requestManagementResource from '../../../utils/requestManagementResource'; + +/** + * Configuration for the createGatewayVariable request. + */ +export interface CreateGatewayVariableConfig extends ManagementRequestConfig { + /** + * Identifier of the gateway. + */ + gatewayId: string; + /** + * The variable to add. + */ + payload: VariableRequest; +} + +/** + * Adds a variable on a gateway. + * + * @param config - Request configuration object. + * @returns A promise that resolves with the variable as the server stored it. + * + * @example + * ```typescript + * const variable = await createGatewayVariable({baseUrl: 'https://localhost:8090', gatewayId, payload: {name: 'SMTP_HOST', value}}); + * ``` + */ +const createGatewayVariable = async ({gatewayId, payload, ...config}: CreateGatewayVariableConfig): Promise => + requestManagementResource(config, { + body: payload, + collection: 'gateways', + failureMessage: 'Failed to add variable', + id: gatewayId, + method: 'POST', + operation: 'createGatewayVariable', + subPath: 'variables', + }); + +export default createGatewayVariable; diff --git a/packages/javascript/src/api/management/gateways/deleteGateway.ts b/packages/javascript/src/api/management/gateways/deleteGateway.ts new file mode 100644 index 00000000..82176fd5 --- /dev/null +++ b/packages/javascript/src/api/management/gateways/deleteGateway.ts @@ -0,0 +1,38 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ManagementRequestConfig} from '../../../models/api'; +import requestManagementResource from '../../../utils/requestManagementResource'; + +/** + * Configuration for the deleteGateway request. + */ +export interface DeleteGatewayConfig extends ManagementRequestConfig { + /** + * Identifier of the gateway. + */ + gatewayId: string; +} + +/** + * Removes a gateway. + * + * @param config - Request configuration object. + * @returns A promise that resolves once the server has made the change. + * + * @example + * ```typescript + * await deleteGateway({baseUrl: 'https://localhost:8090', gatewayId}); + * ``` + */ +const deleteGateway = async ({gatewayId, ...config}: DeleteGatewayConfig): Promise => { + await requestManagementResource(config, { + collection: 'gateways', + failureMessage: 'Failed to remove gateway', + id: gatewayId, + method: 'DELETE', + operation: 'deleteGateway', + }); +}; + +export default deleteGateway; diff --git a/packages/javascript/src/api/management/gateways/deleteGatewaySecret.ts b/packages/javascript/src/api/management/gateways/deleteGatewaySecret.ts new file mode 100644 index 00000000..895fefab --- /dev/null +++ b/packages/javascript/src/api/management/gateways/deleteGatewaySecret.ts @@ -0,0 +1,44 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ManagementRequestConfig} from '../../../models/api'; +import encodePathSegments from '../../../utils/encodePathSegments'; +import requestManagementResource from '../../../utils/requestManagementResource'; + +/** + * Configuration for the deleteGatewaySecret request. + */ +export interface DeleteGatewaySecretConfig extends ManagementRequestConfig { + /** + * Identifier of the gateway. + */ + gatewayId: string; + /** + * Name of the secret. + */ + name: string; +} + +/** + * Deletes a secret on a gateway. + * + * @param config - Request configuration object. + * @returns A promise that resolves once the server has made the change. + * + * @example + * ```typescript + * await deleteGatewaySecret({baseUrl: 'https://localhost:8090', gatewayId, name: 'SMTP_HOST'}); + * ``` + */ +const deleteGatewaySecret = async ({gatewayId, name, ...config}: DeleteGatewaySecretConfig): Promise => { + await requestManagementResource(config, { + collection: 'gateways', + failureMessage: 'Failed to delete secret', + id: gatewayId, + method: 'DELETE', + operation: 'deleteGatewaySecret', + subPath: `secrets/${encodePathSegments('deleteGatewaySecret', [name])}`, + }); +}; + +export default deleteGatewaySecret; diff --git a/packages/javascript/src/api/management/gateways/deleteGatewayVariable.ts b/packages/javascript/src/api/management/gateways/deleteGatewayVariable.ts new file mode 100644 index 00000000..dd3fd63e --- /dev/null +++ b/packages/javascript/src/api/management/gateways/deleteGatewayVariable.ts @@ -0,0 +1,44 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ManagementRequestConfig} from '../../../models/api'; +import encodePathSegments from '../../../utils/encodePathSegments'; +import requestManagementResource from '../../../utils/requestManagementResource'; + +/** + * Configuration for the deleteGatewayVariable request. + */ +export interface DeleteGatewayVariableConfig extends ManagementRequestConfig { + /** + * Identifier of the gateway. + */ + gatewayId: string; + /** + * Name of the variable. + */ + name: string; +} + +/** + * Deletes a variable on a gateway. + * + * @param config - Request configuration object. + * @returns A promise that resolves once the server has made the change. + * + * @example + * ```typescript + * await deleteGatewayVariable({baseUrl: 'https://localhost:8090', gatewayId, name: 'SMTP_HOST'}); + * ``` + */ +const deleteGatewayVariable = async ({gatewayId, name, ...config}: DeleteGatewayVariableConfig): Promise => { + await requestManagementResource(config, { + collection: 'gateways', + failureMessage: 'Failed to delete variable', + id: gatewayId, + method: 'DELETE', + operation: 'deleteGatewayVariable', + subPath: `variables/${encodePathSegments('deleteGatewayVariable', [name])}`, + }); +}; + +export default deleteGatewayVariable; diff --git a/packages/javascript/src/api/management/gateways/getGateway.ts b/packages/javascript/src/api/management/gateways/getGateway.ts new file mode 100644 index 00000000..7a428d1b --- /dev/null +++ b/packages/javascript/src/api/management/gateways/getGateway.ts @@ -0,0 +1,38 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ManagementRequestConfig} from '../../../models/api'; +import {Gateway} from '../../../models/gateway'; +import requestManagementResource from '../../../utils/requestManagementResource'; + +/** + * Configuration for the getGateway request. + */ +export interface GetGatewayConfig extends ManagementRequestConfig { + /** + * Identifier of the gateway. + */ + gatewayId: string; +} + +/** + * Retrieves a gateway. + * + * @param config - Request configuration object. + * @returns A promise that resolves with the gateway. + * + * @example + * ```typescript + * const gateway = await getGateway({baseUrl: 'https://localhost:8090', gatewayId}); + * ``` + */ +const getGateway = async ({gatewayId, ...config}: GetGatewayConfig): Promise => + requestManagementResource(config, { + collection: 'gateways', + failureMessage: 'Failed to fetch gateway', + id: gatewayId, + method: 'GET', + operation: 'getGateway', + }); + +export default getGateway; diff --git a/packages/javascript/src/api/management/gateways/getGatewayAppliedConfiguration.ts b/packages/javascript/src/api/management/gateways/getGatewayAppliedConfiguration.ts new file mode 100644 index 00000000..9dae2c36 --- /dev/null +++ b/packages/javascript/src/api/management/gateways/getGatewayAppliedConfiguration.ts @@ -0,0 +1,42 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ManagementRequestConfig} from '../../../models/api'; +import {GatewayAppliedConfiguration} from '../../../models/gateway'; +import requestManagementResource from '../../../utils/requestManagementResource'; + +/** + * Configuration for the getGatewayAppliedConfiguration request. + */ +export interface GetGatewayAppliedConfigurationConfig extends ManagementRequestConfig { + /** + * Identifier of the gateway. + */ + gatewayId: string; +} + +/** + * Retrieves the configuration a gateway runs. + * + * @param config - Request configuration object. + * @returns A promise that resolves with the applied configuration. + * + * @example + * ```typescript + * const {resources} = await getGatewayAppliedConfiguration({baseUrl: 'https://localhost:8090', gatewayId}); + * ``` + */ +const getGatewayAppliedConfiguration = async ({ + gatewayId, + ...config +}: GetGatewayAppliedConfigurationConfig): Promise => + requestManagementResource(config, { + collection: 'gateways', + failureMessage: 'Failed to fetch gateway applied configuration', + id: gatewayId, + method: 'GET', + operation: 'getGatewayAppliedConfiguration', + subPath: 'applied-configuration', + }); + +export default getGatewayAppliedConfiguration; diff --git a/packages/javascript/src/api/management/gateways/getGatewayAppliedVersion.ts b/packages/javascript/src/api/management/gateways/getGatewayAppliedVersion.ts new file mode 100644 index 00000000..bdf70743 --- /dev/null +++ b/packages/javascript/src/api/management/gateways/getGatewayAppliedVersion.ts @@ -0,0 +1,42 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ManagementRequestConfig} from '../../../models/api'; +import {GatewayAppliedVersion} from '../../../models/gateway'; +import requestManagementResource from '../../../utils/requestManagementResource'; + +/** + * Configuration for the getGatewayAppliedVersion request. + */ +export interface GetGatewayAppliedVersionConfig extends ManagementRequestConfig { + /** + * Identifier of the gateway. + */ + gatewayId: string; +} + +/** + * Retrieves the configuration version a gateway holds. + * + * @param config - Request configuration object. + * @returns A promise that resolves with the applied version. + * + * @example + * ```typescript + * const {appliedVersion} = await getGatewayAppliedVersion({baseUrl: 'https://localhost:8090', gatewayId}); + * ``` + */ +const getGatewayAppliedVersion = async ({ + gatewayId, + ...config +}: GetGatewayAppliedVersionConfig): Promise => + requestManagementResource(config, { + collection: 'gateways', + failureMessage: 'Failed to fetch gateway applied version', + id: gatewayId, + method: 'GET', + operation: 'getGatewayAppliedVersion', + subPath: 'applied-version', + }); + +export default getGatewayAppliedVersion; diff --git a/packages/javascript/src/api/management/gateways/getGatewayConfigurationDiff.ts b/packages/javascript/src/api/management/gateways/getGatewayConfigurationDiff.ts new file mode 100644 index 00000000..59a6e182 --- /dev/null +++ b/packages/javascript/src/api/management/gateways/getGatewayConfigurationDiff.ts @@ -0,0 +1,49 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ManagementRequestConfig} from '../../../models/api'; +import {GatewayConfigurationDiff} from '../../../models/gateway'; +import requestManagementResource from '../../../utils/requestManagementResource'; + +/** + * Configuration for the getGatewayConfigurationDiff request. + */ +export interface GetGatewayConfigurationDiffConfig extends ManagementRequestConfig { + /** + * Identifier of the gateway. + */ + gatewayId: string; + /** + * The version to compare: a version, its hash, a prefix of at least seven characters, or `latest`. + * Defaults to `latest`. + */ + version?: string; +} + +/** + * Compares a configuration version with what a gateway holds. + * + * @param config - Request configuration object. + * @returns A promise that resolves with the difference. + * + * @example + * ```typescript + * const {changes, summary} = await getGatewayConfigurationDiff({baseUrl: 'https://localhost:8090', gatewayId}); + * ``` + */ +const getGatewayConfigurationDiff = async ({ + gatewayId, + version, + ...config +}: GetGatewayConfigurationDiffConfig): Promise => + requestManagementResource(config, { + collection: 'gateways', + failureMessage: 'Failed to fetch gateway configuration diff', + id: gatewayId, + method: 'GET', + operation: 'getGatewayConfigurationDiff', + query: {version}, + subPath: 'diff', + }); + +export default getGatewayConfigurationDiff; diff --git a/packages/javascript/src/api/management/gateways/getGatewaySecret.ts b/packages/javascript/src/api/management/gateways/getGatewaySecret.ts new file mode 100644 index 00000000..18437947 --- /dev/null +++ b/packages/javascript/src/api/management/gateways/getGatewaySecret.ts @@ -0,0 +1,45 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ManagementRequestConfig} from '../../../models/api'; +import {Secret} from '../../../models/variable-store'; +import encodePathSegments from '../../../utils/encodePathSegments'; +import requestManagementResource from '../../../utils/requestManagementResource'; + +/** + * Configuration for the getGatewaySecret request. + */ +export interface GetGatewaySecretConfig extends ManagementRequestConfig { + /** + * Identifier of the gateway. + */ + gatewayId: string; + /** + * Name of the secret. + */ + name: string; +} + +/** + * Retrieves a secret on a gateway. The value is write-only: the server only says whether one is held, and + * the SDK never logs it. + * + * @param config - Request configuration object. + * @returns A promise that resolves with the secret. + * + * @example + * ```typescript + * const secret = await getGatewaySecret({baseUrl: 'https://localhost:8090', gatewayId, name: 'SMTP_HOST'}); + * ``` + */ +const getGatewaySecret = async ({gatewayId, name, ...config}: GetGatewaySecretConfig): Promise => + requestManagementResource(config, { + collection: 'gateways', + failureMessage: 'Failed to fetch secret', + id: gatewayId, + method: 'GET', + operation: 'getGatewaySecret', + subPath: `secrets/${encodePathSegments('getGatewaySecret', [name])}`, + }); + +export default getGatewaySecret; diff --git a/packages/javascript/src/api/management/gateways/getGatewaySecrets.ts b/packages/javascript/src/api/management/gateways/getGatewaySecrets.ts new file mode 100644 index 00000000..aa10ff6c --- /dev/null +++ b/packages/javascript/src/api/management/gateways/getGatewaySecrets.ts @@ -0,0 +1,52 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ApiFilteringParams, ManagementRequestConfig} from '../../../models/api'; +import {SecretListResponse} from '../../../models/variable-store'; +import requestManagementResource from '../../../utils/requestManagementResource'; + +/** + * Configuration for the getGatewaySecrets request. + */ +export interface GetGatewaySecretsConfig extends ManagementRequestConfig, ApiFilteringParams { + /** + * Identifier of the gateway. + */ + gatewayId: string; + /** + * Returns only these names (at most 100). A name with nothing stored is absent from the result. + */ + names?: string[]; +} + +/** + * Retrieves a page of secrets on a gateway. The request is passed to the gateway, which holds the secrets. + * The value is write-only: the server only says whether one is held, and the SDK never logs it. + * + * @param config - Request configuration object. + * @returns A promise that resolves with the page the server returned. + * + * @example + * ```typescript + * const {secrets} = await getGatewaySecrets({baseUrl: 'https://localhost:8090', gatewayId, names: ['SMTP_HOST']}); + * ``` + */ +const getGatewaySecrets = async ({ + filter, + gatewayId, + limit, + names, + offset, + ...config +}: GetGatewaySecretsConfig): Promise => + requestManagementResource(config, { + collection: 'gateways', + failureMessage: 'Failed to fetch secrets', + id: gatewayId, + method: 'GET', + operation: 'getGatewaySecrets', + query: {filter, limit, names: names?.join(','), offset}, + subPath: 'secrets', + }); + +export default getGatewaySecrets; diff --git a/packages/javascript/src/api/management/gateways/getGatewayVariable.ts b/packages/javascript/src/api/management/gateways/getGatewayVariable.ts new file mode 100644 index 00000000..83dbf251 --- /dev/null +++ b/packages/javascript/src/api/management/gateways/getGatewayVariable.ts @@ -0,0 +1,44 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ManagementRequestConfig} from '../../../models/api'; +import {Variable} from '../../../models/variable-store'; +import encodePathSegments from '../../../utils/encodePathSegments'; +import requestManagementResource from '../../../utils/requestManagementResource'; + +/** + * Configuration for the getGatewayVariable request. + */ +export interface GetGatewayVariableConfig extends ManagementRequestConfig { + /** + * Identifier of the gateway. + */ + gatewayId: string; + /** + * Name of the variable. + */ + name: string; +} + +/** + * Retrieves a variable on a gateway. + * + * @param config - Request configuration object. + * @returns A promise that resolves with the variable. + * + * @example + * ```typescript + * const variable = await getGatewayVariable({baseUrl: 'https://localhost:8090', gatewayId, name: 'SMTP_HOST'}); + * ``` + */ +const getGatewayVariable = async ({gatewayId, name, ...config}: GetGatewayVariableConfig): Promise => + requestManagementResource(config, { + collection: 'gateways', + failureMessage: 'Failed to fetch variable', + id: gatewayId, + method: 'GET', + operation: 'getGatewayVariable', + subPath: `variables/${encodePathSegments('getGatewayVariable', [name])}`, + }); + +export default getGatewayVariable; diff --git a/packages/javascript/src/api/management/gateways/getGatewayVariables.ts b/packages/javascript/src/api/management/gateways/getGatewayVariables.ts new file mode 100644 index 00000000..083168aa --- /dev/null +++ b/packages/javascript/src/api/management/gateways/getGatewayVariables.ts @@ -0,0 +1,52 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ApiFilteringParams, ManagementRequestConfig} from '../../../models/api'; +import {VariableListResponse} from '../../../models/variable-store'; +import requestManagementResource from '../../../utils/requestManagementResource'; + +/** + * Configuration for the getGatewayVariables request. + */ +export interface GetGatewayVariablesConfig extends ManagementRequestConfig, ApiFilteringParams { + /** + * Identifier of the gateway. + */ + gatewayId: string; + /** + * Returns only these names (at most 100). A name with nothing stored is absent from the result. + */ + names?: string[]; +} + +/** + * Retrieves a page of variables on a gateway. The request is passed to the gateway, which holds the + * variables. + * + * @param config - Request configuration object. + * @returns A promise that resolves with the page the server returned. + * + * @example + * ```typescript + * const {variables} = await getGatewayVariables({baseUrl: 'https://localhost:8090', gatewayId, names: ['SMTP_HOST']}); + * ``` + */ +const getGatewayVariables = async ({ + filter, + gatewayId, + limit, + names, + offset, + ...config +}: GetGatewayVariablesConfig): Promise => + requestManagementResource(config, { + collection: 'gateways', + failureMessage: 'Failed to fetch variables', + id: gatewayId, + method: 'GET', + operation: 'getGatewayVariables', + query: {filter, limit, names: names?.join(','), offset}, + subPath: 'variables', + }); + +export default getGatewayVariables; diff --git a/packages/javascript/src/api/management/gateways/getGateways.ts b/packages/javascript/src/api/management/gateways/getGateways.ts new file mode 100644 index 00000000..14bde75b --- /dev/null +++ b/packages/javascript/src/api/management/gateways/getGateways.ts @@ -0,0 +1,32 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ManagementRequestConfig} from '../../../models/api'; +import {Gateway} from '../../../models/gateway'; +import requestManagementResource from '../../../utils/requestManagementResource'; + +/** + * Configuration for the getGateways request. + */ +export type GetGatewaysConfig = ManagementRequestConfig; + +/** + * Retrieves the registered gateways. + * + * @param config - Request configuration object. + * @returns A promise that resolves with the gateways. + * + * @example + * ```typescript + * const gateways = await getGateways({baseUrl: 'https://localhost:8090'}); + * ``` + */ +const getGateways = async (config: GetGatewaysConfig): Promise => + requestManagementResource(config, { + collection: 'gateways', + failureMessage: 'Failed to fetch gateways', + method: 'GET', + operation: 'getGateways', + }); + +export default getGateways; diff --git a/packages/javascript/src/api/management/gateways/revertGatewayConfiguration.ts b/packages/javascript/src/api/management/gateways/revertGatewayConfiguration.ts new file mode 100644 index 00000000..d3ea1718 --- /dev/null +++ b/packages/javascript/src/api/management/gateways/revertGatewayConfiguration.ts @@ -0,0 +1,48 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ManagementRequestConfig} from '../../../models/api'; +import {GatewayApplyResult, RevertGatewayConfigurationRequest} from '../../../models/gateway'; +import requestManagementResource from '../../../utils/requestManagementResource'; + +/** + * Configuration for the revertGatewayConfiguration request. + */ +export interface RevertGatewayConfigurationConfig extends ManagementRequestConfig { + /** + * Identifier of the gateway. + */ + gatewayId: string; + /** + * How to revert. + */ + payload?: RevertGatewayConfigurationRequest; +} + +/** + * Reverts a gateway to the configuration version it held before. + * + * @param config - Request configuration object. + * @returns A promise that resolves with the outcome. + * + * @example + * ```typescript + * const result = await revertGatewayConfiguration({baseUrl: 'https://localhost:8090', gatewayId}); + * ``` + */ +const revertGatewayConfiguration = async ({ + gatewayId, + payload, + ...config +}: RevertGatewayConfigurationConfig): Promise => + requestManagementResource(config, { + body: payload, + collection: 'gateways', + failureMessage: 'Failed to revert gateway configuration', + id: gatewayId, + method: 'POST', + operation: 'revertGatewayConfiguration', + subPath: 'revert', + }); + +export default revertGatewayConfiguration; diff --git a/packages/javascript/src/api/management/gateways/updateGateway.ts b/packages/javascript/src/api/management/gateways/updateGateway.ts new file mode 100644 index 00000000..9d826c8d --- /dev/null +++ b/packages/javascript/src/api/management/gateways/updateGateway.ts @@ -0,0 +1,43 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ManagementRequestConfig} from '../../../models/api'; +import {Gateway, UpdateGatewayRequest} from '../../../models/gateway'; +import requestManagementResource from '../../../utils/requestManagementResource'; + +/** + * Configuration for the updateGateway request. + */ +export interface UpdateGatewayConfig extends ManagementRequestConfig { + /** + * Identifier of the gateway. + */ + gatewayId: string; + /** + * The gateway's new state. + */ + payload: UpdateGatewayRequest; +} + +/** + * Changes a gateway. + * + * @param config - Request configuration object. + * @returns A promise that resolves with the gateway as the server stored it. + * + * @example + * ```typescript + * const gateway = await updateGateway({baseUrl: 'https://localhost:8090', gatewayId, payload: {name: 'Edge EU'}}); + * ``` + */ +const updateGateway = async ({gatewayId, payload, ...config}: UpdateGatewayConfig): Promise => + requestManagementResource(config, { + body: payload, + collection: 'gateways', + failureMessage: 'Failed to update gateway', + id: gatewayId, + method: 'PUT', + operation: 'updateGateway', + }); + +export default updateGateway; diff --git a/packages/javascript/src/api/management/gateways/updateGatewaySecret.ts b/packages/javascript/src/api/management/gateways/updateGatewaySecret.ts new file mode 100644 index 00000000..c54adac7 --- /dev/null +++ b/packages/javascript/src/api/management/gateways/updateGatewaySecret.ts @@ -0,0 +1,50 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ManagementRequestConfig} from '../../../models/api'; +import {Secret, SecretUpdateRequest} from '../../../models/variable-store'; +import encodePathSegments from '../../../utils/encodePathSegments'; +import requestManagementResource from '../../../utils/requestManagementResource'; + +/** + * Configuration for the updateGatewaySecret request. + */ +export interface UpdateGatewaySecretConfig extends ManagementRequestConfig { + /** + * Identifier of the gateway. + */ + gatewayId: string; + /** + * Name of the secret. + */ + name: string; + /** + * The secret's new value. + */ + payload: SecretUpdateRequest; +} + +/** + * Sets or replaces a secret on a gateway, creating it when it does not exist. The value is write-only: the + * server only says whether one is held, and the SDK never logs it. + * + * @param config - Request configuration object. + * @returns A promise that resolves with the secret as the server stored it. + * + * @example + * ```typescript + * const secret = await updateGatewaySecret({baseUrl: 'https://localhost:8090', gatewayId, name: 'SMTP_HOST', payload: {value}}); + * ``` + */ +const updateGatewaySecret = async ({gatewayId, name, payload, ...config}: UpdateGatewaySecretConfig): Promise => + requestManagementResource(config, { + body: payload, + collection: 'gateways', + failureMessage: 'Failed to update secret', + id: gatewayId, + method: 'PUT', + operation: 'updateGatewaySecret', + subPath: `secrets/${encodePathSegments('updateGatewaySecret', [name])}`, + }); + +export default updateGatewaySecret; diff --git a/packages/javascript/src/api/management/gateways/updateGatewayVariable.ts b/packages/javascript/src/api/management/gateways/updateGatewayVariable.ts new file mode 100644 index 00000000..5d49fc82 --- /dev/null +++ b/packages/javascript/src/api/management/gateways/updateGatewayVariable.ts @@ -0,0 +1,54 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ManagementRequestConfig} from '../../../models/api'; +import {Variable, VariableUpdateRequest} from '../../../models/variable-store'; +import encodePathSegments from '../../../utils/encodePathSegments'; +import requestManagementResource from '../../../utils/requestManagementResource'; + +/** + * Configuration for the updateGatewayVariable request. + */ +export interface UpdateGatewayVariableConfig extends ManagementRequestConfig { + /** + * Identifier of the gateway. + */ + gatewayId: string; + /** + * Name of the variable. + */ + name: string; + /** + * The variable's new value. + */ + payload: VariableUpdateRequest; +} + +/** + * Sets or replaces a variable on a gateway, creating it when it does not exist. + * + * @param config - Request configuration object. + * @returns A promise that resolves with the variable as the server stored it. + * + * @example + * ```typescript + * const variable = await updateGatewayVariable({baseUrl: 'https://localhost:8090', gatewayId, name: 'SMTP_HOST', payload: {value}}); + * ``` + */ +const updateGatewayVariable = async ({ + gatewayId, + name, + payload, + ...config +}: UpdateGatewayVariableConfig): Promise => + requestManagementResource(config, { + body: payload, + collection: 'gateways', + failureMessage: 'Failed to update variable', + id: gatewayId, + method: 'PUT', + operation: 'updateGatewayVariable', + subPath: `variables/${encodePathSegments('updateGatewayVariable', [name])}`, + }); + +export default updateGatewayVariable; diff --git a/packages/javascript/src/api/management/groups/deleteGroup.ts b/packages/javascript/src/api/management/groups/deleteGroup.ts index 096ef4aa..d78025f5 100644 --- a/packages/javascript/src/api/management/groups/deleteGroup.ts +++ b/packages/javascript/src/api/management/groups/deleteGroup.ts @@ -1,13 +1,19 @@ // Copyright 2026 The ThunderID Authors // SPDX-License-Identifier: Apache-2.0 +import AdministrationModes, { + AdministrationFlowConfigKey, + AdministrationFlowInput, +} from '../../../constants/AdministrationConstants'; +import {AdministrationOptions} from '../../../models/administration'; import {ManagementRequestConfig} from '../../../models/api'; import requestManagementResource from '../../../utils/requestManagementResource'; +import runAdministrationFlow from '../administration/runAdministrationFlow'; /** * Configuration for the deleteGroup request. */ -export interface DeleteGroupConfig extends ManagementRequestConfig { +export interface DeleteGroupConfig extends ManagementRequestConfig, AdministrationOptions { /** * Identifier of the group to delete. */ @@ -15,7 +21,8 @@ export interface DeleteGroupConfig extends ManagementRequestConfig { } /** - * Deletes a group. + * Deletes a group. In `flow` mode the configured group deletion flow runs instead, revoking what the + * group's members held through it before the group goes. * * @param config - Request configuration object. * @returns A promise that resolves once the server has applied the change. @@ -25,13 +32,27 @@ export interface DeleteGroupConfig extends ManagementRequestConfig { * await deleteGroup({baseUrl: 'https://localhost:8090', groupId}); * ``` */ -const deleteGroup = async ({groupId, ...config}: DeleteGroupConfig): Promise => - requestManagementResource(config, { +const deleteGroup = async ({administrationMode, groupId, ...config}: DeleteGroupConfig): Promise => { + if (administrationMode === AdministrationModes.FLOW) { + const result: Record | null = await runAdministrationFlow( + config, + AdministrationFlowConfigKey.GROUP_DELETION, + {[AdministrationFlowInput.GROUP]: groupId}, + 'group deletion', + ); + + if (result !== null) { + return; + } + } + + await 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/removeGroupMembers.ts b/packages/javascript/src/api/management/groups/removeGroupMembers.ts index 1b7a0434..47b1d416 100644 --- a/packages/javascript/src/api/management/groups/removeGroupMembers.ts +++ b/packages/javascript/src/api/management/groups/removeGroupMembers.ts @@ -1,14 +1,19 @@ // Copyright 2026 The ThunderID Authors // SPDX-License-Identifier: Apache-2.0 +import AdministrationModes, { + AdministrationFlowConfigKey, + AdministrationFlowInput, +} from '../../../constants/AdministrationConstants'; +import {AdministrationOptions} from '../../../models/administration'; import {ManagementRequestConfig} from '../../../models/api'; +import getGroup from './getGroup'; import {Group, GroupMember} from '../../../models/group'; import requestManagementResource from '../../../utils/requestManagementResource'; +import executeAdministrationFlow from '../administration/executeAdministrationFlow'; +import resolveAdministrationFlowId from '../administration/resolveAdministrationFlowId'; -/** - * Configuration for the removeGroupMembers request. - */ -export interface RemoveGroupMembersConfig extends ManagementRequestConfig { +export interface RemoveGroupMembersConfig extends ManagementRequestConfig, AdministrationOptions { /** * Identifier of the group to remove members from. */ @@ -20,18 +25,38 @@ export interface RemoveGroupMembersConfig extends ManagementRequestConfig { } /** - * 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'}]}); - * ``` + * Removes members from a group and returns the group. In `flow` mode the configured membership removal + * flow runs once for each member instead, revoking what the member held through the group, and the + * group is read afterwards. */ -const removeGroupMembers = async ({groupId, members, ...config}: RemoveGroupMembersConfig): Promise => - requestManagementResource(config, { +const removeGroupMembers = async ({ + administrationMode, + groupId, + members, + ...config +}: RemoveGroupMembersConfig): Promise => { + if (administrationMode === AdministrationModes.FLOW) { + const flowId: string | null = await resolveAdministrationFlowId( + config, + AdministrationFlowConfigKey.GROUP_MEMBERSHIP_REMOVAL, + 'group membership removal', + ); + + if (flowId) { + for (const member of members) { + await executeAdministrationFlow( + config, + flowId, + {[AdministrationFlowInput.GROUP]: groupId, [AdministrationFlowInput.MEMBER]: member.id}, + 'group membership removal', + ); + } + + return getGroup({...config, groupId}); + } + } + + return requestManagementResource(config, { body: {members}, collection: 'groups', failureMessage: 'Failed to remove group members', @@ -40,5 +65,6 @@ const removeGroupMembers = async ({groupId, members, ...config}: RemoveGroupMemb operation: 'removeGroupMembers', subPath: 'members/remove', }); +}; export default removeGroupMembers; diff --git a/packages/javascript/src/api/management/i18n/deleteTranslation.ts b/packages/javascript/src/api/management/i18n/deleteTranslation.ts new file mode 100644 index 00000000..7cd85d30 --- /dev/null +++ b/packages/javascript/src/api/management/i18n/deleteTranslation.ts @@ -0,0 +1,48 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ManagementRequestConfig} from '../../../models/api'; +import encodePathSegments from '../../../utils/encodePathSegments'; +import requestManagementResource from '../../../utils/requestManagementResource'; + +/** + * Configuration for the deleteTranslation request. + */ +export interface DeleteTranslationConfig extends ManagementRequestConfig { + /** + * The translation key. + */ + key: string; + /** + * The language tag, such as `en-US`. + */ + language: string; + /** + * The translation namespace. + */ + namespace: string; +} + +/** + * Clears one custom translation. + * + * @param config - Request configuration object. + * @returns A promise that resolves once the server has made the change. + * + * @example + * ```typescript + * await deleteTranslation({baseUrl: 'https://localhost:8090', key: 'signin.title', language: 'en-US', namespace: 'auth'}); + * ``` + */ +const deleteTranslation = async ({key, language, namespace, ...config}: DeleteTranslationConfig): Promise => { + await requestManagementResource(config, { + collection: 'i18n', + failureMessage: 'Failed to delete translation', + id: 'languages', + method: 'DELETE', + operation: 'deleteTranslation', + subPath: `${encodePathSegments('deleteTranslation', [language])}/translations/ns/${encodePathSegments('deleteTranslation', [namespace])}/keys/${encodePathSegments('deleteTranslation', [key])}`, + }); +}; + +export default deleteTranslation; diff --git a/packages/javascript/src/api/management/i18n/deleteTranslations.ts b/packages/javascript/src/api/management/i18n/deleteTranslations.ts new file mode 100644 index 00000000..cfe07bc1 --- /dev/null +++ b/packages/javascript/src/api/management/i18n/deleteTranslations.ts @@ -0,0 +1,40 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ManagementRequestConfig} from '../../../models/api'; +import encodePathSegments from '../../../utils/encodePathSegments'; +import requestManagementResource from '../../../utils/requestManagementResource'; + +/** + * Configuration for the deleteTranslations request. + */ +export interface DeleteTranslationsConfig extends ManagementRequestConfig { + /** + * The language tag, such as `en-US`. + */ + language: string; +} + +/** + * Clears every custom translation of a language. + * + * @param config - Request configuration object. + * @returns A promise that resolves once the server has made the change. + * + * @example + * ```typescript + * await deleteTranslations({baseUrl: 'https://localhost:8090', language: 'en-US'}); + * ``` + */ +const deleteTranslations = async ({language, ...config}: DeleteTranslationsConfig): Promise => { + await requestManagementResource(config, { + collection: 'i18n', + failureMessage: 'Failed to delete translations', + id: 'languages', + method: 'DELETE', + operation: 'deleteTranslations', + subPath: `${encodePathSegments('deleteTranslations', [language])}/translations`, + }); +}; + +export default deleteTranslations; diff --git a/packages/javascript/src/api/management/i18n/getLanguages.ts b/packages/javascript/src/api/management/i18n/getLanguages.ts new file mode 100644 index 00000000..490ea93d --- /dev/null +++ b/packages/javascript/src/api/management/i18n/getLanguages.ts @@ -0,0 +1,33 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ManagementRequestConfig} from '../../../models/api'; +import {LanguageListResponse} from '../../../models/i18n-management'; +import requestManagementResource from '../../../utils/requestManagementResource'; + +/** + * Configuration for the getLanguages request. + */ +export type GetLanguagesConfig = ManagementRequestConfig; + +/** + * Retrieves the languages the server holds translations for. + * + * @param config - Request configuration object. + * @returns A promise that resolves with the languages. + * + * @example + * ```typescript + * const {languages} = await getLanguages({baseUrl: 'https://localhost:8090'}); + * ``` + */ +const getLanguages = async (config: GetLanguagesConfig): Promise => + requestManagementResource(config, { + collection: 'i18n', + failureMessage: 'Failed to fetch languages', + id: 'languages', + method: 'GET', + operation: 'getLanguages', + }); + +export default getLanguages; diff --git a/packages/javascript/src/api/management/i18n/resolveTranslation.ts b/packages/javascript/src/api/management/i18n/resolveTranslation.ts new file mode 100644 index 00000000..2fb67e52 --- /dev/null +++ b/packages/javascript/src/api/management/i18n/resolveTranslation.ts @@ -0,0 +1,53 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ManagementRequestConfig} from '../../../models/api'; +import {ManagedTranslation} from '../../../models/i18n-management'; +import encodePathSegments from '../../../utils/encodePathSegments'; +import requestManagementResource from '../../../utils/requestManagementResource'; + +/** + * Configuration for the resolveTranslation request. + */ +export interface ResolveTranslationConfig extends ManagementRequestConfig { + /** + * The translation key. + */ + key: string; + /** + * The language tag, such as `en-US`. + */ + language: string; + /** + * The translation namespace. + */ + namespace: string; +} + +/** + * Resolves one translation. + * + * @param config - Request configuration object. + * @returns A promise that resolves with the translation. + * + * @example + * ```typescript + * const {value} = await resolveTranslation({baseUrl: 'https://localhost:8090', key: 'signin.title', language: 'en-US', namespace: 'auth'}); + * ``` + */ +const resolveTranslation = async ({ + key, + language, + namespace, + ...config +}: ResolveTranslationConfig): Promise => + requestManagementResource(config, { + collection: 'i18n', + failureMessage: 'Failed to resolve translation', + id: 'languages', + method: 'GET', + operation: 'resolveTranslation', + subPath: `${encodePathSegments('resolveTranslation', [language])}/translations/ns/${encodePathSegments('resolveTranslation', [namespace])}/keys/${encodePathSegments('resolveTranslation', [key])}/resolve`, + }); + +export default resolveTranslation; diff --git a/packages/javascript/src/api/management/i18n/resolveTranslations.ts b/packages/javascript/src/api/management/i18n/resolveTranslations.ts new file mode 100644 index 00000000..11519470 --- /dev/null +++ b/packages/javascript/src/api/management/i18n/resolveTranslations.ts @@ -0,0 +1,50 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ManagementRequestConfig} from '../../../models/api'; +import {LanguageTranslationsResponse} from '../../../models/i18n-management'; +import encodePathSegments from '../../../utils/encodePathSegments'; +import requestManagementResource from '../../../utils/requestManagementResource'; + +/** + * Configuration for the resolveTranslations request. + */ +export interface ResolveTranslationsConfig extends ManagementRequestConfig { + /** + * The language tag, such as `en-US`. + */ + language: string; + /** + * Resolves only this namespace. + */ + namespace?: string; +} + +/** + * Resolves the translations of a language, with the server defaults filled in, optionally for one + * namespace. + * + * @param config - Request configuration object. + * @returns A promise that resolves with the translations. + * + * @example + * ```typescript + * const {translations} = await resolveTranslations({baseUrl: 'https://localhost:8090', language: 'en-US'}); + * ``` + */ +const resolveTranslations = async ({ + language, + namespace, + ...config +}: ResolveTranslationsConfig): Promise => + requestManagementResource(config, { + collection: 'i18n', + failureMessage: 'Failed to resolve translations', + id: 'languages', + method: 'GET', + operation: 'resolveTranslations', + query: {namespace}, + subPath: `${encodePathSegments('resolveTranslations', [language])}/translations/resolve`, + }); + +export default resolveTranslations; diff --git a/packages/javascript/src/api/management/i18n/setTranslation.ts b/packages/javascript/src/api/management/i18n/setTranslation.ts new file mode 100644 index 00000000..76c5df45 --- /dev/null +++ b/packages/javascript/src/api/management/i18n/setTranslation.ts @@ -0,0 +1,65 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ManagementRequestConfig} from '../../../models/api'; +import {ManagedTranslation, SetTranslationRequest} from '../../../models/i18n-management'; +import encodePathSegments from '../../../utils/encodePathSegments'; +import requestManagementResource from '../../../utils/requestManagementResource'; + +/** + * Configuration for the setTranslation request. + */ +export interface SetTranslationConfig extends ManagementRequestConfig { + /** + * The translation key. + */ + key: string; + /** + * The language tag, such as `en-US`. + */ + language: string; + /** + * The translation namespace. + */ + namespace: string; + /** + * The translated value. + */ + payload: SetTranslationRequest; +} + +/** + * Sets one translation. + * + * @param config - Request configuration object. + * @returns A promise that resolves with the translation as the server stored it. + * + * @example + * ```typescript + * await setTranslation({ + * baseUrl: 'https://localhost:8090', + * key: 'signin.title', + * language: 'en-US', + * namespace: 'auth', + * payload: {value: 'Welcome back'}, + * }); + * ``` + */ +const setTranslation = async ({ + key, + language, + namespace, + payload, + ...config +}: SetTranslationConfig): Promise => + requestManagementResource(config, { + body: payload, + collection: 'i18n', + failureMessage: 'Failed to set translation', + id: 'languages', + method: 'POST', + operation: 'setTranslation', + subPath: `${encodePathSegments('setTranslation', [language])}/translations/ns/${encodePathSegments('setTranslation', [namespace])}/keys/${encodePathSegments('setTranslation', [key])}`, + }); + +export default setTranslation; diff --git a/packages/javascript/src/api/management/i18n/setTranslations.ts b/packages/javascript/src/api/management/i18n/setTranslations.ts new file mode 100644 index 00000000..53bbd6ea --- /dev/null +++ b/packages/javascript/src/api/management/i18n/setTranslations.ts @@ -0,0 +1,53 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ManagementRequestConfig} from '../../../models/api'; +import {LanguageTranslationsResponse, SetTranslationsRequest} from '../../../models/i18n-management'; +import encodePathSegments from '../../../utils/encodePathSegments'; +import requestManagementResource from '../../../utils/requestManagementResource'; + +/** + * Configuration for the setTranslations request. + */ +export interface SetTranslationsConfig extends ManagementRequestConfig { + /** + * The language tag, such as `en-US`. + */ + language: string; + /** + * The translations, keyed by namespace and then by key. + */ + payload: SetTranslationsRequest; +} + +/** + * Sets translations of a language. + * + * @param config - Request configuration object. + * @returns A promise that resolves with the translations as the server stored them. + * + * @example + * ```typescript + * await setTranslations({ + * baseUrl: 'https://localhost:8090', + * language: 'en-US', + * payload: {translations: {auth: {'signin.title': 'Welcome back'}}}, + * }); + * ``` + */ +const setTranslations = async ({ + language, + payload, + ...config +}: SetTranslationsConfig): Promise => + requestManagementResource(config, { + body: payload, + collection: 'i18n', + failureMessage: 'Failed to set translations', + id: 'languages', + method: 'POST', + operation: 'setTranslations', + subPath: `${encodePathSegments('setTranslations', [language])}/translations`, + }); + +export default setTranslations; diff --git a/packages/javascript/src/api/management/notification-templates/createNotificationTemplate.ts b/packages/javascript/src/api/management/notification-templates/createNotificationTemplate.ts new file mode 100644 index 00000000..7b1de883 --- /dev/null +++ b/packages/javascript/src/api/management/notification-templates/createNotificationTemplate.ts @@ -0,0 +1,56 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ManagementRequestConfig} from '../../../models/api'; +import { + CreateNotificationTemplateRequest, + NotificationChannel, + NotificationTemplate, +} from '../../../models/notification-template'; +import requestManagementResource from '../../../utils/requestManagementResource'; + +/** + * Configuration for the createNotificationTemplate request. + */ +export interface CreateNotificationTemplateConfig extends ManagementRequestConfig { + /** + * The channel the templates are sent through: `email` or `sms`. + */ + channel: NotificationChannel; + /** + * The template to create, in the shape of its channel. + */ + payload: CreateNotificationTemplateRequest; +} + +/** + * Creates a notification template on a channel. + * + * @param config - Request configuration object. + * @returns A promise that resolves with the template as the server stored it. + * + * @example + * ```typescript + * const template = await createNotificationTemplate({ + * baseUrl: 'https://localhost:8090', + * channel: 'sms', + * payload: {content: {body: 'Your code is {{otp}}'}, displayName: 'OTP', handle: 'otp', ouId}, + * }); + * ``` + */ +const createNotificationTemplate = async ({ + channel, + payload, + ...config +}: CreateNotificationTemplateConfig): Promise => + requestManagementResource(config, { + body: payload, + collection: 'notification-templates', + failureMessage: 'Failed to create notification template', + id: channel, + method: 'POST', + operation: 'createNotificationTemplate', + subPath: 'templates', + }); + +export default createNotificationTemplate; diff --git a/packages/javascript/src/api/management/notification-templates/deleteNotificationTemplate.ts b/packages/javascript/src/api/management/notification-templates/deleteNotificationTemplate.ts new file mode 100644 index 00000000..9a65da0d --- /dev/null +++ b/packages/javascript/src/api/management/notification-templates/deleteNotificationTemplate.ts @@ -0,0 +1,49 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ManagementRequestConfig} from '../../../models/api'; +import {NotificationChannel} from '../../../models/notification-template'; +import encodePathSegments from '../../../utils/encodePathSegments'; +import requestManagementResource from '../../../utils/requestManagementResource'; + +/** + * Configuration for the deleteNotificationTemplate request. + */ +export interface DeleteNotificationTemplateConfig extends ManagementRequestConfig { + /** + * The channel the templates are sent through: `email` or `sms`. + */ + channel: NotificationChannel; + /** + * Identifier of the template. + */ + templateId: string; +} + +/** + * Deletes a notification template. + * + * @param config - Request configuration object. + * @returns A promise that resolves once the server has made the change. + * + * @example + * ```typescript + * await deleteNotificationTemplate({baseUrl: 'https://localhost:8090', channel: 'email', templateId}); + * ``` + */ +const deleteNotificationTemplate = async ({ + channel, + templateId, + ...config +}: DeleteNotificationTemplateConfig): Promise => { + await requestManagementResource(config, { + collection: 'notification-templates', + failureMessage: 'Failed to delete notification template', + id: channel, + method: 'DELETE', + operation: 'deleteNotificationTemplate', + subPath: `templates/${encodePathSegments('deleteNotificationTemplate', [templateId])}`, + }); +}; + +export default deleteNotificationTemplate; diff --git a/packages/javascript/src/api/management/notification-templates/getNotificationTemplate.ts b/packages/javascript/src/api/management/notification-templates/getNotificationTemplate.ts new file mode 100644 index 00000000..0102a4fb --- /dev/null +++ b/packages/javascript/src/api/management/notification-templates/getNotificationTemplate.ts @@ -0,0 +1,48 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ManagementRequestConfig} from '../../../models/api'; +import {NotificationChannel, NotificationTemplate} from '../../../models/notification-template'; +import encodePathSegments from '../../../utils/encodePathSegments'; +import requestManagementResource from '../../../utils/requestManagementResource'; + +/** + * Configuration for the getNotificationTemplate request. + */ +export interface GetNotificationTemplateConfig extends ManagementRequestConfig { + /** + * The channel the templates are sent through: `email` or `sms`. + */ + channel: NotificationChannel; + /** + * Identifier of the template. + */ + templateId: string; +} + +/** + * Retrieves a notification template. + * + * @param config - Request configuration object. + * @returns A promise that resolves with the template. + * + * @example + * ```typescript + * const template = await getNotificationTemplate({baseUrl: 'https://localhost:8090', channel: 'email', templateId}); + * ``` + */ +const getNotificationTemplate = async ({ + channel, + templateId, + ...config +}: GetNotificationTemplateConfig): Promise => + requestManagementResource(config, { + collection: 'notification-templates', + failureMessage: 'Failed to fetch notification template', + id: channel, + method: 'GET', + operation: 'getNotificationTemplate', + subPath: `templates/${encodePathSegments('getNotificationTemplate', [templateId])}`, + }); + +export default getNotificationTemplate; diff --git a/packages/javascript/src/api/management/notification-templates/getNotificationTemplates.ts b/packages/javascript/src/api/management/notification-templates/getNotificationTemplates.ts new file mode 100644 index 00000000..129379fe --- /dev/null +++ b/packages/javascript/src/api/management/notification-templates/getNotificationTemplates.ts @@ -0,0 +1,47 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ApiFilteringParams, ManagementRequestConfig} from '../../../models/api'; +import {NotificationChannel, NotificationTemplateListResponse} from '../../../models/notification-template'; +import requestManagementResource from '../../../utils/requestManagementResource'; + +/** + * Configuration for the getNotificationTemplates request. + */ +export interface GetNotificationTemplatesConfig + extends ManagementRequestConfig, + Pick { + /** + * The channel the templates are sent through: `email` or `sms`. + */ + channel: NotificationChannel; +} + +/** + * Retrieves a page of the notification templates of a channel. + * + * @param config - Request configuration object. + * @returns A promise that resolves with the page the server returned. + * + * @example + * ```typescript + * const {templates} = await getNotificationTemplates({baseUrl: 'https://localhost:8090', channel: 'email'}); + * ``` + */ +const getNotificationTemplates = async ({ + channel, + limit, + offset, + ...config +}: GetNotificationTemplatesConfig): Promise => + requestManagementResource(config, { + collection: 'notification-templates', + failureMessage: 'Failed to fetch notification templates', + id: channel, + method: 'GET', + operation: 'getNotificationTemplates', + query: {limit, offset}, + subPath: 'templates', + }); + +export default getNotificationTemplates; diff --git a/packages/javascript/src/api/management/notification-templates/updateNotificationTemplate.ts b/packages/javascript/src/api/management/notification-templates/updateNotificationTemplate.ts new file mode 100644 index 00000000..a5056a90 --- /dev/null +++ b/packages/javascript/src/api/management/notification-templates/updateNotificationTemplate.ts @@ -0,0 +1,63 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ManagementRequestConfig} from '../../../models/api'; +import { + NotificationChannel, + NotificationTemplate, + UpdateNotificationTemplateRequest, +} from '../../../models/notification-template'; +import encodePathSegments from '../../../utils/encodePathSegments'; +import requestManagementResource from '../../../utils/requestManagementResource'; + +/** + * Configuration for the updateNotificationTemplate request. + */ +export interface UpdateNotificationTemplateConfig extends ManagementRequestConfig { + /** + * The channel the templates are sent through: `email` or `sms`. + */ + channel: NotificationChannel; + /** + * The template's new state, in the shape of its channel. + */ + payload: UpdateNotificationTemplateRequest; + /** + * Identifier of the template. + */ + templateId: string; +} + +/** + * Updates a notification template. + * + * @param config - Request configuration object. + * @returns A promise that resolves with the template as the server stored it. + * + * @example + * ```typescript + * const template = await updateNotificationTemplate({ + * baseUrl: 'https://localhost:8090', + * channel: 'sms', + * templateId, + * payload: {content: {body: 'Your code: {{otp}}'}, displayName: 'OTP'}, + * }); + * ``` + */ +const updateNotificationTemplate = async ({ + channel, + payload, + templateId, + ...config +}: UpdateNotificationTemplateConfig): Promise => + requestManagementResource(config, { + body: payload, + collection: 'notification-templates', + failureMessage: 'Failed to update notification template', + id: channel, + method: 'PUT', + operation: 'updateNotificationTemplate', + subPath: `templates/${encodePathSegments('updateNotificationTemplate', [templateId])}`, + }); + +export default updateNotificationTemplate; diff --git a/packages/javascript/src/api/management/organization-units/createOrganizationUnit.ts b/packages/javascript/src/api/management/organization-units/createOrganizationUnit.ts new file mode 100644 index 00000000..164a2301 --- /dev/null +++ b/packages/javascript/src/api/management/organization-units/createOrganizationUnit.ts @@ -0,0 +1,41 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ManagementRequestConfig} from '../../../models/api'; +import {CreateOrganizationUnitRequest, OrganizationUnit} from '../../../models/organization-unit'; +import requestManagementResource from '../../../utils/requestManagementResource'; + +/** + * Configuration for the createOrganizationUnit request. + */ +export interface CreateOrganizationUnitConfig extends ManagementRequestConfig { + /** + * The organization unit to create. `parent` is `null` for a root. + */ + payload: CreateOrganizationUnitRequest; +} + +/** + * Creates an organization unit. + * + * @param config - Request configuration object. + * @returns A promise that resolves with the organization unit as the server stored it. + * + * @example + * ```typescript + * const organizationUnit = await createOrganizationUnit({ + * baseUrl: 'https://localhost:8090', + * payload: {handle: 'platform', name: 'Platform', parent: engineeringId}, + * }); + * ``` + */ +const createOrganizationUnit = async ({payload, ...config}: CreateOrganizationUnitConfig): Promise => + requestManagementResource(config, { + body: payload, + collection: 'organization-units', + failureMessage: 'Failed to create organization unit', + method: 'POST', + operation: 'createOrganizationUnit', + }); + +export default createOrganizationUnit; diff --git a/packages/javascript/src/api/management/organization-units/deleteOrganizationUnit.ts b/packages/javascript/src/api/management/organization-units/deleteOrganizationUnit.ts new file mode 100644 index 00000000..c91b8888 --- /dev/null +++ b/packages/javascript/src/api/management/organization-units/deleteOrganizationUnit.ts @@ -0,0 +1,38 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ManagementRequestConfig} from '../../../models/api'; +import requestManagementResource from '../../../utils/requestManagementResource'; + +/** + * Configuration for the deleteOrganizationUnit request. + */ +export interface DeleteOrganizationUnitConfig extends ManagementRequestConfig { + /** + * Identifier of the organization unit to delete. + */ + ouId: string; +} + +/** + * Deletes an organization unit. + * + * @param config - Request configuration object. + * @returns A promise that resolves once the server has made the change. + * + * @example + * ```typescript + * await deleteOrganizationUnit({baseUrl: 'https://localhost:8090', ouId}); + * ``` + */ +const deleteOrganizationUnit = async ({ouId, ...config}: DeleteOrganizationUnitConfig): Promise => { + await requestManagementResource(config, { + collection: 'organization-units', + failureMessage: 'Failed to delete organization unit', + id: ouId, + method: 'DELETE', + operation: 'deleteOrganizationUnit', + }); +}; + +export default deleteOrganizationUnit; diff --git a/packages/javascript/src/api/management/organization-units/deleteOrganizationUnitByPath.ts b/packages/javascript/src/api/management/organization-units/deleteOrganizationUnitByPath.ts new file mode 100644 index 00000000..6787894f --- /dev/null +++ b/packages/javascript/src/api/management/organization-units/deleteOrganizationUnitByPath.ts @@ -0,0 +1,41 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ManagementRequestConfig} from '../../../models/api'; +import encodeHandlePath from '../../../utils/encodeHandlePath'; +import requestManagementResource from '../../../utils/requestManagementResource'; + +/** + * Configuration for the deleteOrganizationUnitByPath request. + */ +export interface DeleteOrganizationUnitByPathConfig extends ManagementRequestConfig { + /** + * Handle path of the organization unit, such as `engineering/platform`. Each handle is encoded and the + * slashes between them are kept. + */ + path: string; +} + +/** + * Deletes an organization unit addressed by its handle path. + * + * @param config - Request configuration object. + * @returns A promise that resolves once the server has made the change. + * + * @example + * ```typescript + * await deleteOrganizationUnitByPath({baseUrl: 'https://localhost:8090', path: 'engineering/platform'}); + * ``` + */ +const deleteOrganizationUnitByPath = async ({path, ...config}: DeleteOrganizationUnitByPathConfig): Promise => { + await requestManagementResource(config, { + collection: 'organization-units', + failureMessage: 'Failed to delete organization unit', + id: 'tree', + method: 'DELETE', + operation: 'deleteOrganizationUnitByPath', + subPath: encodeHandlePath('deleteOrganizationUnitByPath', path), + }); +}; + +export default deleteOrganizationUnitByPath; diff --git a/packages/javascript/src/api/management/organization-units/getOrganizationUnit.ts b/packages/javascript/src/api/management/organization-units/getOrganizationUnit.ts new file mode 100644 index 00000000..65479f79 --- /dev/null +++ b/packages/javascript/src/api/management/organization-units/getOrganizationUnit.ts @@ -0,0 +1,38 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ManagementRequestConfig} from '../../../models/api'; +import {OrganizationUnit} from '../../../models/organization-unit'; +import requestManagementResource from '../../../utils/requestManagementResource'; + +/** + * Configuration for the getOrganizationUnit request. + */ +export interface GetOrganizationUnitConfig extends ManagementRequestConfig { + /** + * Identifier of the organization unit. + */ + ouId: string; +} + +/** + * Retrieves an organization unit. + * + * @param config - Request configuration object. + * @returns A promise that resolves with the organization unit. + * + * @example + * ```typescript + * const organizationUnit = await getOrganizationUnit({baseUrl: 'https://localhost:8090', ouId}); + * ``` + */ +const getOrganizationUnit = async ({ouId, ...config}: GetOrganizationUnitConfig): Promise => + requestManagementResource(config, { + collection: 'organization-units', + failureMessage: 'Failed to fetch organization unit', + id: ouId, + method: 'GET', + operation: 'getOrganizationUnit', + }); + +export default getOrganizationUnit; diff --git a/packages/javascript/src/api/management/organization-units/getOrganizationUnitByPath.ts b/packages/javascript/src/api/management/organization-units/getOrganizationUnitByPath.ts new file mode 100644 index 00000000..5d590145 --- /dev/null +++ b/packages/javascript/src/api/management/organization-units/getOrganizationUnitByPath.ts @@ -0,0 +1,44 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ManagementRequestConfig} from '../../../models/api'; +import {OrganizationUnit} from '../../../models/organization-unit'; +import encodeHandlePath from '../../../utils/encodeHandlePath'; +import requestManagementResource from '../../../utils/requestManagementResource'; + +/** + * Configuration for the getOrganizationUnitByPath request. + */ +export interface GetOrganizationUnitByPathConfig extends ManagementRequestConfig { + /** + * Handle path of the organization unit, such as `engineering/platform`. Each handle is encoded and the + * slashes between them are kept. + */ + path: string; +} + +/** + * Retrieves an organization unit by its handle path. + * + * @param config - Request configuration object. + * @returns A promise that resolves with the organization unit. + * + * @example + * ```typescript + * const organizationUnit = await getOrganizationUnitByPath({baseUrl: 'https://localhost:8090', path: 'engineering/platform'}); + * ``` + */ +const getOrganizationUnitByPath = async ({ + path, + ...config +}: GetOrganizationUnitByPathConfig): Promise => + requestManagementResource(config, { + collection: 'organization-units', + failureMessage: 'Failed to fetch organization unit', + id: 'tree', + method: 'GET', + operation: 'getOrganizationUnitByPath', + subPath: encodeHandlePath('getOrganizationUnitByPath', path), + }); + +export default getOrganizationUnitByPath; diff --git a/packages/javascript/src/api/management/getOrganizationUnitChildren.ts b/packages/javascript/src/api/management/organization-units/getOrganizationUnitChildren.ts similarity index 55% rename from packages/javascript/src/api/management/getOrganizationUnitChildren.ts rename to packages/javascript/src/api/management/organization-units/getOrganizationUnitChildren.ts index 5552912b..1cd370d7 100644 --- a/packages/javascript/src/api/management/getOrganizationUnitChildren.ts +++ b/packages/javascript/src/api/management/organization-units/getOrganizationUnitChildren.ts @@ -1,40 +1,16 @@ -// Copyright 2025 The ThunderID Authors +// Copyright 2025-2026 The ThunderID Authors // SPDX-License-Identifier: Apache-2.0 -import ThunderIDAPIError from '../../errors/ThunderIDAPIError'; - -/** - * Represents a single organization unit entry. - */ -export interface OrganizationUnit { - /** Optional description of the organization unit. */ - description?: string; - /** Unique identifier of the organization unit. */ - id: string; - /** Human-readable name of the organization unit. */ - name: string; - /** Identifier of the parent organization unit, if any. */ - parentId?: string; - /** Status of the organization unit (e.g. ACTIVE). */ - status?: string; -} - -/** - * Response shape for the organization unit children list endpoint. - */ -export interface OrganizationUnitListResponse { - /** Number of items returned in this page. */ - count?: number; - /** List of child organization units. */ - organizationUnits: OrganizationUnit[]; - /** Pagination start index (1-based). */ - startIndex?: number; - /** Total number of matching organization units. */ - totalResults?: number; -} +import ThunderIDAPIError from '../../../errors/ThunderIDAPIError'; +import {OrganizationUnitListResponse} from '../../../models/organization-unit'; +import encodeHandlePath from '../../../utils/encodeHandlePath'; +import requestManagementResource from '../../../utils/requestManagementResource'; /** * Configuration for the getOrganizationUnitChildren request. + * + * Address the parent by `ouId` or by its handle `path`. `organizationUnitId` is the earlier form and keeps + * its earlier endpoint. */ export interface GetOrganizationUnitChildrenConfig extends Omit { /** @@ -47,6 +23,10 @@ export interface GetOrganizationUnitChildrenConfig extends Omit Promise; + /** + * Narrows the children, such as `handle sw "eng"`. Applies with `ouId` or `path`. + */ + filter?: string; /** * Maximum number of results to return. */ @@ -56,37 +36,74 @@ export interface GetOrganizationUnitChildrenConfig extends Omit => { + if (ouId !== undefined || path !== undefined || organizationUnitId === undefined) { + return requestManagementResource( + {...requestConfig, baseUrl, fetcher, url}, + { + collection: 'organization-units', + failureMessage: 'Failed to fetch organization unit children', + id: path === undefined ? (ouId ?? '') : 'tree', + method: 'GET', + operation: 'getOrganizationUnitChildren', + query: {filter, limit, offset}, + subPath: path === undefined ? 'ous' : `${encodeHandlePath('getOrganizationUnitChildren', path)}/ous`, + }, + ); + } + try { // eslint-disable-next-line no-new new URL((url ?? baseUrl)!); } catch (error) { throw new ThunderIDAPIError( - `Invalid URL provided. ${error?.toString()}`, + `Invalid URL provided. ${String(error)}`, 'getOrganizationUnitChildren-ValidationError-001', 'javascript', 400, @@ -94,7 +111,7 @@ const getOrganizationUnitChildren = async ({ ); } - const fetchFn: typeof fetch = fetcher || fetch; + const fetchFn: typeof fetch = fetcher ?? fetch; const queryParams = new URLSearchParams(); if (limit !== undefined) queryParams.set('limit', String(limit)); @@ -106,6 +123,7 @@ const getOrganizationUnitChildren = async ({ const requestInit: RequestInit = { ...requestConfig, + ...(body !== undefined ? {body} : {}), headers: { Accept: 'application/json', 'Content-Type': 'application/json', diff --git a/packages/javascript/src/api/management/organization-units/getOrganizationUnitGroups.ts b/packages/javascript/src/api/management/organization-units/getOrganizationUnitGroups.ts new file mode 100644 index 00000000..ebeff9b9 --- /dev/null +++ b/packages/javascript/src/api/management/organization-units/getOrganizationUnitGroups.ts @@ -0,0 +1,45 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ApiFilteringParams, ManagementRequestConfig} from '../../../models/api'; +import {OrganizationUnitGroupListResponse, OrganizationUnitReference} from '../../../models/organization-unit'; +import encodeHandlePath from '../../../utils/encodeHandlePath'; +import requestManagementResource from '../../../utils/requestManagementResource'; + +/** + * Configuration for the getOrganizationUnitGroups request. + */ +export type GetOrganizationUnitGroupsConfig = ManagementRequestConfig & + Pick & + OrganizationUnitReference; + +/** + * Retrieves a page of the groups in an organization unit, addressed by its identifier (`ouId`) or its + * handle path (`path`). + * + * @param config - Request configuration object. + * @returns A promise that resolves with the page the server returned. + * + * @example + * ```typescript + * const {groups} = await getOrganizationUnitGroups({baseUrl: 'https://localhost:8090', path: 'engineering/platform', limit: 20}); + * ``` + */ +const getOrganizationUnitGroups = async ({ + limit, + offset, + ouId, + path, + ...config +}: GetOrganizationUnitGroupsConfig): Promise => + requestManagementResource(config, { + collection: 'organization-units', + failureMessage: 'Failed to fetch organization unit groups', + id: path === undefined ? (ouId ?? '') : 'tree', + method: 'GET', + operation: 'getOrganizationUnitGroups', + query: {limit, offset}, + subPath: path === undefined ? 'groups' : `${encodeHandlePath('getOrganizationUnitGroups', path)}/groups`, + }); + +export default getOrganizationUnitGroups; diff --git a/packages/javascript/src/api/management/organization-units/getOrganizationUnitRoles.ts b/packages/javascript/src/api/management/organization-units/getOrganizationUnitRoles.ts new file mode 100644 index 00000000..649ef3c5 --- /dev/null +++ b/packages/javascript/src/api/management/organization-units/getOrganizationUnitRoles.ts @@ -0,0 +1,45 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ApiFilteringParams, ManagementRequestConfig} from '../../../models/api'; +import {OrganizationUnitReference, OrganizationUnitRoleListResponse} from '../../../models/organization-unit'; +import encodeHandlePath from '../../../utils/encodeHandlePath'; +import requestManagementResource from '../../../utils/requestManagementResource'; + +/** + * Configuration for the getOrganizationUnitRoles request. + */ +export type GetOrganizationUnitRolesConfig = ManagementRequestConfig & + Pick & + OrganizationUnitReference; + +/** + * Retrieves a page of the roles in an organization unit, addressed by its identifier (`ouId`) or its handle + * path (`path`). + * + * @param config - Request configuration object. + * @returns A promise that resolves with the page the server returned. + * + * @example + * ```typescript + * const {roles} = await getOrganizationUnitRoles({baseUrl: 'https://localhost:8090', path: 'engineering/platform', limit: 20}); + * ``` + */ +const getOrganizationUnitRoles = async ({ + limit, + offset, + ouId, + path, + ...config +}: GetOrganizationUnitRolesConfig): Promise => + requestManagementResource(config, { + collection: 'organization-units', + failureMessage: 'Failed to fetch organization unit roles', + id: path === undefined ? (ouId ?? '') : 'tree', + method: 'GET', + operation: 'getOrganizationUnitRoles', + query: {limit, offset}, + subPath: path === undefined ? 'roles' : `${encodeHandlePath('getOrganizationUnitRoles', path)}/roles`, + }); + +export default getOrganizationUnitRoles; diff --git a/packages/javascript/src/api/management/organization-units/getOrganizationUnitUsers.ts b/packages/javascript/src/api/management/organization-units/getOrganizationUnitUsers.ts new file mode 100644 index 00000000..8ef92634 --- /dev/null +++ b/packages/javascript/src/api/management/organization-units/getOrganizationUnitUsers.ts @@ -0,0 +1,45 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ApiFilteringParams, ManagementRequestConfig} from '../../../models/api'; +import {OrganizationUnitReference, OrganizationUnitUserListResponse} from '../../../models/organization-unit'; +import encodeHandlePath from '../../../utils/encodeHandlePath'; +import requestManagementResource from '../../../utils/requestManagementResource'; + +/** + * Configuration for the getOrganizationUnitUsers request. + */ +export type GetOrganizationUnitUsersConfig = ManagementRequestConfig & + Pick & + OrganizationUnitReference; + +/** + * Retrieves a page of the users in an organization unit, addressed by its identifier (`ouId`) or its handle + * path (`path`). Each user carries its resolved `display` value. + * + * @param config - Request configuration object. + * @returns A promise that resolves with the page the server returned. + * + * @example + * ```typescript + * const {users} = await getOrganizationUnitUsers({baseUrl: 'https://localhost:8090', path: 'engineering/platform', limit: 20}); + * ``` + */ +const getOrganizationUnitUsers = async ({ + limit, + offset, + ouId, + path, + ...config +}: GetOrganizationUnitUsersConfig): Promise => + requestManagementResource(config, { + collection: 'organization-units', + failureMessage: 'Failed to fetch organization unit users', + id: path === undefined ? (ouId ?? '') : 'tree', + method: 'GET', + operation: 'getOrganizationUnitUsers', + query: {include: 'display', limit, offset}, + subPath: path === undefined ? 'users' : `${encodeHandlePath('getOrganizationUnitUsers', path)}/users`, + }); + +export default getOrganizationUnitUsers; diff --git a/packages/javascript/src/api/management/organization-units/getOrganizationUnits.ts b/packages/javascript/src/api/management/organization-units/getOrganizationUnits.ts new file mode 100644 index 00000000..416b8930 --- /dev/null +++ b/packages/javascript/src/api/management/organization-units/getOrganizationUnits.ts @@ -0,0 +1,38 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ApiFilteringParams, ManagementRequestConfig} from '../../../models/api'; +import {OrganizationUnitListResponse} from '../../../models/organization-unit'; +import requestManagementResource from '../../../utils/requestManagementResource'; + +/** + * Configuration for the getOrganizationUnits request. + */ +export type GetOrganizationUnitsConfig = ManagementRequestConfig & ApiFilteringParams; + +/** + * Retrieves a page of the root organization units. + * + * @param config - Request configuration object. + * @returns A promise that resolves with the page the server returned. + * + * @example + * ```typescript + * const {organizationUnits} = await getOrganizationUnits({baseUrl: 'https://localhost:8090', filter: 'handle sw "eng"'}); + * ``` + */ +const getOrganizationUnits = async ({ + filter, + limit, + offset, + ...config +}: GetOrganizationUnitsConfig): Promise => + requestManagementResource(config, { + collection: 'organization-units', + failureMessage: 'Failed to fetch organization units', + method: 'GET', + operation: 'getOrganizationUnits', + query: {filter, limit, offset}, + }); + +export default getOrganizationUnits; diff --git a/packages/javascript/src/api/management/organization-units/updateOrganizationUnit.ts b/packages/javascript/src/api/management/organization-units/updateOrganizationUnit.ts new file mode 100644 index 00000000..ba07f1df --- /dev/null +++ b/packages/javascript/src/api/management/organization-units/updateOrganizationUnit.ts @@ -0,0 +1,51 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ManagementRequestConfig} from '../../../models/api'; +import {OrganizationUnit, UpdateOrganizationUnitRequest} from '../../../models/organization-unit'; +import requestManagementResource from '../../../utils/requestManagementResource'; + +/** + * Configuration for the updateOrganizationUnit request. + */ +export interface UpdateOrganizationUnitConfig extends ManagementRequestConfig { + /** + * Identifier of the organization unit. + */ + ouId: string; + /** + * The organization unit's new state. + */ + payload: UpdateOrganizationUnitRequest; +} + +/** + * Updates an organization unit. + * + * @param config - Request configuration object. + * @returns A promise that resolves with the organization unit as the server stored it. + * + * @example + * ```typescript + * const organizationUnit = await updateOrganizationUnit({ + * baseUrl: 'https://localhost:8090', + * ouId, + * payload: {handle: 'platform', name: 'Platform Engineering', parent: engineeringId}, + * }); + * ``` + */ +const updateOrganizationUnit = async ({ + ouId, + payload, + ...config +}: UpdateOrganizationUnitConfig): Promise => + requestManagementResource(config, { + body: payload, + collection: 'organization-units', + failureMessage: 'Failed to update organization unit', + id: ouId, + method: 'PUT', + operation: 'updateOrganizationUnit', + }); + +export default updateOrganizationUnit; diff --git a/packages/javascript/src/api/management/organization-units/updateOrganizationUnitByPath.ts b/packages/javascript/src/api/management/organization-units/updateOrganizationUnitByPath.ts new file mode 100644 index 00000000..a1248b5d --- /dev/null +++ b/packages/javascript/src/api/management/organization-units/updateOrganizationUnitByPath.ts @@ -0,0 +1,54 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ManagementRequestConfig} from '../../../models/api'; +import {OrganizationUnit, UpdateOrganizationUnitByPathRequest} from '../../../models/organization-unit'; +import encodeHandlePath from '../../../utils/encodeHandlePath'; +import requestManagementResource from '../../../utils/requestManagementResource'; + +/** + * Configuration for the updateOrganizationUnitByPath request. + */ +export interface UpdateOrganizationUnitByPathConfig extends ManagementRequestConfig { + /** + * Handle path of the organization unit, such as `engineering/platform`. Each handle is encoded and the + * slashes between them are kept. + */ + path: string; + /** + * The organization unit's new state. + */ + payload: UpdateOrganizationUnitByPathRequest; +} + +/** + * Updates an organization unit addressed by its handle path. + * + * @param config - Request configuration object. + * @returns A promise that resolves with the organization unit as the server stored it. + * + * @example + * ```typescript + * const organizationUnit = await updateOrganizationUnitByPath({ + * baseUrl: 'https://localhost:8090', + * path: 'engineering/platform', + * payload: {handle: 'platform', name: 'Platform Engineering'}, + * }); + * ``` + */ +const updateOrganizationUnitByPath = async ({ + path, + payload, + ...config +}: UpdateOrganizationUnitByPathConfig): Promise => + requestManagementResource(config, { + body: payload, + collection: 'organization-units', + failureMessage: 'Failed to update organization unit', + id: 'tree', + method: 'PUT', + operation: 'updateOrganizationUnitByPath', + subPath: encodeHandlePath('updateOrganizationUnitByPath', path), + }); + +export default updateOrganizationUnitByPath; diff --git a/packages/javascript/src/api/management/resource-servers/createAction.ts b/packages/javascript/src/api/management/resource-servers/createAction.ts new file mode 100644 index 00000000..b0367db5 --- /dev/null +++ b/packages/javascript/src/api/management/resource-servers/createAction.ts @@ -0,0 +1,61 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ManagementRequestConfig} from '../../../models/api'; +import {CreateActionRequest, ManagedAction} from '../../../models/resource-server'; +import encodePathSegments from '../../../utils/encodePathSegments'; +import requestManagementResource from '../../../utils/requestManagementResource'; + +/** + * Configuration for the createAction request. + */ +export interface CreateActionConfig extends ManagementRequestConfig { + /** + * The action to create. + */ + payload: CreateActionRequest; + /** + * Identifier of the resource the action belongs to. Omit it for an action on the resource server + * itself. + */ + resourceId?: string; + /** + * Identifier of the resource server. + */ + resourceServerId: string; +} + +/** + * Creates an action on a resource server, or on one of its resources when `resourceId` is given. + * + * @param config - Request configuration object. + * @returns A promise that resolves with the action as the server stored it. + * + * @example + * ```typescript + * const action = await createAction({ + * baseUrl: 'https://localhost:8090', + * resourceId, + * resourceServerId, + * payload: {handle: 'read', name: 'Read'}, + * }); + * ``` + */ +const createAction = async ({ + payload, + resourceId, + resourceServerId, + ...config +}: CreateActionConfig): Promise => + requestManagementResource(config, { + body: payload, + collection: 'resource-servers', + failureMessage: 'Failed to create action', + id: resourceServerId, + method: 'POST', + operation: 'createAction', + subPath: + resourceId === undefined ? 'actions' : `resources/${encodePathSegments('createAction', [resourceId])}/actions`, + }); + +export default createAction; diff --git a/packages/javascript/src/api/management/resource-servers/createResource.ts b/packages/javascript/src/api/management/resource-servers/createResource.ts new file mode 100644 index 00000000..67689e3d --- /dev/null +++ b/packages/javascript/src/api/management/resource-servers/createResource.ts @@ -0,0 +1,48 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ManagementRequestConfig} from '../../../models/api'; +import {CreateResourceRequest, ManagedResource} from '../../../models/resource-server'; +import requestManagementResource from '../../../utils/requestManagementResource'; + +/** + * Configuration for the createResource request. + */ +export interface CreateResourceConfig extends ManagementRequestConfig { + /** + * The resource to create. + */ + payload: CreateResourceRequest; + /** + * Identifier of the resource server. + */ + resourceServerId: string; +} + +/** + * Creates a resource on a resource server. + * + * @param config - Request configuration object. + * @returns A promise that resolves with the resource as the server stored it. + * + * @example + * ```typescript + * const resource = await createResource({ + * baseUrl: 'https://localhost:8090', + * resourceServerId, + * payload: {handle: 'orders', name: 'Orders'}, + * }); + * ``` + */ +const createResource = async ({payload, resourceServerId, ...config}: CreateResourceConfig): Promise => + requestManagementResource(config, { + body: payload, + collection: 'resource-servers', + failureMessage: 'Failed to create resource', + id: resourceServerId, + method: 'POST', + operation: 'createResource', + subPath: 'resources', + }); + +export default createResource; diff --git a/packages/javascript/src/api/management/resource-servers/createResourceServer.ts b/packages/javascript/src/api/management/resource-servers/createResourceServer.ts new file mode 100644 index 00000000..0fe8a924 --- /dev/null +++ b/packages/javascript/src/api/management/resource-servers/createResourceServer.ts @@ -0,0 +1,41 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ManagementRequestConfig} from '../../../models/api'; +import {CreateResourceServerRequest, ResourceServer} from '../../../models/resource-server'; +import requestManagementResource from '../../../utils/requestManagementResource'; + +/** + * Configuration for the createResourceServer request. + */ +export interface CreateResourceServerConfig extends ManagementRequestConfig { + /** + * The resource server to create. + */ + payload: CreateResourceServerRequest; +} + +/** + * Creates a resource server. + * + * @param config - Request configuration object. + * @returns A promise that resolves with the resource server as the server stored it. + * + * @example + * ```typescript + * const resourceServer = await createResourceServer({ + * baseUrl: 'https://localhost:8090', + * payload: {identifier: 'https://api.example.com', name: 'Orders API', ouId}, + * }); + * ``` + */ +const createResourceServer = async ({payload, ...config}: CreateResourceServerConfig): Promise => + requestManagementResource(config, { + body: payload, + collection: 'resource-servers', + failureMessage: 'Failed to create resource server', + method: 'POST', + operation: 'createResourceServer', + }); + +export default createResourceServer; diff --git a/packages/javascript/src/api/management/resource-servers/deleteAction.ts b/packages/javascript/src/api/management/resource-servers/deleteAction.ts new file mode 100644 index 00000000..e053ed8b --- /dev/null +++ b/packages/javascript/src/api/management/resource-servers/deleteAction.ts @@ -0,0 +1,90 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import AdministrationModes, { + AdministrationFlowConfigKey, + AdministrationFlowInput, +} from '../../../constants/AdministrationConstants'; +import {AdministrationOptions} from '../../../models/administration'; +import {ManagementRequestConfig} from '../../../models/api'; +import encodePathSegments from '../../../utils/encodePathSegments'; +import requestManagementResource from '../../../utils/requestManagementResource'; +import runAdministrationFlow from '../administration/runAdministrationFlow'; + +/** + * Configuration for the deleteAction request. + */ +export interface DeleteActionConfig extends ManagementRequestConfig, AdministrationOptions { + /** + * Identifier of the action to delete. + */ + actionId: string; + /** + * Identifier of the resource the action belongs to. Omit it for an action on the resource server itself. + */ + resourceId?: string; + /** + * Identifier of the resource server. + */ + resourceServerId: string; +} + +/** + * Deletes an action of a resource server, or of one of its resources when `resourceId` is given. In `flow` + * mode the configured action deletion flow runs instead, revoking what the action's permission granted + * before the record goes; when no flow is configured, the action is deleted directly. + * + * @param config - Request configuration object. + * @returns A promise that resolves once the action is deleted. + * + * @example + * ```typescript + * await deleteAction({ + * baseUrl: 'https://localhost:8090', + * actionId, + * administrationMode: 'flow', + * resourceServerId, + * }); + * ``` + */ +const deleteAction = async ({ + actionId, + administrationMode, + resourceId, + resourceServerId, + ...config +}: DeleteActionConfig): Promise => { + // Built first, so a missing identifier is refused before a flow runs. + const subPath: string = + resourceId === undefined + ? `actions/${encodePathSegments('deleteAction', [actionId])}` + : `resources/${encodePathSegments('deleteAction', [resourceId])}/actions/${encodePathSegments('deleteAction', [actionId])}`; + + if (administrationMode === AdministrationModes.FLOW) { + const result: Record | null = await runAdministrationFlow( + config, + AdministrationFlowConfigKey.ACTION_DELETION, + { + [AdministrationFlowInput.ACTION]: actionId, + [AdministrationFlowInput.RESOURCE_SERVER]: resourceServerId, + ...(resourceId !== undefined ? {[AdministrationFlowInput.RESOURCE]: resourceId} : {}), + }, + 'action deletion', + ); + + if (result !== null) { + return; + } + } + + await requestManagementResource(config, { + collection: 'resource-servers', + failureMessage: 'Failed to delete action', + id: resourceServerId, + method: 'DELETE', + operation: 'deleteAction', + subPath, + }); +}; + +export default deleteAction; diff --git a/packages/javascript/src/api/management/resource-servers/deleteResource.ts b/packages/javascript/src/api/management/resource-servers/deleteResource.ts new file mode 100644 index 00000000..607c1f7d --- /dev/null +++ b/packages/javascript/src/api/management/resource-servers/deleteResource.ts @@ -0,0 +1,44 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ManagementRequestConfig} from '../../../models/api'; +import encodePathSegments from '../../../utils/encodePathSegments'; +import requestManagementResource from '../../../utils/requestManagementResource'; + +/** + * Configuration for the deleteResource request. + */ +export interface DeleteResourceConfig extends ManagementRequestConfig { + /** + * Identifier of the resource. + */ + resourceId: string; + /** + * Identifier of the resource server. + */ + resourceServerId: string; +} + +/** + * Deletes a resource of a resource server. + * + * @param config - Request configuration object. + * @returns A promise that resolves once the server has made the change. + * + * @example + * ```typescript + * await deleteResource({baseUrl: 'https://localhost:8090', resourceId, resourceServerId}); + * ``` + */ +const deleteResource = async ({resourceId, resourceServerId, ...config}: DeleteResourceConfig): Promise => { + await requestManagementResource(config, { + collection: 'resource-servers', + failureMessage: 'Failed to delete resource', + id: resourceServerId, + method: 'DELETE', + operation: 'deleteResource', + subPath: `resources/${encodePathSegments('deleteResource', [resourceId])}`, + }); +}; + +export default deleteResource; diff --git a/packages/javascript/src/api/management/resource-servers/deleteResourceServer.ts b/packages/javascript/src/api/management/resource-servers/deleteResourceServer.ts new file mode 100644 index 00000000..8399bdc0 --- /dev/null +++ b/packages/javascript/src/api/management/resource-servers/deleteResourceServer.ts @@ -0,0 +1,38 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ManagementRequestConfig} from '../../../models/api'; +import requestManagementResource from '../../../utils/requestManagementResource'; + +/** + * Configuration for the deleteResourceServer request. + */ +export interface DeleteResourceServerConfig extends ManagementRequestConfig { + /** + * Identifier of the resource server. + */ + resourceServerId: string; +} + +/** + * Deletes a resource server. + * + * @param config - Request configuration object. + * @returns A promise that resolves once the server has made the change. + * + * @example + * ```typescript + * await deleteResourceServer({baseUrl: 'https://localhost:8090', resourceServerId}); + * ``` + */ +const deleteResourceServer = async ({resourceServerId, ...config}: DeleteResourceServerConfig): Promise => { + await requestManagementResource(config, { + collection: 'resource-servers', + failureMessage: 'Failed to delete resource server', + id: resourceServerId, + method: 'DELETE', + operation: 'deleteResourceServer', + }); +}; + +export default deleteResourceServer; diff --git a/packages/javascript/src/api/management/resource-servers/getAction.ts b/packages/javascript/src/api/management/resource-servers/getAction.ts new file mode 100644 index 00000000..5204b5b8 --- /dev/null +++ b/packages/javascript/src/api/management/resource-servers/getAction.ts @@ -0,0 +1,54 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ManagementRequestConfig} from '../../../models/api'; +import {ManagedAction} from '../../../models/resource-server'; +import encodePathSegments from '../../../utils/encodePathSegments'; +import requestManagementResource from '../../../utils/requestManagementResource'; + +/** + * Configuration for the getAction request. + */ +export interface GetActionConfig extends ManagementRequestConfig { + /** + * Identifier of the action. + */ + actionId: string; + /** + * Identifier of the resource the action belongs to. Omit it for an action on the resource server + * itself. + */ + resourceId?: string; + /** + * Identifier of the resource server. + */ + resourceServerId: string; +} + +/** + * Retrieves an action of a resource server, or of one of its resources when `resourceId` is given. + * + * @param config - Request configuration object. + * @returns A promise that resolves with the action. + * + * @example + * ```typescript + * const action = await getAction({baseUrl: 'https://localhost:8090', actionId, resourceServerId}); + * ``` + */ +const getAction = async ({ + actionId, + resourceId, + resourceServerId, + ...config +}: GetActionConfig): Promise => + requestManagementResource(config, { + collection: 'resource-servers', + failureMessage: 'Failed to fetch action', + id: resourceServerId, + method: 'GET', + operation: 'getAction', + subPath: `${resourceId === undefined ? '' : `resources/${encodePathSegments('getAction', [resourceId])}/`}actions/${encodePathSegments('getAction', [actionId])}`, + }); + +export default getAction; diff --git a/packages/javascript/src/api/management/resource-servers/getActions.ts b/packages/javascript/src/api/management/resource-servers/getActions.ts new file mode 100644 index 00000000..beeb19ec --- /dev/null +++ b/packages/javascript/src/api/management/resource-servers/getActions.ts @@ -0,0 +1,59 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ApiFilteringParams, ManagementRequestConfig} from '../../../models/api'; +import {ManagedActionKind, ManagedActionListResponse} from '../../../models/resource-server'; +import encodePathSegments from '../../../utils/encodePathSegments'; +import requestManagementResource from '../../../utils/requestManagementResource'; + +/** + * Configuration for the getActions request. + */ +export interface GetActionsConfig extends ManagementRequestConfig, Pick { + /** + * Lists only the actions of this kind. + */ + kind?: ManagedActionKind; + /** + * Identifier of the resource the action belongs to. Omit it for an action on the resource server + * itself. + */ + resourceId?: string; + /** + * Identifier of the resource server. + */ + resourceServerId: string; +} + +/** + * Retrieves a page of the actions of a resource server, or of one of its resources when `resourceId` is + * given. + * + * @param config - Request configuration object. + * @returns A promise that resolves with the page the server returned. + * + * @example + * ```typescript + * const {actions} = await getActions({baseUrl: 'https://localhost:8090', resourceId, resourceServerId}); + * ``` + */ +const getActions = async ({ + kind, + limit, + offset, + resourceId, + resourceServerId, + ...config +}: GetActionsConfig): Promise => + requestManagementResource(config, { + collection: 'resource-servers', + failureMessage: 'Failed to fetch actions', + id: resourceServerId, + method: 'GET', + operation: 'getActions', + query: {kind, limit, offset}, + subPath: + resourceId === undefined ? 'actions' : `resources/${encodePathSegments('getActions', [resourceId])}/actions`, + }); + +export default getActions; diff --git a/packages/javascript/src/api/management/resource-servers/getResource.ts b/packages/javascript/src/api/management/resource-servers/getResource.ts new file mode 100644 index 00000000..2b0c60e9 --- /dev/null +++ b/packages/javascript/src/api/management/resource-servers/getResource.ts @@ -0,0 +1,44 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ManagementRequestConfig} from '../../../models/api'; +import {ManagedResource} from '../../../models/resource-server'; +import encodePathSegments from '../../../utils/encodePathSegments'; +import requestManagementResource from '../../../utils/requestManagementResource'; + +/** + * Configuration for the getResource request. + */ +export interface GetResourceConfig extends ManagementRequestConfig { + /** + * Identifier of the resource. + */ + resourceId: string; + /** + * Identifier of the resource server. + */ + resourceServerId: string; +} + +/** + * Retrieves a resource of a resource server. + * + * @param config - Request configuration object. + * @returns A promise that resolves with the resource. + * + * @example + * ```typescript + * const resource = await getResource({baseUrl: 'https://localhost:8090', resourceId, resourceServerId}); + * ``` + */ +const getResource = async ({resourceId, resourceServerId, ...config}: GetResourceConfig): Promise => + requestManagementResource(config, { + collection: 'resource-servers', + failureMessage: 'Failed to fetch resource', + id: resourceServerId, + method: 'GET', + operation: 'getResource', + subPath: `resources/${encodePathSegments('getResource', [resourceId])}`, + }); + +export default getResource; diff --git a/packages/javascript/src/api/management/resource-servers/getResourceServer.ts b/packages/javascript/src/api/management/resource-servers/getResourceServer.ts new file mode 100644 index 00000000..7e9ee62a --- /dev/null +++ b/packages/javascript/src/api/management/resource-servers/getResourceServer.ts @@ -0,0 +1,38 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ManagementRequestConfig} from '../../../models/api'; +import {ResourceServer} from '../../../models/resource-server'; +import requestManagementResource from '../../../utils/requestManagementResource'; + +/** + * Configuration for the getResourceServer request. + */ +export interface GetResourceServerConfig extends ManagementRequestConfig { + /** + * Identifier of the resource server. + */ + resourceServerId: string; +} + +/** + * Retrieves a resource server. + * + * @param config - Request configuration object. + * @returns A promise that resolves with the resource server. + * + * @example + * ```typescript + * const resourceServer = await getResourceServer({baseUrl: 'https://localhost:8090', resourceServerId}); + * ``` + */ +const getResourceServer = async ({resourceServerId, ...config}: GetResourceServerConfig): Promise => + requestManagementResource(config, { + collection: 'resource-servers', + failureMessage: 'Failed to fetch resource server', + id: resourceServerId, + method: 'GET', + operation: 'getResourceServer', + }); + +export default getResourceServer; diff --git a/packages/javascript/src/api/management/resource-servers/getResourceServers.ts b/packages/javascript/src/api/management/resource-servers/getResourceServers.ts new file mode 100644 index 00000000..71dbd707 --- /dev/null +++ b/packages/javascript/src/api/management/resource-servers/getResourceServers.ts @@ -0,0 +1,37 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ApiFilteringParams, ManagementRequestConfig} from '../../../models/api'; +import {ResourceServerListResponse} from '../../../models/resource-server'; +import requestManagementResource from '../../../utils/requestManagementResource'; + +/** + * Configuration for the getResourceServers request. + */ +export type GetResourceServersConfig = ManagementRequestConfig & Pick; + +/** + * Retrieves a page of resource servers. + * + * @param config - Request configuration object. + * @returns A promise that resolves with the page the server returned. + * + * @example + * ```typescript + * const {resourceServers} = await getResourceServers({baseUrl: 'https://localhost:8090', limit: 20}); + * ``` + */ +const getResourceServers = async ({ + limit, + offset, + ...config +}: GetResourceServersConfig): Promise => + requestManagementResource(config, { + collection: 'resource-servers', + failureMessage: 'Failed to fetch resource servers', + method: 'GET', + operation: 'getResourceServers', + query: {limit, offset}, + }); + +export default getResourceServers; diff --git a/packages/javascript/src/api/management/resource-servers/getResources.ts b/packages/javascript/src/api/management/resource-servers/getResources.ts new file mode 100644 index 00000000..74c6e452 --- /dev/null +++ b/packages/javascript/src/api/management/resource-servers/getResources.ts @@ -0,0 +1,50 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ApiFilteringParams, ManagementRequestConfig} from '../../../models/api'; +import {ManagedResourceListResponse} from '../../../models/resource-server'; +import requestManagementResource from '../../../utils/requestManagementResource'; + +/** + * Configuration for the getResources request. + */ +export interface GetResourcesConfig extends ManagementRequestConfig, Pick { + /** + * Lists the resources nested under this resource instead of the top-level ones. + */ + parentId?: string; + /** + * Identifier of the resource server. + */ + resourceServerId: string; +} + +/** + * Retrieves a page of the resources of a resource server. + * + * @param config - Request configuration object. + * @returns A promise that resolves with the page the server returned. + * + * @example + * ```typescript + * const {resources} = await getResources({baseUrl: 'https://localhost:8090', resourceServerId}); + * ``` + */ +const getResources = async ({ + limit, + offset, + parentId, + resourceServerId, + ...config +}: GetResourcesConfig): Promise => + requestManagementResource(config, { + collection: 'resource-servers', + failureMessage: 'Failed to fetch resources', + id: resourceServerId, + method: 'GET', + operation: 'getResources', + query: {limit, offset, parentId}, + subPath: 'resources', + }); + +export default getResources; diff --git a/packages/javascript/src/api/management/resource-servers/updateAction.ts b/packages/javascript/src/api/management/resource-servers/updateAction.ts new file mode 100644 index 00000000..be1b08e1 --- /dev/null +++ b/packages/javascript/src/api/management/resource-servers/updateAction.ts @@ -0,0 +1,65 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ManagementRequestConfig} from '../../../models/api'; +import {ManagedAction, UpdateActionRequest} from '../../../models/resource-server'; +import encodePathSegments from '../../../utils/encodePathSegments'; +import requestManagementResource from '../../../utils/requestManagementResource'; + +/** + * Configuration for the updateAction request. + */ +export interface UpdateActionConfig extends ManagementRequestConfig { + /** + * Identifier of the action. + */ + actionId: string; + /** + * The action's new state. + */ + payload: UpdateActionRequest; + /** + * Identifier of the resource the action belongs to. Omit it for an action on the resource server + * itself. + */ + resourceId?: string; + /** + * Identifier of the resource server. + */ + resourceServerId: string; +} + +/** + * Updates an action of a resource server, or of one of its resources when `resourceId` is given. + * + * @param config - Request configuration object. + * @returns A promise that resolves with the action as the server stored it. + * + * @example + * ```typescript + * const action = await updateAction({ + * baseUrl: 'https://localhost:8090', + * actionId, + * resourceServerId, + * payload: {name: 'Read orders'}, + * }); + * ``` + */ +const updateAction = async ({ + actionId, + payload, + resourceId, + resourceServerId, + ...config +}: UpdateActionConfig): Promise => + requestManagementResource(config, { + body: payload, + collection: 'resource-servers', + failureMessage: 'Failed to update action', + id: resourceServerId, + method: 'PUT', + operation: 'updateAction', + subPath: `${resourceId === undefined ? '' : `resources/${encodePathSegments('updateAction', [resourceId])}/`}actions/${encodePathSegments('updateAction', [actionId])}`, + }); + +export default updateAction; diff --git a/packages/javascript/src/api/management/resource-servers/updateResource.ts b/packages/javascript/src/api/management/resource-servers/updateResource.ts new file mode 100644 index 00000000..24645ae4 --- /dev/null +++ b/packages/javascript/src/api/management/resource-servers/updateResource.ts @@ -0,0 +1,59 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ManagementRequestConfig} from '../../../models/api'; +import {ManagedResource, UpdateResourceRequest} from '../../../models/resource-server'; +import encodePathSegments from '../../../utils/encodePathSegments'; +import requestManagementResource from '../../../utils/requestManagementResource'; + +/** + * Configuration for the updateResource request. + */ +export interface UpdateResourceConfig extends ManagementRequestConfig { + /** + * The resource's new state. + */ + payload: UpdateResourceRequest; + /** + * Identifier of the resource. + */ + resourceId: string; + /** + * Identifier of the resource server. + */ + resourceServerId: string; +} + +/** + * Updates a resource of a resource server. + * + * @param config - Request configuration object. + * @returns A promise that resolves with the resource as the server stored it. + * + * @example + * ```typescript + * const resource = await updateResource({ + * baseUrl: 'https://localhost:8090', + * resourceId, + * resourceServerId, + * payload: {name: 'Customer orders'}, + * }); + * ``` + */ +const updateResource = async ({ + payload, + resourceId, + resourceServerId, + ...config +}: UpdateResourceConfig): Promise => + requestManagementResource(config, { + body: payload, + collection: 'resource-servers', + failureMessage: 'Failed to update resource', + id: resourceServerId, + method: 'PUT', + operation: 'updateResource', + subPath: `resources/${encodePathSegments('updateResource', [resourceId])}`, + }); + +export default updateResource; diff --git a/packages/javascript/src/api/management/resource-servers/updateResourceServer.ts b/packages/javascript/src/api/management/resource-servers/updateResourceServer.ts new file mode 100644 index 00000000..b061796e --- /dev/null +++ b/packages/javascript/src/api/management/resource-servers/updateResourceServer.ts @@ -0,0 +1,51 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ManagementRequestConfig} from '../../../models/api'; +import {ResourceServer, UpdateResourceServerRequest} from '../../../models/resource-server'; +import requestManagementResource from '../../../utils/requestManagementResource'; + +/** + * Configuration for the updateResourceServer request. + */ +export interface UpdateResourceServerConfig extends ManagementRequestConfig { + /** + * The resource server's new state. + */ + payload: UpdateResourceServerRequest; + /** + * Identifier of the resource server. + */ + resourceServerId: string; +} + +/** + * Updates a resource server. + * + * @param config - Request configuration object. + * @returns A promise that resolves with the resource server as the server stored it. + * + * @example + * ```typescript + * const resourceServer = await updateResourceServer({ + * baseUrl: 'https://localhost:8090', + * resourceServerId, + * payload: {name: 'Orders API', ouId}, + * }); + * ``` + */ +const updateResourceServer = async ({ + payload, + resourceServerId, + ...config +}: UpdateResourceServerConfig): Promise => + requestManagementResource(config, { + body: payload, + collection: 'resource-servers', + failureMessage: 'Failed to update resource server', + id: resourceServerId, + method: 'PUT', + operation: 'updateResourceServer', + }); + +export default updateResourceServer; diff --git a/packages/javascript/src/api/management/roles/deleteRole.ts b/packages/javascript/src/api/management/roles/deleteRole.ts index ff7328b4..d9058476 100644 --- a/packages/javascript/src/api/management/roles/deleteRole.ts +++ b/packages/javascript/src/api/management/roles/deleteRole.ts @@ -1,13 +1,19 @@ // Copyright 2026 The ThunderID Authors // SPDX-License-Identifier: Apache-2.0 +import AdministrationModes, { + AdministrationFlowConfigKey, + AdministrationFlowInput, +} from '../../../constants/AdministrationConstants'; +import {AdministrationOptions} from '../../../models/administration'; import {ManagementRequestConfig} from '../../../models/api'; import requestManagementResource from '../../../utils/requestManagementResource'; +import runAdministrationFlow from '../administration/runAdministrationFlow'; /** * Configuration for the deleteRole request. */ -export interface DeleteRoleConfig extends ManagementRequestConfig { +export interface DeleteRoleConfig extends ManagementRequestConfig, AdministrationOptions { /** * Identifier of the role to delete. */ @@ -15,7 +21,8 @@ export interface DeleteRoleConfig extends ManagementRequestConfig { } /** - * Deletes a role. + * Deletes a role. In `flow` mode the configured role deletion flow runs instead, revoking the scopes + * the role granted before it goes. * * @param config - Request configuration object. * @returns A promise that resolves once the server has applied the change. @@ -25,13 +32,27 @@ export interface DeleteRoleConfig extends ManagementRequestConfig { * await deleteRole({baseUrl: 'https://localhost:8090', roleId}); * ``` */ -const deleteRole = async ({roleId, ...config}: DeleteRoleConfig): Promise => - requestManagementResource(config, { +const deleteRole = async ({administrationMode, roleId, ...config}: DeleteRoleConfig): Promise => { + if (administrationMode === AdministrationModes.FLOW) { + const result: Record | null = await runAdministrationFlow( + config, + AdministrationFlowConfigKey.ROLE_DELETION, + {[AdministrationFlowInput.ROLE]: roleId}, + 'role deletion', + ); + + if (result !== null) { + return; + } + } + + await 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 index 0b0c8401..54eda258 100644 --- a/packages/javascript/src/api/management/roles/getRole.ts +++ b/packages/javascript/src/api/management/roles/getRole.ts @@ -33,6 +33,7 @@ const getRole = async ({roleId, ...config}: GetRoleConfig): Promise => id: roleId, method: 'GET', operation: 'getRole', + query: {include: 'display'}, }); export default getRole; diff --git a/packages/javascript/src/api/management/roles/getRoles.ts b/packages/javascript/src/api/management/roles/getRoles.ts index 87cff418..a7ff50c5 100644 --- a/packages/javascript/src/api/management/roles/getRoles.ts +++ b/packages/javascript/src/api/management/roles/getRoles.ts @@ -27,7 +27,7 @@ const getRoles = async ({limit, offset, ...config}: GetRolesConfig): Promise => - requestManagementResource(config, { +const removeRoleAssignments = async ({ + administrationMode, + assignments, + roleId, + ...config +}: RemoveRoleAssignmentsConfig): Promise => { + if (administrationMode === AdministrationModes.FLOW) { + const flowId: string | null = await resolveAdministrationFlowId( + config, + AdministrationFlowConfigKey.ROLE_ASSIGNMENT_REMOVAL, + 'role assignment removal', + ); + + if (flowId) { + for (const assignment of assignments) { + await executeAdministrationFlow( + config, + flowId, + {[AdministrationFlowInput.ASSIGNEE]: assignment.id, [AdministrationFlowInput.ROLE]: roleId}, + 'role assignment removal', + ); + } + + return; + } + } + + await requestManagementResource(config, { body: {assignments}, collection: 'roles', failureMessage: 'Failed to remove role assignments', @@ -40,5 +63,6 @@ const removeRoleAssignments = async ({assignments, roleId, ...config}: RemoveRol 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 index 1bb3e495..9f785454 100644 --- a/packages/javascript/src/api/management/roles/updateRole.ts +++ b/packages/javascript/src/api/management/roles/updateRole.ts @@ -1,14 +1,24 @@ // Copyright 2026 The ThunderID Authors // SPDX-License-Identifier: Apache-2.0 +import getRole from './getRole'; +import AdministrationModes, { + AdministrationFlowConfigKey, + AdministrationFlowInput, +} from '../../../constants/AdministrationConstants'; +import {AdministrationOptions} from '../../../models/administration'; import {ManagementRequestConfig} from '../../../models/api'; -import {Role, UpdateRoleRequest} from '../../../models/role'; +import {Role, RoleResourcePermissions, UpdateRoleRequest} from '../../../models/role'; import requestManagementResource from '../../../utils/requestManagementResource'; +import executeAdministrationFlow from '../administration/executeAdministrationFlow'; +import resolveAdministrationFlowId from '../administration/resolveAdministrationFlowId'; -/** - * Configuration for the updateRole request. - */ -export interface UpdateRoleConfig extends ManagementRequestConfig { +export interface UpdateRoleConfig extends ManagementRequestConfig, AdministrationOptions { + /** + * The role as it is now. In `flow` mode it tells whether the update removes a permission; when left + * out, the role is read first. + */ + current?: Role; /** * The role's new state. */ @@ -20,17 +30,28 @@ export interface UpdateRoleConfig extends ManagementRequestConfig { } /** - * 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}); - * ``` + * Whether the new permissions drop any of the current ones. Only a removal has anything to revoke. */ -const updateRole = async ({payload, roleId, ...config}: UpdateRoleConfig): Promise => +const removesPermission = ( + current: RoleResourcePermissions[] | undefined, + next: RoleResourcePermissions[] | undefined, +): boolean => { + const retained: Set = new Set(); + + (next ?? []).forEach((resource: RoleResourcePermissions) => { + (resource.permissions ?? []).forEach((permission: string) => { + retained.add(`${resource.resourceServerId}\u0000${permission}`); + }); + }); + + return (current ?? []).some((resource: RoleResourcePermissions) => + (resource.permissions ?? []).some( + (permission: string) => !retained.has(`${resource.resourceServerId}\u0000${permission}`), + ), + ); +}; + +const putRole = (config: ManagementRequestConfig, roleId: string, payload: UpdateRoleRequest): Promise => requestManagementResource(config, { body: payload, collection: 'roles', @@ -40,4 +61,58 @@ const updateRole = async ({payload, roleId, ...config}: UpdateRoleConfig): Promi operation: 'updateRole', }); +/** + * Updates a role. In `flow` mode an update that removes a permission runs the configured permission + * removal flow, which revokes the scopes the permissions granted; the role's other fields are written + * first, so a rejected rename fails before anything is revoked. + */ +const updateRole = async ({ + administrationMode, + current, + payload, + roleId, + ...config +}: UpdateRoleConfig): Promise => { + if (administrationMode !== AdministrationModes.FLOW) { + return putRole(config, roleId, payload); + } + + const before: Role = current ?? (await getRole({...config, roleId})); + + if (!removesPermission(before.permissions, payload.permissions)) { + return putRole(config, roleId, payload); + } + + const flowId: string | null = await resolveAdministrationFlowId( + config, + AdministrationFlowConfigKey.ROLE_PERMISSION_REMOVAL, + 'role permission change', + ); + + if (flowId === null) { + return putRole(config, roleId, payload); + } + + // The flow writes only the permissions, so the other fields go first. + const otherFieldsChanged: boolean = + payload.name !== before.name || + (payload.description ?? '') !== (before.description ?? '') || + payload.ouId !== before.ouId; + const updated: Role = otherFieldsChanged + ? await putRole(config, roleId, {...payload, permissions: before.permissions ?? []}) + : before; + + await executeAdministrationFlow( + config, + flowId, + { + [AdministrationFlowInput.PERMISSIONS]: JSON.stringify(payload.permissions ?? []), + [AdministrationFlowInput.ROLE]: roleId, + }, + 'role permission change', + ); + + return {...updated, permissions: payload.permissions ?? []}; +}; + export default updateRole; diff --git a/packages/javascript/src/api/management/secrets/createSecret.ts b/packages/javascript/src/api/management/secrets/createSecret.ts new file mode 100644 index 00000000..1655a31f --- /dev/null +++ b/packages/javascript/src/api/management/secrets/createSecret.ts @@ -0,0 +1,39 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ManagementRequestConfig} from '../../../models/api'; +import {Secret, SecretRequest} from '../../../models/variable-store'; +import requestManagementResource from '../../../utils/requestManagementResource'; + +/** + * Configuration for the createSecret request. + */ +export interface CreateSecretConfig extends ManagementRequestConfig { + /** + * The secret to add. + */ + payload: SecretRequest; +} + +/** + * Adds a secret. The value is write-only: the server only says whether one is held, and the SDK never logs + * it. + * + * @param config - Request configuration object. + * @returns A promise that resolves with the secret as the server stored it. + * + * @example + * ```typescript + * const secret = await createSecret({baseUrl: 'https://localhost:8090', payload: {name: 'SMTP_HOST', value}}); + * ``` + */ +const createSecret = async ({payload, ...config}: CreateSecretConfig): Promise => + requestManagementResource(config, { + body: payload, + collection: 'secrets', + failureMessage: 'Failed to add secret', + method: 'POST', + operation: 'createSecret', + }); + +export default createSecret; diff --git a/packages/javascript/src/api/management/secrets/deleteSecret.ts b/packages/javascript/src/api/management/secrets/deleteSecret.ts new file mode 100644 index 00000000..18f8f834 --- /dev/null +++ b/packages/javascript/src/api/management/secrets/deleteSecret.ts @@ -0,0 +1,38 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ManagementRequestConfig} from '../../../models/api'; +import requestManagementResource from '../../../utils/requestManagementResource'; + +/** + * Configuration for the deleteSecret request. + */ +export interface DeleteSecretConfig extends ManagementRequestConfig { + /** + * Name of the secret. + */ + name: string; +} + +/** + * Deletes a secret. + * + * @param config - Request configuration object. + * @returns A promise that resolves once the server has made the change. + * + * @example + * ```typescript + * await deleteSecret({baseUrl: 'https://localhost:8090', name: 'SMTP_HOST'}); + * ``` + */ +const deleteSecret = async ({name, ...config}: DeleteSecretConfig): Promise => { + await requestManagementResource(config, { + collection: 'secrets', + failureMessage: 'Failed to delete secret', + id: name, + method: 'DELETE', + operation: 'deleteSecret', + }); +}; + +export default deleteSecret; diff --git a/packages/javascript/src/api/management/secrets/getSecret.ts b/packages/javascript/src/api/management/secrets/getSecret.ts new file mode 100644 index 00000000..2bb47502 --- /dev/null +++ b/packages/javascript/src/api/management/secrets/getSecret.ts @@ -0,0 +1,39 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ManagementRequestConfig} from '../../../models/api'; +import {Secret} from '../../../models/variable-store'; +import requestManagementResource from '../../../utils/requestManagementResource'; + +/** + * Configuration for the getSecret request. + */ +export interface GetSecretConfig extends ManagementRequestConfig { + /** + * Name of the secret. + */ + name: string; +} + +/** + * Retrieves a secret. The value is write-only: the server only says whether one is held, and the SDK never + * logs it. + * + * @param config - Request configuration object. + * @returns A promise that resolves with the secret. + * + * @example + * ```typescript + * const secret = await getSecret({baseUrl: 'https://localhost:8090', name: 'SMTP_HOST'}); + * ``` + */ +const getSecret = async ({name, ...config}: GetSecretConfig): Promise => + requestManagementResource(config, { + collection: 'secrets', + failureMessage: 'Failed to fetch secret', + id: name, + method: 'GET', + operation: 'getSecret', + }); + +export default getSecret; diff --git a/packages/javascript/src/api/management/secrets/getSecrets.ts b/packages/javascript/src/api/management/secrets/getSecrets.ts new file mode 100644 index 00000000..b50b1ab6 --- /dev/null +++ b/packages/javascript/src/api/management/secrets/getSecrets.ts @@ -0,0 +1,39 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ApiFilteringParams, ManagementRequestConfig} from '../../../models/api'; +import {SecretListResponse} from '../../../models/variable-store'; +import requestManagementResource from '../../../utils/requestManagementResource'; + +/** + * Configuration for the getSecrets request. + */ +export interface GetSecretsConfig extends ManagementRequestConfig, ApiFilteringParams { + /** + * Returns only these names (at most 100). A name with nothing stored is absent from the result. + */ + names?: string[]; +} + +/** + * Retrieves a page of secrets. The value is write-only: the server only says whether one is held, and the + * SDK never logs it. + * + * @param config - Request configuration object. + * @returns A promise that resolves with the page the server returned. + * + * @example + * ```typescript + * const {secrets} = await getSecrets({baseUrl: 'https://localhost:8090', names: ['SMTP_HOST']}); + * ``` + */ +const getSecrets = async ({filter, limit, names, offset, ...config}: GetSecretsConfig): Promise => + requestManagementResource(config, { + collection: 'secrets', + failureMessage: 'Failed to fetch secrets', + method: 'GET', + operation: 'getSecrets', + query: {filter, limit, names: names?.join(','), offset}, + }); + +export default getSecrets; diff --git a/packages/javascript/src/api/management/secrets/updateSecret.ts b/packages/javascript/src/api/management/secrets/updateSecret.ts new file mode 100644 index 00000000..d1e870ec --- /dev/null +++ b/packages/javascript/src/api/management/secrets/updateSecret.ts @@ -0,0 +1,44 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ManagementRequestConfig} from '../../../models/api'; +import {Secret, SecretUpdateRequest} from '../../../models/variable-store'; +import requestManagementResource from '../../../utils/requestManagementResource'; + +/** + * Configuration for the updateSecret request. + */ +export interface UpdateSecretConfig extends ManagementRequestConfig { + /** + * Name of the secret. + */ + name: string; + /** + * The secret's new value. + */ + payload: SecretUpdateRequest; +} + +/** + * Sets or replaces a secret, creating it when it does not exist. The value is write-only: the server only + * says whether one is held, and the SDK never logs it. + * + * @param config - Request configuration object. + * @returns A promise that resolves with the secret as the server stored it. + * + * @example + * ```typescript + * const secret = await updateSecret({baseUrl: 'https://localhost:8090', name: 'SMTP_HOST', payload: {value}}); + * ``` + */ +const updateSecret = async ({name, payload, ...config}: UpdateSecretConfig): Promise => + requestManagementResource(config, { + body: payload, + collection: 'secrets', + failureMessage: 'Failed to update secret', + id: name, + method: 'PUT', + operation: 'updateSecret', + }); + +export default updateSecret; diff --git a/packages/javascript/src/api/management/server-config/getServerConfig.ts b/packages/javascript/src/api/management/server-config/getServerConfig.ts new file mode 100644 index 00000000..e5e9125c --- /dev/null +++ b/packages/javascript/src/api/management/server-config/getServerConfig.ts @@ -0,0 +1,38 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ManagementRequestConfig} from '../../../models/api'; +import {ServerConfigLayers} from '../../../models/server-config'; +import requestManagementResource from '../../../utils/requestManagementResource'; + +/** + * Configuration for the getServerConfig request. + */ +export interface GetServerConfigConfig extends ManagementRequestConfig { + /** + * Name of the configuration section, such as `flow`. + */ + name: string; +} + +/** + * Retrieves a server configuration section in its layers. `merged` is what the server applies. + * + * @param config - Request configuration object. + * @returns A promise that resolves with the section. + * + * @example + * ```typescript + * const {merged} = await getServerConfig<{defaultEmailSenderId?: string}>({baseUrl: 'https://localhost:8090', name: 'notification'}); + * ``` + */ +const getServerConfig = async ({name, ...config}: GetServerConfigConfig): Promise> => + requestManagementResource>(config, { + collection: 'server-config', + failureMessage: 'Failed to fetch server configuration section', + id: name, + method: 'GET', + operation: 'getServerConfig', + }); + +export default getServerConfig; diff --git a/packages/javascript/src/api/management/server-config/getServerConfigs.ts b/packages/javascript/src/api/management/server-config/getServerConfigs.ts new file mode 100644 index 00000000..cfc6f75a --- /dev/null +++ b/packages/javascript/src/api/management/server-config/getServerConfigs.ts @@ -0,0 +1,31 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ManagementRequestConfig} from '../../../models/api'; +import requestManagementResource from '../../../utils/requestManagementResource'; + +/** + * Configuration for the getServerConfigs request. + */ +export type GetServerConfigsConfig = ManagementRequestConfig; + +/** + * Retrieves the names of the server configuration sections. + * + * @param config - Request configuration object. + * @returns A promise that resolves with the section names. + * + * @example + * ```typescript + * const sections = await getServerConfigs({baseUrl: 'https://localhost:8090'}); + * ``` + */ +const getServerConfigs = async (config: GetServerConfigsConfig): Promise => + requestManagementResource(config, { + collection: 'server-config', + failureMessage: 'Failed to fetch server configuration', + method: 'GET', + operation: 'getServerConfigs', + }); + +export default getServerConfigs; diff --git a/packages/javascript/src/api/management/server-config/updateServerConfig.ts b/packages/javascript/src/api/management/server-config/updateServerConfig.ts new file mode 100644 index 00000000..bfc219be --- /dev/null +++ b/packages/javascript/src/api/management/server-config/updateServerConfig.ts @@ -0,0 +1,51 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ManagementRequestConfig} from '../../../models/api'; +import {ServerConfigLayers} from '../../../models/server-config'; +import requestManagementResource from '../../../utils/requestManagementResource'; + +/** + * Configuration for the updateServerConfig request. + */ +export type UpdateServerConfigConfig = ManagementRequestConfig & { + /** + * Name of the configuration section, such as `flow`. + */ + name: string; + /** + * The section's new writable value. + */ + payload: T; +}; + +/** + * Updates the writable layer of a server configuration section. + * + * @param config - Request configuration object. + * @returns A promise that resolves with the section. + * + * @example + * ```typescript + * const {merged} = await updateServerConfig({ + * baseUrl: 'https://localhost:8090', + * name: 'notification', + * payload: {defaultEmailSenderId: connectionId}, + * }); + * ``` + */ +const updateServerConfig = async ({ + name, + payload, + ...config +}: UpdateServerConfigConfig): Promise> => + requestManagementResource>(config, { + body: payload, + collection: 'server-config', + failureMessage: 'Failed to update server configuration section', + id: name, + method: 'PUT', + operation: 'updateServerConfig', + }); + +export default updateServerConfig; diff --git a/packages/javascript/src/api/management/user-types/createUserType.ts b/packages/javascript/src/api/management/user-types/createUserType.ts new file mode 100644 index 00000000..98be19e1 --- /dev/null +++ b/packages/javascript/src/api/management/user-types/createUserType.ts @@ -0,0 +1,27 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ManagementRequestConfig} from '../../../models/api'; +import {CreateUserTypeRequest, ManagedUserTypeDetails} from '../../../models/entity-type'; +import requestManagementResource from '../../../utils/requestManagementResource'; + +export interface CreateUserTypeConfig extends ManagementRequestConfig { + /** + * The user type to create. + */ + payload: CreateUserTypeRequest; +} + +/** + * Creates a user type. + */ +const createUserType = async ({payload, ...config}: CreateUserTypeConfig): Promise => + requestManagementResource(config, { + body: payload, + collection: 'user-types', + failureMessage: 'Failed to create user type', + method: 'POST', + operation: 'createUserType', + }); + +export default createUserType; diff --git a/packages/javascript/src/api/management/user-types/deleteUserType.ts b/packages/javascript/src/api/management/user-types/deleteUserType.ts new file mode 100644 index 00000000..a74216c9 --- /dev/null +++ b/packages/javascript/src/api/management/user-types/deleteUserType.ts @@ -0,0 +1,27 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ManagementRequestConfig} from '../../../models/api'; +import requestManagementResource from '../../../utils/requestManagementResource'; + +export interface DeleteUserTypeConfig extends ManagementRequestConfig { + /** + * Identifier of the user type to delete. + */ + userTypeId: string; +} + +/** + * Deletes a user type. + */ +const deleteUserType = async ({userTypeId, ...config}: DeleteUserTypeConfig): Promise => { + await requestManagementResource(config, { + collection: 'user-types', + failureMessage: 'Failed to delete user type', + id: userTypeId, + method: 'DELETE', + operation: 'deleteUserType', + }); +}; + +export default deleteUserType; diff --git a/packages/javascript/src/api/management/user-types/getUserType.ts b/packages/javascript/src/api/management/user-types/getUserType.ts new file mode 100644 index 00000000..e9edd649 --- /dev/null +++ b/packages/javascript/src/api/management/user-types/getUserType.ts @@ -0,0 +1,28 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ManagementRequestConfig} from '../../../models/api'; +import {ManagedUserTypeDetails} from '../../../models/entity-type'; +import requestManagementResource from '../../../utils/requestManagementResource'; + +export interface GetUserTypeConfig extends ManagementRequestConfig { + /** + * Identifier of the user type. + */ + userTypeId: string; +} + +/** + * Fetches a user type with its attribute schema. + */ +const getUserType = async ({userTypeId, ...config}: GetUserTypeConfig): Promise => + requestManagementResource(config, { + collection: 'user-types', + failureMessage: 'Failed to fetch user type', + id: userTypeId, + method: 'GET', + operation: 'getUserType', + query: {include: 'display'}, + }); + +export default getUserType; diff --git a/packages/javascript/src/api/management/user-types/getUserTypes.ts b/packages/javascript/src/api/management/user-types/getUserTypes.ts index b57827e0..4c13baa4 100644 --- a/packages/javascript/src/api/management/user-types/getUserTypes.ts +++ b/packages/javascript/src/api/management/user-types/getUserTypes.ts @@ -30,7 +30,7 @@ const getUserTypes = async ({limit, offset, ...config}: GetUserTypesConfig): Pro failureMessage: 'Failed to fetch user types', method: 'GET', operation: 'getUserTypes', - query: {limit, offset}, + query: {include: 'display', limit, offset}, }); export default getUserTypes; diff --git a/packages/javascript/src/api/management/user-types/updateUserType.ts b/packages/javascript/src/api/management/user-types/updateUserType.ts new file mode 100644 index 00000000..fe48fa8b --- /dev/null +++ b/packages/javascript/src/api/management/user-types/updateUserType.ts @@ -0,0 +1,36 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ManagementRequestConfig} from '../../../models/api'; +import {ManagedUserTypeDetails, UpdateUserTypeRequest} from '../../../models/entity-type'; +import requestManagementResource from '../../../utils/requestManagementResource'; + +export interface UpdateUserTypeConfig extends ManagementRequestConfig { + /** + * The user type's new state. + */ + payload: UpdateUserTypeRequest; + /** + * Identifier of the user type. + */ + userTypeId: string; +} + +/** + * Updates a user type. + */ +const updateUserType = async ({ + payload, + userTypeId, + ...config +}: UpdateUserTypeConfig): Promise => + requestManagementResource(config, { + body: payload, + collection: 'user-types', + failureMessage: 'Failed to update user type', + id: userTypeId, + method: 'PUT', + operation: 'updateUserType', + }); + +export default updateUserType; diff --git a/packages/javascript/src/api/management/users/createUserByPath.ts b/packages/javascript/src/api/management/users/createUserByPath.ts new file mode 100644 index 00000000..4d6096e6 --- /dev/null +++ b/packages/javascript/src/api/management/users/createUserByPath.ts @@ -0,0 +1,50 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ManagementRequestConfig} from '../../../models/api'; +import {CreateUserByPathRequest, ManagedUser} from '../../../models/managed-user'; +import encodeHandlePath from '../../../utils/encodeHandlePath'; +import requestManagementResource from '../../../utils/requestManagementResource'; + +/** + * Configuration for the createUserByPath request. + */ +export interface CreateUserByPathConfig extends ManagementRequestConfig { + /** + * Handle path of the organization unit, such as `engineering/platform`. Each handle is encoded and the + * slashes between them are kept. + */ + path: string; + /** + * The user to create. + */ + payload: CreateUserByPathRequest; +} + +/** + * Creates a user in the organization unit at a handle path. + * + * @param config - Request configuration object. + * @returns A promise that resolves with the user as the server stored it. + * + * @example + * ```typescript + * const user = await createUserByPath({ + * baseUrl: 'https://localhost:8090', + * path: 'engineering/platform', + * payload: {type: 'customer', attributes: {username: 'alice'}}, + * }); + * ``` + */ +const createUserByPath = async ({path, payload, ...config}: CreateUserByPathConfig): Promise => + requestManagementResource(config, { + body: payload, + collection: 'users', + failureMessage: 'Failed to create user', + id: 'tree', + method: 'POST', + operation: 'createUserByPath', + subPath: encodeHandlePath('createUserByPath', path), + }); + +export default createUserByPath; diff --git a/packages/javascript/src/api/management/users/createUserSharingPolicy.ts b/packages/javascript/src/api/management/users/createUserSharingPolicy.ts new file mode 100644 index 00000000..8b5beaa4 --- /dev/null +++ b/packages/javascript/src/api/management/users/createUserSharingPolicy.ts @@ -0,0 +1,52 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ManagementRequestConfig} from '../../../models/api'; +import {SharingPolicy, SharingPolicyRequest} from '../../../models/sharing-policy'; +import requestManagementResource from '../../../utils/requestManagementResource'; + +/** + * Configuration for the createUserSharingPolicy request. + */ +export interface CreateUserSharingPolicyConfig extends ManagementRequestConfig { + /** + * The policy to create. + */ + payload: SharingPolicyRequest; + /** + * Identifier of the user. + */ + userId: string; +} + +/** + * Creates a sharing policy for a user: the organization units it reaches beyond its owner. + * + * @param config - Request configuration object. + * @returns A promise that resolves with the policy as the server stored it. + * + * @example + * ```typescript + * const policy = await createUserSharingPolicy({ + * baseUrl: 'https://localhost:8090', + * userId, + * payload: {childUnits: {mode: 'all'}}, + * }); + * ``` + */ +const createUserSharingPolicy = async ({ + payload, + userId, + ...config +}: CreateUserSharingPolicyConfig): Promise => + requestManagementResource(config, { + body: payload, + collection: 'users', + failureMessage: 'Failed to create sharing policy', + id: userId, + method: 'POST', + operation: 'createUserSharingPolicy', + subPath: 'sharing-policies', + }); + +export default createUserSharingPolicy; diff --git a/packages/javascript/src/api/management/users/deleteUser.ts b/packages/javascript/src/api/management/users/deleteUser.ts index 040b7a6a..bf32c1ff 100644 --- a/packages/javascript/src/api/management/users/deleteUser.ts +++ b/packages/javascript/src/api/management/users/deleteUser.ts @@ -1,13 +1,16 @@ -// Copyright 2025-2026 The ThunderID Authors +// Copyright 2026 The ThunderID Authors // SPDX-License-Identifier: Apache-2.0 +import AdministrationModes, { + AdministrationFlowConfigKey, + AdministrationFlowInput, +} from '../../../constants/AdministrationConstants'; +import {AdministrationOptions} from '../../../models/administration'; import {ManagementRequestConfig} from '../../../models/api'; import requestManagementResource from '../../../utils/requestManagementResource'; +import runAdministrationFlow from '../administration/runAdministrationFlow'; -/** - * Configuration for the deleteUser request. - */ -export interface DeleteUserConfig extends ManagementRequestConfig { +export interface DeleteUserConfig extends ManagementRequestConfig, AdministrationOptions { /** * Identifier of the user to delete. */ @@ -15,26 +18,30 @@ export interface DeleteUserConfig extends ManagementRequestConfig { } /** - * Deletes a user. - * - * @param config - Request configuration object. - * @returns A promise that resolves once the user is deleted. - * - * @example - * ```typescript - * await deleteUser({ - * baseUrl: 'https://localhost:8090', - * userId: '9a475e1e-b0cb-4b29-8df5-2e5b24fb0ed3', - * }); - * ``` + * Deletes a user. In `flow` mode the configured user deletion flow runs instead, revoking the user's + * grants and ending their sessions before the record goes. */ -const deleteUser = async ({userId, ...config}: DeleteUserConfig): Promise => - requestManagementResource(config, { +const deleteUser = async ({administrationMode, userId, ...config}: DeleteUserConfig): Promise => { + if (administrationMode === AdministrationModes.FLOW) { + const result: Record | null = await runAdministrationFlow( + config, + AdministrationFlowConfigKey.USER_DELETION, + {[AdministrationFlowInput.SUBJECT]: userId}, + 'user deletion', + ); + + if (result !== null) { + return; + } + } + + await requestManagementResource(config, { collection: 'users', failureMessage: 'Failed to delete user', id: userId, method: 'DELETE', operation: 'deleteUser', }); +}; export default deleteUser; diff --git a/packages/javascript/src/api/management/users/deleteUserSharingPolicy.ts b/packages/javascript/src/api/management/users/deleteUserSharingPolicy.ts new file mode 100644 index 00000000..924db7e8 --- /dev/null +++ b/packages/javascript/src/api/management/users/deleteUserSharingPolicy.ts @@ -0,0 +1,44 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ManagementRequestConfig} from '../../../models/api'; +import encodePathSegments from '../../../utils/encodePathSegments'; +import requestManagementResource from '../../../utils/requestManagementResource'; + +/** + * Configuration for the deleteUserSharingPolicy request. + */ +export interface DeleteUserSharingPolicyConfig extends ManagementRequestConfig { + /** + * Identifier of the sharing policy. + */ + policyId: string; + /** + * Identifier of the user. + */ + userId: string; +} + +/** + * Deletes a sharing policy of a user. + * + * @param config - Request configuration object. + * @returns A promise that resolves once the server has made the change. + * + * @example + * ```typescript + * await deleteUserSharingPolicy({baseUrl: 'https://localhost:8090', userId, policyId}); + * ``` + */ +const deleteUserSharingPolicy = async ({policyId, userId, ...config}: DeleteUserSharingPolicyConfig): Promise => { + await requestManagementResource(config, { + collection: 'users', + failureMessage: 'Failed to delete sharing policy', + id: userId, + method: 'DELETE', + operation: 'deleteUserSharingPolicy', + subPath: `sharing-policies/${encodePathSegments('deleteUserSharingPolicy', [policyId])}`, + }); +}; + +export default deleteUserSharingPolicy; diff --git a/packages/javascript/src/api/management/users/getUserOverlayRules.ts b/packages/javascript/src/api/management/users/getUserOverlayRules.ts new file mode 100644 index 00000000..c8688075 --- /dev/null +++ b/packages/javascript/src/api/management/users/getUserOverlayRules.ts @@ -0,0 +1,44 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ManagementRequestConfig} from '../../../models/api'; +import {ResolvedOverlay} from '../../../models/sharing-policy'; +import requestManagementResource from '../../../utils/requestManagementResource'; + +/** + * Configuration for the getUserOverlayRules request. + */ +export interface GetUserOverlayRulesConfig extends ManagementRequestConfig { + /** + * Identifier of the organization unit to resolve the rules for. + */ + ouId: string; + /** + * Identifier of the user. + */ + userId: string; +} + +/** + * Retrieves the overlay rules that apply to a user in an organization unit. + * + * @param config - Request configuration object. + * @returns A promise that resolves with the resolved overlay. + * + * @example + * ```typescript + * const {rules} = await getUserOverlayRules({baseUrl: 'https://localhost:8090', userId, ouId}); + * ``` + */ +const getUserOverlayRules = async ({ouId, userId, ...config}: GetUserOverlayRulesConfig): Promise => + requestManagementResource(config, { + collection: 'users', + failureMessage: 'Failed to fetch overlay rules', + id: userId, + method: 'GET', + operation: 'getUserOverlayRules', + query: {ouId}, + subPath: 'overlay-rules', + }); + +export default getUserOverlayRules; diff --git a/packages/javascript/src/api/management/users/getUserSharingPolicies.ts b/packages/javascript/src/api/management/users/getUserSharingPolicies.ts new file mode 100644 index 00000000..88cfe131 --- /dev/null +++ b/packages/javascript/src/api/management/users/getUserSharingPolicies.ts @@ -0,0 +1,47 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ApiFilteringParams, ManagementRequestConfig} from '../../../models/api'; +import {SharingPolicyListResponse} from '../../../models/sharing-policy'; +import requestManagementResource from '../../../utils/requestManagementResource'; + +/** + * Configuration for the getUserSharingPolicies request. + */ +export interface GetUserSharingPoliciesConfig + extends ManagementRequestConfig, + Pick { + /** + * Identifier of the user. + */ + userId: string; +} + +/** + * Retrieves a page of the sharing policies of a user. + * + * @param config - Request configuration object. + * @returns A promise that resolves with the page the server returned. + * + * @example + * ```typescript + * const {policies} = await getUserSharingPolicies({baseUrl: 'https://localhost:8090', userId}); + * ``` + */ +const getUserSharingPolicies = async ({ + limit, + offset, + userId, + ...config +}: GetUserSharingPoliciesConfig): Promise => + requestManagementResource(config, { + collection: 'users', + failureMessage: 'Failed to fetch sharing policies', + id: userId, + method: 'GET', + operation: 'getUserSharingPolicies', + query: {limit, offset}, + subPath: 'sharing-policies', + }); + +export default getUserSharingPolicies; diff --git a/packages/javascript/src/api/management/users/getUserSharingPolicy.ts b/packages/javascript/src/api/management/users/getUserSharingPolicy.ts new file mode 100644 index 00000000..0a7261e9 --- /dev/null +++ b/packages/javascript/src/api/management/users/getUserSharingPolicy.ts @@ -0,0 +1,48 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ManagementRequestConfig} from '../../../models/api'; +import {SharingPolicy} from '../../../models/sharing-policy'; +import encodePathSegments from '../../../utils/encodePathSegments'; +import requestManagementResource from '../../../utils/requestManagementResource'; + +/** + * Configuration for the getUserSharingPolicy request. + */ +export interface GetUserSharingPolicyConfig extends ManagementRequestConfig { + /** + * Identifier of the sharing policy. + */ + policyId: string; + /** + * Identifier of the user. + */ + userId: string; +} + +/** + * Retrieves a sharing policy of a user. + * + * @param config - Request configuration object. + * @returns A promise that resolves with the policy. + * + * @example + * ```typescript + * const policy = await getUserSharingPolicy({baseUrl: 'https://localhost:8090', userId, policyId}); + * ``` + */ +const getUserSharingPolicy = async ({ + policyId, + userId, + ...config +}: GetUserSharingPolicyConfig): Promise => + requestManagementResource(config, { + collection: 'users', + failureMessage: 'Failed to fetch sharing policy', + id: userId, + method: 'GET', + operation: 'getUserSharingPolicy', + subPath: `sharing-policies/${encodePathSegments('getUserSharingPolicy', [policyId])}`, + }); + +export default getUserSharingPolicy; diff --git a/packages/javascript/src/api/management/users/getUserUsages.ts b/packages/javascript/src/api/management/users/getUserUsages.ts new file mode 100644 index 00000000..0b9f2328 --- /dev/null +++ b/packages/javascript/src/api/management/users/getUserUsages.ts @@ -0,0 +1,29 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ManagementRequestConfig} from '../../../models/api'; +import {UserUsagesResponse} from '../../../models/managed-user'; +import requestManagementResource from '../../../utils/requestManagementResource'; + +export interface GetUserUsagesConfig extends ManagementRequestConfig { + /** + * Identifier of the user. + */ + userId: string; +} + +/** + * Lists the resources that reference a user, such as agents that name the user as their owner, and + * what happens to each when the user is deleted. + */ +const getUserUsages = async ({userId, ...config}: GetUserUsagesConfig): Promise => + requestManagementResource(config, { + collection: 'users', + failureMessage: 'Failed to fetch user usages', + id: userId, + method: 'GET', + operation: 'getUserUsages', + subPath: 'usages', + }); + +export default getUserUsages; diff --git a/packages/javascript/src/api/management/users/getUsersByPath.ts b/packages/javascript/src/api/management/users/getUsersByPath.ts new file mode 100644 index 00000000..0a8e82a7 --- /dev/null +++ b/packages/javascript/src/api/management/users/getUsersByPath.ts @@ -0,0 +1,48 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ApiFilteringParams, ManagementRequestConfig} from '../../../models/api'; +import {ManagedUserListResponse} from '../../../models/managed-user'; +import encodeHandlePath from '../../../utils/encodeHandlePath'; +import requestManagementResource from '../../../utils/requestManagementResource'; + +/** + * Configuration for the getUsersByPath request. + */ +export interface GetUsersByPathConfig extends ManagementRequestConfig, Pick { + /** + * Handle path of the organization unit, such as `engineering/platform`. Each handle is encoded and the + * slashes between them are kept. + */ + path: string; +} + +/** + * Retrieves a page of the users in the organization unit at a handle path. Each user carries its resolved + * `display` value. + * + * @param config - Request configuration object. + * @returns A promise that resolves with the page the server returned. + * + * @example + * ```typescript + * const {users} = await getUsersByPath({baseUrl: 'https://localhost:8090', path: 'engineering/platform', limit: 20}); + * ``` + */ +const getUsersByPath = async ({ + limit, + offset, + path, + ...config +}: GetUsersByPathConfig): Promise => + requestManagementResource(config, { + collection: 'users', + failureMessage: 'Failed to fetch users', + id: 'tree', + method: 'GET', + operation: 'getUsersByPath', + query: {include: 'display', limit, offset}, + subPath: encodeHandlePath('getUsersByPath', path), + }); + +export default getUsersByPath; diff --git a/packages/javascript/src/api/management/users/suspendUser.ts b/packages/javascript/src/api/management/users/suspendUser.ts new file mode 100644 index 00000000..ff8c2967 --- /dev/null +++ b/packages/javascript/src/api/management/users/suspendUser.ts @@ -0,0 +1,43 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {AdministrationFlowConfigKey} from '../../../constants/AdministrationConstants'; +import {AccountLifecycleOptions, AccountLifecycleResult} from '../../../models/administration'; +import {ManagementRequestConfig} from '../../../models/api'; +import runAccountLifecycleFlow from '../administration/runAccountLifecycleFlow'; + +/** + * Configuration for the suspendUser request. + */ +export interface SuspendUserConfig extends ManagementRequestConfig, AccountLifecycleOptions { + /** + * Identifier of the user. + */ + userId: string; +} + +/** + * Suspends a user, so they can no longer sign in, through the user suspension flow the server configures. + * The flow can end the user's sessions and notify them. There is no management API endpoint for this, so + * when no flow is configured the call fails with `suspendUser-NotConfiguredError-001`. + * + * @param config - Request configuration object. Running a flow needs `baseUrl`, and a token that may read + * the server configuration and run administration flows. + * @returns A promise that resolves with whether the flow's email step sent its notification, absent when + * the flow has no email step. + * + * @example + * ```typescript + * const {emailSent} = await suspendUser({baseUrl: 'https://localhost:8090', userId, operatorNote: 'Ticket 4521'}); + * ``` + */ +const suspendUser = async ({userId, operatorNote, ...config}: SuspendUserConfig): Promise => + runAccountLifecycleFlow(config, { + action: 'user suspension', + configKey: AdministrationFlowConfigKey.USER_SUSPEND, + operation: 'suspendUser', + operatorNote, + subject: userId, + }); + +export default suspendUser; diff --git a/packages/javascript/src/api/management/users/unlockUser.ts b/packages/javascript/src/api/management/users/unlockUser.ts new file mode 100644 index 00000000..b199f70b --- /dev/null +++ b/packages/javascript/src/api/management/users/unlockUser.ts @@ -0,0 +1,43 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {AdministrationFlowConfigKey} from '../../../constants/AdministrationConstants'; +import {AccountLifecycleOptions, AccountLifecycleResult} from '../../../models/administration'; +import {ManagementRequestConfig} from '../../../models/api'; +import runAccountLifecycleFlow from '../administration/runAccountLifecycleFlow'; + +/** + * Configuration for the unlockUser request. + */ +export interface UnlockUserConfig extends ManagementRequestConfig, AccountLifecycleOptions { + /** + * Identifier of the user. + */ + userId: string; +} + +/** + * Unlocks a user locked out after repeated failed sign-in attempts, through the user unlock flow the server + * configures. There is no management API endpoint for this, so when no flow is configured the call fails + * with `unlockUser-NotConfiguredError-001`. + * + * @param config - Request configuration object. Running a flow needs `baseUrl`, and a token that may read + * the server configuration and run administration flows. + * @returns A promise that resolves with whether the flow's email step sent its notification, absent when + * the flow has no email step. + * + * @example + * ```typescript + * const {emailSent} = await unlockUser({baseUrl: 'https://localhost:8090', userId, operatorNote: 'Ticket 4521'}); + * ``` + */ +const unlockUser = async ({userId, operatorNote, ...config}: UnlockUserConfig): Promise => + runAccountLifecycleFlow(config, { + action: 'user unlock', + configKey: AdministrationFlowConfigKey.USER_UNLOCK, + operation: 'unlockUser', + operatorNote, + subject: userId, + }); + +export default unlockUser; diff --git a/packages/javascript/src/api/management/users/unsuspendUser.ts b/packages/javascript/src/api/management/users/unsuspendUser.ts new file mode 100644 index 00000000..d18df4ab --- /dev/null +++ b/packages/javascript/src/api/management/users/unsuspendUser.ts @@ -0,0 +1,43 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {AdministrationFlowConfigKey} from '../../../constants/AdministrationConstants'; +import {AccountLifecycleOptions, AccountLifecycleResult} from '../../../models/administration'; +import {ManagementRequestConfig} from '../../../models/api'; +import runAccountLifecycleFlow from '../administration/runAccountLifecycleFlow'; + +/** + * Configuration for the unsuspendUser request. + */ +export interface UnsuspendUserConfig extends ManagementRequestConfig, AccountLifecycleOptions { + /** + * Identifier of the user. + */ + userId: string; +} + +/** + * Lifts the suspension of a user through the user unsuspension flow the server configures. There is no + * management API endpoint for this, so when no flow is configured the call fails with + * `unsuspendUser-NotConfiguredError-001`. + * + * @param config - Request configuration object. Running a flow needs `baseUrl`, and a token that may read + * the server configuration and run administration flows. + * @returns A promise that resolves with whether the flow's email step sent its notification, absent when + * the flow has no email step. + * + * @example + * ```typescript + * const {emailSent} = await unsuspendUser({baseUrl: 'https://localhost:8090', userId, operatorNote: 'Ticket 4521'}); + * ``` + */ +const unsuspendUser = async ({userId, operatorNote, ...config}: UnsuspendUserConfig): Promise => + runAccountLifecycleFlow(config, { + action: 'user unsuspension', + configKey: AdministrationFlowConfigKey.USER_UNSUSPEND, + operation: 'unsuspendUser', + operatorNote, + subject: userId, + }); + +export default unsuspendUser; diff --git a/packages/javascript/src/api/management/users/updateUserCredentials.ts b/packages/javascript/src/api/management/users/updateUserCredentials.ts new file mode 100644 index 00000000..9f02d811 --- /dev/null +++ b/packages/javascript/src/api/management/users/updateUserCredentials.ts @@ -0,0 +1,34 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ManagementRequestConfig} from '../../../models/api'; +import {UpdateUserCredentialsRequest} from '../../../models/managed-user'; +import requestManagementResource from '../../../utils/requestManagementResource'; + +export interface UpdateUserCredentialsConfig extends ManagementRequestConfig { + /** + * The new credential values, keyed by attribute name. + */ + payload: UpdateUserCredentialsRequest; + /** + * Identifier of the user. + */ + userId: string; +} + +/** + * Sets new values for some of a user's credential attributes, such as the password. + */ +const updateUserCredentials = async ({payload, userId, ...config}: UpdateUserCredentialsConfig): Promise => { + await requestManagementResource(config, { + body: payload, + collection: 'users', + failureMessage: 'Failed to update user credentials', + id: userId, + method: 'POST', + operation: 'updateUserCredentials', + subPath: 'update-credentials', + }); +}; + +export default updateUserCredentials; diff --git a/packages/javascript/src/api/management/users/updateUserSharingPolicy.ts b/packages/javascript/src/api/management/users/updateUserSharingPolicy.ts new file mode 100644 index 00000000..fd28e92a --- /dev/null +++ b/packages/javascript/src/api/management/users/updateUserSharingPolicy.ts @@ -0,0 +1,60 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ManagementRequestConfig} from '../../../models/api'; +import {SharingPolicy, SharingPolicyUpdateRequest} from '../../../models/sharing-policy'; +import encodePathSegments from '../../../utils/encodePathSegments'; +import requestManagementResource from '../../../utils/requestManagementResource'; + +/** + * Configuration for the updateUserSharingPolicy request. + */ +export interface UpdateUserSharingPolicyConfig extends ManagementRequestConfig { + /** + * The policy's new sections and the version being edited. + */ + payload: SharingPolicyUpdateRequest; + /** + * Identifier of the sharing policy. + */ + policyId: string; + /** + * Identifier of the user. + */ + userId: string; +} + +/** + * Replaces the sections of a sharing policy of a user. The payload names the version it edits, and an edit + * against any other version is refused. + * + * @param config - Request configuration object. + * @returns A promise that resolves with the policy as the server stored it. + * + * @example + * ```typescript + * const policy = await updateUserSharingPolicy({ + * baseUrl: 'https://localhost:8090', + * userId, + * policyId, + * payload: {childUnits: {mode: 'all', excludedOuIds: [ouId]}, version: 1}, + * }); + * ``` + */ +const updateUserSharingPolicy = async ({ + payload, + policyId, + userId, + ...config +}: UpdateUserSharingPolicyConfig): Promise => + requestManagementResource(config, { + body: payload, + collection: 'users', + failureMessage: 'Failed to update sharing policy', + id: userId, + method: 'PUT', + operation: 'updateUserSharingPolicy', + subPath: `sharing-policies/${encodePathSegments('updateUserSharingPolicy', [policyId])}`, + }); + +export default updateUserSharingPolicy; diff --git a/packages/javascript/src/api/management/variables/createVariable.ts b/packages/javascript/src/api/management/variables/createVariable.ts new file mode 100644 index 00000000..c8f9740f --- /dev/null +++ b/packages/javascript/src/api/management/variables/createVariable.ts @@ -0,0 +1,38 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ManagementRequestConfig} from '../../../models/api'; +import {Variable, VariableRequest} from '../../../models/variable-store'; +import requestManagementResource from '../../../utils/requestManagementResource'; + +/** + * Configuration for the createVariable request. + */ +export interface CreateVariableConfig extends ManagementRequestConfig { + /** + * The variable to add. + */ + payload: VariableRequest; +} + +/** + * Adds a variable. + * + * @param config - Request configuration object. + * @returns A promise that resolves with the variable as the server stored it. + * + * @example + * ```typescript + * const variable = await createVariable({baseUrl: 'https://localhost:8090', payload: {name: 'SMTP_HOST', value}}); + * ``` + */ +const createVariable = async ({payload, ...config}: CreateVariableConfig): Promise => + requestManagementResource(config, { + body: payload, + collection: 'variables', + failureMessage: 'Failed to add variable', + method: 'POST', + operation: 'createVariable', + }); + +export default createVariable; diff --git a/packages/javascript/src/api/management/variables/deleteVariable.ts b/packages/javascript/src/api/management/variables/deleteVariable.ts new file mode 100644 index 00000000..80717538 --- /dev/null +++ b/packages/javascript/src/api/management/variables/deleteVariable.ts @@ -0,0 +1,38 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ManagementRequestConfig} from '../../../models/api'; +import requestManagementResource from '../../../utils/requestManagementResource'; + +/** + * Configuration for the deleteVariable request. + */ +export interface DeleteVariableConfig extends ManagementRequestConfig { + /** + * Name of the variable. + */ + name: string; +} + +/** + * Deletes a variable. + * + * @param config - Request configuration object. + * @returns A promise that resolves once the server has made the change. + * + * @example + * ```typescript + * await deleteVariable({baseUrl: 'https://localhost:8090', name: 'SMTP_HOST'}); + * ``` + */ +const deleteVariable = async ({name, ...config}: DeleteVariableConfig): Promise => { + await requestManagementResource(config, { + collection: 'variables', + failureMessage: 'Failed to delete variable', + id: name, + method: 'DELETE', + operation: 'deleteVariable', + }); +}; + +export default deleteVariable; diff --git a/packages/javascript/src/api/management/variables/getVariable.ts b/packages/javascript/src/api/management/variables/getVariable.ts new file mode 100644 index 00000000..010e8209 --- /dev/null +++ b/packages/javascript/src/api/management/variables/getVariable.ts @@ -0,0 +1,38 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ManagementRequestConfig} from '../../../models/api'; +import {Variable} from '../../../models/variable-store'; +import requestManagementResource from '../../../utils/requestManagementResource'; + +/** + * Configuration for the getVariable request. + */ +export interface GetVariableConfig extends ManagementRequestConfig { + /** + * Name of the variable. + */ + name: string; +} + +/** + * Retrieves a variable. + * + * @param config - Request configuration object. + * @returns A promise that resolves with the variable. + * + * @example + * ```typescript + * const variable = await getVariable({baseUrl: 'https://localhost:8090', name: 'SMTP_HOST'}); + * ``` + */ +const getVariable = async ({name, ...config}: GetVariableConfig): Promise => + requestManagementResource(config, { + collection: 'variables', + failureMessage: 'Failed to fetch variable', + id: name, + method: 'GET', + operation: 'getVariable', + }); + +export default getVariable; diff --git a/packages/javascript/src/api/management/variables/getVariables.ts b/packages/javascript/src/api/management/variables/getVariables.ts new file mode 100644 index 00000000..3d800cc9 --- /dev/null +++ b/packages/javascript/src/api/management/variables/getVariables.ts @@ -0,0 +1,44 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ApiFilteringParams, ManagementRequestConfig} from '../../../models/api'; +import {VariableListResponse} from '../../../models/variable-store'; +import requestManagementResource from '../../../utils/requestManagementResource'; + +/** + * Configuration for the getVariables request. + */ +export interface GetVariablesConfig extends ManagementRequestConfig, ApiFilteringParams { + /** + * Returns only these names (at most 100). A name with nothing stored is absent from the result. + */ + names?: string[]; +} + +/** + * Retrieves a page of variables. + * + * @param config - Request configuration object. + * @returns A promise that resolves with the page the server returned. + * + * @example + * ```typescript + * const {variables} = await getVariables({baseUrl: 'https://localhost:8090', names: ['SMTP_HOST']}); + * ``` + */ +const getVariables = async ({ + filter, + limit, + names, + offset, + ...config +}: GetVariablesConfig): Promise => + requestManagementResource(config, { + collection: 'variables', + failureMessage: 'Failed to fetch variables', + method: 'GET', + operation: 'getVariables', + query: {filter, limit, names: names?.join(','), offset}, + }); + +export default getVariables; diff --git a/packages/javascript/src/api/management/variables/updateVariable.ts b/packages/javascript/src/api/management/variables/updateVariable.ts new file mode 100644 index 00000000..eeebf7c1 --- /dev/null +++ b/packages/javascript/src/api/management/variables/updateVariable.ts @@ -0,0 +1,43 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ManagementRequestConfig} from '../../../models/api'; +import {Variable, VariableUpdateRequest} from '../../../models/variable-store'; +import requestManagementResource from '../../../utils/requestManagementResource'; + +/** + * Configuration for the updateVariable request. + */ +export interface UpdateVariableConfig extends ManagementRequestConfig { + /** + * Name of the variable. + */ + name: string; + /** + * The variable's new value. + */ + payload: VariableUpdateRequest; +} + +/** + * Sets or replaces a variable, creating it when it does not exist. + * + * @param config - Request configuration object. + * @returns A promise that resolves with the variable as the server stored it. + * + * @example + * ```typescript + * const variable = await updateVariable({baseUrl: 'https://localhost:8090', name: 'SMTP_HOST', payload: {value}}); + * ``` + */ +const updateVariable = async ({name, payload, ...config}: UpdateVariableConfig): Promise => + requestManagementResource(config, { + body: payload, + collection: 'variables', + failureMessage: 'Failed to update variable', + id: name, + method: 'PUT', + operation: 'updateVariable', + }); + +export default updateVariable; diff --git a/packages/javascript/src/constants/AdministrationConstants.ts b/packages/javascript/src/constants/AdministrationConstants.ts new file mode 100644 index 00000000..d793225d --- /dev/null +++ b/packages/javascript/src/constants/AdministrationConstants.ts @@ -0,0 +1,109 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +/** + * How an operation that changes state is carried out. + * + * - `flow`: run the administration flow the server configures for the operation, which handles the + * runtime side first (for example revoking tokens and ending sessions) and then changes the record. + * When no flow is configured for the operation, the management API is called directly. + * - `native`: call the management API directly, with no flow. The default. + */ +const AdministrationModes: { + FLOW: 'flow'; + NATIVE: 'native'; +} = { + FLOW: 'flow', + NATIVE: 'native', +}; + +/** + * Keys of the `flow` server-config section that name the administration flow for each operation. An + * unset key is the opt-out: the operation calls the management API directly. + */ +export const AdministrationFlowConfigKey: { + ACTION_DELETION: 'actionDeletionFlow'; + AGENT_SUSPEND: 'agentSuspendFlow'; + AGENT_UNLOCK: 'agentUnlockFlow'; + AGENT_UNSUSPEND: 'agentUnsuspendFlow'; + APPLICATION_DELETION: 'applicationDeletionFlow'; + APPLICATION_SUSPEND: 'applicationSuspendFlow'; + APPLICATION_UNSUSPEND: 'applicationUnsuspendFlow'; + GROUP_DELETION: 'groupDeletionFlow'; + GROUP_MEMBERSHIP_REMOVAL: 'groupMembershipRemovalFlow'; + ROLE_ASSIGNMENT_REMOVAL: 'roleAssignmentRemovalFlow'; + ROLE_DELETION: 'roleDeletionFlow'; + ROLE_PERMISSION_REMOVAL: 'rolePermissionRemovalFlow'; + SECRET_REGENERATION: 'secretRegenerationFlow'; + USER_DELETION: 'userDeletionFlow'; + USER_SUSPEND: 'userSuspendFlow'; + USER_UNLOCK: 'userUnlockFlow'; + USER_UNSUSPEND: 'userUnsuspendFlow'; +} = { + ACTION_DELETION: 'actionDeletionFlow', + AGENT_SUSPEND: 'agentSuspendFlow', + AGENT_UNLOCK: 'agentUnlockFlow', + AGENT_UNSUSPEND: 'agentUnsuspendFlow', + APPLICATION_DELETION: 'applicationDeletionFlow', + APPLICATION_SUSPEND: 'applicationSuspendFlow', + APPLICATION_UNSUSPEND: 'applicationUnsuspendFlow', + GROUP_DELETION: 'groupDeletionFlow', + GROUP_MEMBERSHIP_REMOVAL: 'groupMembershipRemovalFlow', + ROLE_ASSIGNMENT_REMOVAL: 'roleAssignmentRemovalFlow', + ROLE_DELETION: 'roleDeletionFlow', + ROLE_PERMISSION_REMOVAL: 'rolePermissionRemovalFlow', + SECRET_REGENERATION: 'secretRegenerationFlow', + USER_DELETION: 'userDeletionFlow', + USER_SUSPEND: 'userSuspendFlow', + USER_UNLOCK: 'userUnlockFlow', + USER_UNSUSPEND: 'userUnsuspendFlow', +}; + +/** + * Names of the inputs an administration flow reads. They avoid the execution request's top-level names + * so a value cannot land in the wrong place. + */ +export const AdministrationFlowInput: { + ACTION: 'targetActionId'; + APPLICATION: 'targetApplicationId'; + ASSIGNEE: 'targetAssigneeId'; + GROUP: 'targetGroupId'; + MEMBER: 'targetMemberId'; + OPERATOR_NOTE: 'operatorNote'; + PERMISSIONS: 'targetPermissions'; + RESOURCE: 'targetResourceId'; + RESOURCE_SERVER: 'targetResourceServerId'; + ROLE: 'targetRoleId'; + SUBJECT: 'subject'; +} = { + ACTION: 'targetActionId', + APPLICATION: 'targetApplicationId', + ASSIGNEE: 'targetAssigneeId', + GROUP: 'targetGroupId', + MEMBER: 'targetMemberId', + OPERATOR_NOTE: 'operatorNote', + PERMISSIONS: 'targetPermissions', + RESOURCE: 'targetResourceId', + RESOURCE_SERVER: 'targetResourceServerId', + ROLE: 'targetRoleId', + SUBJECT: 'subject', +}; + +/** + * Keys of the values an administration flow returns. A regenerated client secret is readable only + * here. `emailSent` is `"false"` when a best-effort email step sent nothing. + */ +export const AdministrationFlowData: { + CLIENT_SECRET: 'clientSecret'; + EMAIL_SENT: 'emailSent'; +} = { + CLIENT_SECRET: 'clientSecret', + EMAIL_SENT: 'emailSent', +}; + +/** + * Flow type of every administration flow. + */ +export const ADMINISTRATION_FLOW_TYPE = 'ADMINISTRATION'; + +export default AdministrationModes; diff --git a/packages/javascript/src/constants/DelegationGrantTypes.ts b/packages/javascript/src/constants/DelegationGrantTypes.ts new file mode 100644 index 00000000..ae61f094 --- /dev/null +++ b/packages/javascript/src/constants/DelegationGrantTypes.ts @@ -0,0 +1,15 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +/** + * The OAuth grant types that let an agent act on behalf of a signed-in user: authorization code, CIBA + * and token exchange. An agent holding any of them is delegated, the rule the server applies when it + * checks an agent's grants. + */ +const DelegationGrantTypes: readonly string[] = [ + 'authorization_code', + 'urn:openid:params:grant-type:ciba', + 'urn:ietf:params:oauth:grant-type:token-exchange', +]; + +export default DelegationGrantTypes; diff --git a/packages/javascript/src/errors/AdministrationFlowError.ts b/packages/javascript/src/errors/AdministrationFlowError.ts new file mode 100644 index 00000000..809232dc --- /dev/null +++ b/packages/javascript/src/errors/AdministrationFlowError.ts @@ -0,0 +1,25 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import ThunderIDError from './ThunderIDError'; +import {AdministrationFlowStepError} from '../models/administration'; + +/** + * Raised when an administration flow does not complete: a step refused (its code and reason are in + * {@link AdministrationFlowError.flowError}), the flow asked for input the caller cannot give, or the + * flow the server configures for the operation no longer exists. + * + * A refusal arrives as a completed HTTP request with a failed step rather than as an HTTP error, so it + * carries the step's code here for the caller to map to a localized message. + */ +export default class AdministrationFlowError extends ThunderIDError { + /** + * The error of the step that refused, when one did. + */ + public readonly flowError: AdministrationFlowStepError; + + constructor(message: string, code: string, flowError: AdministrationFlowStepError = {}) { + super(message, code, 'javascript'); + this.flowError = flowError; + } +} diff --git a/packages/javascript/src/errors/ThunderIDAPIError.ts b/packages/javascript/src/errors/ThunderIDAPIError.ts index d41ccbf7..1c6e1663 100644 --- a/packages/javascript/src/errors/ThunderIDAPIError.ts +++ b/packages/javascript/src/errors/ThunderIDAPIError.ts @@ -2,8 +2,26 @@ // SPDX-License-Identifier: Apache-2.0 import ThunderIDError from './ThunderIDError'; +import {ServerError} from '../models/api'; import parseApiErrorMessage from '../utils/parseApiErrorMessage'; +/** + * Reads the server's error envelope from a raw response body, when the body is one. + */ +const parseServerError = (body: string): ServerError | undefined => { + try { + const parsed: unknown = JSON.parse(body); + + if (parsed && typeof parsed === 'object' && typeof (parsed as ServerError).code === 'string') { + return parsed as ServerError; + } + } catch { + // Not JSON: there is no envelope to read. + } + + return undefined; +}; + /** * Base class for all API-related errors in ThunderID. This class extends ThunderIDError * and adds support for HTTP status codes and status text. @@ -35,6 +53,13 @@ export default class ThunderIDAPIError extends ThunderIDError { * @param prefix - Optional prefix prepended to the resolved message * @constructor */ + /** + * The error the server returned, when its response body was an error envelope: the server's own + * error code, and its message and description with their translation keys and parameters. Use it to + * show a message specific to the server's code. + */ + public readonly serverError?: ServerError; + constructor( message: string, code: string, @@ -47,6 +72,8 @@ export default class ThunderIDAPIError extends ThunderIDError { const resolvedMessage: string = prefix ? `${prefix}: ${parsed}` : parsed; super(resolvedMessage, code, origin); + this.serverError = parseServerError(message); + Object.defineProperty(this, 'name', { configurable: true, value: 'ThunderIDAPIError', diff --git a/packages/javascript/src/errors/__tests__/ThunderIDAPIError.test.ts b/packages/javascript/src/errors/__tests__/ThunderIDAPIError.test.ts index d4e29436..051ac118 100644 --- a/packages/javascript/src/errors/__tests__/ThunderIDAPIError.test.ts +++ b/packages/javascript/src/errors/__tests__/ThunderIDAPIError.test.ts @@ -179,3 +179,31 @@ describe('ThunderIDAPIError — structured response body parsing', (): void => { expect(error.code).toBe('FES-1004'); }); }); + +describe('ThunderIDAPIError server error', () => { + it("keeps the server's error envelope, with its code and params", () => { + const body: string = JSON.stringify({ + code: 'USR-1027', + description: {defaultValue: 'Agents depend on it', params: {dependencies: 'agents'}}, + message: {defaultValue: 'User cannot be deleted'}, + }); + + const error: ThunderIDAPIError = new ThunderIDAPIError(body, 'deleteUser-ResponseError-001', 'javascript', 409); + + expect(error.message).toBe('User cannot be deleted'); + expect(error.serverError).toEqual({ + code: 'USR-1027', + description: {defaultValue: 'Agents depend on it', params: {dependencies: 'agents'}}, + message: {defaultValue: 'User cannot be deleted'}, + }); + }); + + it('has no server error when the body is not an error envelope', () => { + expect( + new ThunderIDAPIError('Bad gateway', 'getUser-ResponseError-001', 'javascript', 502).serverError, + ).toBeUndefined(); + expect( + new ThunderIDAPIError('{"foo":1}', 'getUser-ResponseError-001', 'javascript', 500).serverError, + ).toBeUndefined(); + }); +}); diff --git a/packages/javascript/src/i18n/models/i18n.ts b/packages/javascript/src/i18n/models/i18n.ts index 65feb395..0901450d 100644 --- a/packages/javascript/src/i18n/models/i18n.ts +++ b/packages/javascript/src/i18n/models/i18n.ts @@ -330,6 +330,51 @@ export interface I18nTranslations { 'admin.agents.details.delete.action': string; 'admin.agents.details.delete.confirm': string; 'admin.agents.details.delete.error': string; + 'admin.applications.list.title': string; + 'admin.applications.list.columns.name': string; + 'admin.applications.list.columns.client_id': string; + 'admin.applications.list.columns.type': string; + 'admin.applications.list.columns.status': string; + 'admin.applications.list.empty': string; + 'admin.applications.list.error': string; + 'admin.applications.status.active': string; + 'admin.applications.status.suspended': string; + 'admin.applications.create.title': string; + 'admin.applications.create.submit': string; + 'admin.applications.create.error': string; + 'admin.applications.fields.name': string; + 'admin.applications.fields.description': string; + 'admin.applications.fields.url': string; + 'admin.applications.fields.logo_url': string; + 'admin.applications.fields.redirect_uris': string; + 'admin.applications.fields.redirect_uris_hint': string; + 'admin.applications.fields.grant_types': string; + 'admin.applications.fields.public_client': string; + 'admin.applications.grant_types.authorization_code': string; + 'admin.applications.grant_types.refresh_token': string; + 'admin.applications.grant_types.client_credentials': string; + 'admin.applications.secret.title': string; + 'admin.applications.secret.notice': string; + 'admin.applications.secret.done': string; + 'admin.applications.details.overview': string; + 'admin.applications.details.oauth': string; + 'admin.applications.details.type': string; + 'admin.applications.details.public_client': string; + 'admin.applications.details.yes': string; + 'admin.applications.details.no': string; + 'admin.applications.details.not_set': string; + 'admin.applications.details.no_redirect_uris': string; + 'admin.applications.details.no_grant_types': string; + 'admin.applications.details.error': string; + 'admin.applications.details.delete.action': string; + 'admin.applications.details.delete.confirm': string; + 'admin.applications.details.delete.error': string; + 'admin.applications.details.edit.action': string; + 'admin.applications.details.edit.save': string; + 'admin.applications.details.edit.error': string; + 'admin.applications.details.secret.action': string; + 'admin.applications.details.secret.confirm': string; + 'admin.applications.details.secret.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 914843c0..193659c6 100644 --- a/packages/javascript/src/i18n/translations/en-US.ts +++ b/packages/javascript/src/i18n/translations/en-US.ts @@ -327,6 +327,52 @@ const translations: I18nTranslations = { '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.', + 'admin.applications.list.title': 'Applications', + 'admin.applications.list.columns.name': 'Name', + 'admin.applications.list.columns.client_id': 'Client ID', + 'admin.applications.list.columns.type': 'Type', + 'admin.applications.list.columns.status': 'Status', + 'admin.applications.list.empty': 'No applications found.', + 'admin.applications.list.error': 'Could not load applications. Please try again.', + 'admin.applications.status.active': 'Active', + 'admin.applications.status.suspended': 'Suspended', + 'admin.applications.create.title': 'Create application', + 'admin.applications.create.submit': 'Create application', + 'admin.applications.create.error': 'Could not create the application. Please try again.', + 'admin.applications.fields.name': 'Name', + 'admin.applications.fields.description': 'Description', + 'admin.applications.fields.url': 'Application URL', + 'admin.applications.fields.logo_url': 'Logo URL', + 'admin.applications.fields.redirect_uris': 'Redirect URIs', + 'admin.applications.fields.redirect_uris_hint': 'Needed for the authorization code grant. Separate URIs with commas.', + 'admin.applications.fields.grant_types': 'Grant types', + 'admin.applications.fields.public_client': 'Public client (no client secret)', + 'admin.applications.grant_types.authorization_code': 'Authorization code', + 'admin.applications.grant_types.refresh_token': 'Refresh token', + 'admin.applications.grant_types.client_credentials': 'Client credentials', + 'admin.applications.secret.title': 'Client secret', + 'admin.applications.secret.notice': 'Copy the client secret now. It will not be shown again.', + 'admin.applications.secret.done': 'Done', + 'admin.applications.details.overview': 'Overview', + 'admin.applications.details.oauth': 'OAuth', + 'admin.applications.details.type': 'Type', + 'admin.applications.details.public_client': 'Public client', + 'admin.applications.details.yes': 'Yes', + 'admin.applications.details.no': 'No', + 'admin.applications.details.not_set': 'Not set', + 'admin.applications.details.no_redirect_uris': 'No redirect URIs.', + 'admin.applications.details.no_grant_types': 'No grant types.', + 'admin.applications.details.error': 'Could not load this application. Please try again.', + 'admin.applications.details.delete.action': 'Delete application', + 'admin.applications.details.delete.confirm': 'Delete {name}?', + 'admin.applications.details.delete.error': 'Could not delete the application. Please try again.', + 'admin.applications.details.edit.action': 'Edit', + 'admin.applications.details.edit.save': 'Save changes', + 'admin.applications.details.edit.error': 'Could not save the application. Please try again.', + 'admin.applications.details.secret.action': 'Regenerate client secret', + 'admin.applications.details.secret.confirm': + 'Regenerate the client secret of {name}? Apps using the current secret stop working.', + 'admin.applications.details.secret.error': 'Could not regenerate the client secret. 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 91043233..4cdfcb9f 100644 --- a/packages/javascript/src/i18n/translations/fr-FR.ts +++ b/packages/javascript/src/i18n/translations/fr-FR.ts @@ -334,6 +334,53 @@ const translations: I18nTranslations = { '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.", + 'admin.applications.list.title': 'Applications', + 'admin.applications.list.columns.name': 'Nom', + 'admin.applications.list.columns.client_id': 'ID client', + 'admin.applications.list.columns.type': 'Type', + 'admin.applications.list.columns.status': 'Statut', + 'admin.applications.list.empty': 'Aucune application trouvée.', + 'admin.applications.list.error': 'Impossible de charger les applications. Veuillez réessayer.', + 'admin.applications.status.active': 'Actif', + 'admin.applications.status.suspended': 'Suspendu', + 'admin.applications.create.title': 'Créer une application', + 'admin.applications.create.submit': 'Créer une application', + 'admin.applications.create.error': "Impossible de créer l'application. Veuillez réessayer.", + 'admin.applications.fields.name': 'Nom', + 'admin.applications.fields.description': 'Description', + 'admin.applications.fields.url': "URL de l'application", + 'admin.applications.fields.logo_url': 'URL du logo', + 'admin.applications.fields.redirect_uris': 'URI de redirection', + 'admin.applications.fields.redirect_uris_hint': + "Requises pour l'octroi par code d'autorisation. Séparez les URI par des virgules.", + 'admin.applications.fields.grant_types': "Types d'octroi", + 'admin.applications.fields.public_client': 'Client public (sans secret client)', + 'admin.applications.grant_types.authorization_code': "Code d'autorisation", + 'admin.applications.grant_types.refresh_token': 'Jeton de rafraîchissement', + 'admin.applications.grant_types.client_credentials': 'Identifiants client', + 'admin.applications.secret.title': 'Secret client', + 'admin.applications.secret.notice': 'Copiez le secret client maintenant. Il ne sera plus affiché.', + 'admin.applications.secret.done': 'Terminé', + 'admin.applications.details.overview': "Vue d'ensemble", + 'admin.applications.details.oauth': 'OAuth', + 'admin.applications.details.type': 'Type', + 'admin.applications.details.public_client': 'Client public', + 'admin.applications.details.yes': 'Oui', + 'admin.applications.details.no': 'Non', + 'admin.applications.details.not_set': 'Non défini', + 'admin.applications.details.no_redirect_uris': 'Aucune URI de redirection.', + 'admin.applications.details.no_grant_types': "Aucun type d'octroi.", + 'admin.applications.details.error': 'Impossible de charger cette application. Veuillez réessayer.', + 'admin.applications.details.delete.action': "Supprimer l'application", + 'admin.applications.details.delete.confirm': 'Supprimer {name} ?', + 'admin.applications.details.delete.error': "Impossible de supprimer l'application. Veuillez réessayer.", + 'admin.applications.details.edit.action': 'Modifier', + 'admin.applications.details.edit.save': 'Enregistrer les modifications', + 'admin.applications.details.edit.error': "Impossible d'enregistrer l'application. Veuillez réessayer.", + 'admin.applications.details.secret.action': 'Régénérer le secret client', + 'admin.applications.details.secret.confirm': + 'Régénérer le secret client de {name} ? Les applications qui utilisent le secret actuel cesseront de fonctionner.', + 'admin.applications.details.secret.error': 'Impossible de régénérer le secret client. 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 4f99e05f..a37e5232 100644 --- a/packages/javascript/src/i18n/translations/hi-IN.ts +++ b/packages/javascript/src/i18n/translations/hi-IN.ts @@ -324,6 +324,52 @@ const translations: I18nTranslations = { 'admin.agents.details.delete.action': 'एजेंट हटाएं', 'admin.agents.details.delete.confirm': '{name} को हटाएं?', 'admin.agents.details.delete.error': 'एजेंट को हटाया नहीं जा सका। कृपया फिर से प्रयास करें।', + 'admin.applications.list.title': 'एप्लिकेशन', + 'admin.applications.list.columns.name': 'नाम', + 'admin.applications.list.columns.client_id': 'क्लाइंट आईडी', + 'admin.applications.list.columns.type': 'प्रकार', + 'admin.applications.list.columns.status': 'स्थिति', + 'admin.applications.list.empty': 'कोई एप्लिकेशन नहीं मिला।', + 'admin.applications.list.error': 'एप्लिकेशन लोड नहीं हो सके। कृपया फिर से प्रयास करें।', + 'admin.applications.status.active': 'सक्रिय', + 'admin.applications.status.suspended': 'निलंबित', + 'admin.applications.create.title': 'एप्लिकेशन बनाएं', + 'admin.applications.create.submit': 'एप्लिकेशन बनाएं', + 'admin.applications.create.error': 'एप्लिकेशन नहीं बनाया जा सका। कृपया फिर से प्रयास करें।', + 'admin.applications.fields.name': 'नाम', + 'admin.applications.fields.description': 'विवरण', + 'admin.applications.fields.url': 'एप्लिकेशन URL', + 'admin.applications.fields.logo_url': 'लोगो URL', + 'admin.applications.fields.redirect_uris': 'रीडायरेक्ट URI', + 'admin.applications.fields.redirect_uris_hint': 'ऑथराइज़ेशन कोड ग्रांट के लिए आवश्यक। URI को अल्पविराम से अलग करें।', + 'admin.applications.fields.grant_types': 'ग्रांट प्रकार', + 'admin.applications.fields.public_client': 'सार्वजनिक क्लाइंट (बिना क्लाइंट सीक्रेट)', + 'admin.applications.grant_types.authorization_code': 'ऑथराइज़ेशन कोड', + 'admin.applications.grant_types.refresh_token': 'रिफ्रेश टोकन', + 'admin.applications.grant_types.client_credentials': 'क्लाइंट क्रेडेंशियल', + 'admin.applications.secret.title': 'क्लाइंट सीक्रेट', + 'admin.applications.secret.notice': 'क्लाइंट सीक्रेट अभी कॉपी करें। यह फिर से नहीं दिखाया जाएगा।', + 'admin.applications.secret.done': 'हो गया', + 'admin.applications.details.overview': 'अवलोकन', + 'admin.applications.details.oauth': 'OAuth', + 'admin.applications.details.type': 'प्रकार', + 'admin.applications.details.public_client': 'सार्वजनिक क्लाइंट', + 'admin.applications.details.yes': 'हाँ', + 'admin.applications.details.no': 'नहीं', + 'admin.applications.details.not_set': 'सेट नहीं है', + 'admin.applications.details.no_redirect_uris': 'कोई रीडायरेक्ट URI नहीं।', + 'admin.applications.details.no_grant_types': 'कोई ग्रांट प्रकार नहीं।', + 'admin.applications.details.error': 'यह एप्लिकेशन लोड नहीं हो सका। कृपया फिर से प्रयास करें।', + 'admin.applications.details.delete.action': 'एप्लिकेशन हटाएं', + 'admin.applications.details.delete.confirm': '{name} को हटाएं?', + 'admin.applications.details.delete.error': 'एप्लिकेशन को हटाया नहीं जा सका। कृपया फिर से प्रयास करें।', + 'admin.applications.details.edit.action': 'संपादित करें', + 'admin.applications.details.edit.save': 'परिवर्तन सहेजें', + 'admin.applications.details.edit.error': 'एप्लिकेशन सहेजा नहीं जा सका। कृपया फिर से प्रयास करें।', + 'admin.applications.details.secret.action': 'क्लाइंट सीक्रेट फिर से बनाएं', + 'admin.applications.details.secret.confirm': + '{name} का क्लाइंट सीक्रेट फिर से बनाएं? मौजूदा सीक्रेट का उपयोग करने वाले ऐप काम करना बंद कर देंगे।', + 'admin.applications.details.secret.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 df912847..3df8f99d 100644 --- a/packages/javascript/src/i18n/translations/ja-JP.ts +++ b/packages/javascript/src/i18n/translations/ja-JP.ts @@ -325,6 +325,53 @@ const translations: I18nTranslations = { 'admin.agents.details.delete.action': 'エージェントを削除', 'admin.agents.details.delete.confirm': '{name} を削除しますか?', 'admin.agents.details.delete.error': 'エージェントを削除できませんでした。もう一度お試しください。', + 'admin.applications.list.title': 'アプリケーション', + 'admin.applications.list.columns.name': '名前', + 'admin.applications.list.columns.client_id': 'クライアントID', + 'admin.applications.list.columns.type': '種類', + 'admin.applications.list.columns.status': 'ステータス', + 'admin.applications.list.empty': 'アプリケーションが見つかりません。', + 'admin.applications.list.error': 'アプリケーションを読み込めませんでした。もう一度お試しください。', + 'admin.applications.status.active': '有効', + 'admin.applications.status.suspended': '停止中', + 'admin.applications.create.title': 'アプリケーションを作成', + 'admin.applications.create.submit': 'アプリケーションを作成', + 'admin.applications.create.error': 'アプリケーションを作成できませんでした。もう一度お試しください。', + 'admin.applications.fields.name': '名前', + 'admin.applications.fields.description': '説明', + 'admin.applications.fields.url': 'アプリケーションURL', + 'admin.applications.fields.logo_url': 'ロゴURL', + 'admin.applications.fields.redirect_uris': 'リダイレクトURI', + 'admin.applications.fields.redirect_uris_hint': '認可コードグラントに必要です。複数のURIはカンマで区切ってください。', + 'admin.applications.fields.grant_types': 'グラントタイプ', + 'admin.applications.fields.public_client': 'パブリッククライアント(クライアントシークレットなし)', + 'admin.applications.grant_types.authorization_code': '認可コード', + 'admin.applications.grant_types.refresh_token': 'リフレッシュトークン', + 'admin.applications.grant_types.client_credentials': 'クライアントクレデンシャル', + 'admin.applications.secret.title': 'クライアントシークレット', + 'admin.applications.secret.notice': '今すぐクライアントシークレットをコピーしてください。再表示されません。', + 'admin.applications.secret.done': '完了', + 'admin.applications.details.overview': '概要', + 'admin.applications.details.oauth': 'OAuth', + 'admin.applications.details.type': '種類', + 'admin.applications.details.public_client': 'パブリッククライアント', + 'admin.applications.details.yes': 'はい', + 'admin.applications.details.no': 'いいえ', + 'admin.applications.details.not_set': '未設定', + 'admin.applications.details.no_redirect_uris': 'リダイレクトURIはありません。', + 'admin.applications.details.no_grant_types': 'グラントタイプはありません。', + 'admin.applications.details.error': 'このアプリケーションを読み込めませんでした。もう一度お試しください。', + 'admin.applications.details.delete.action': 'アプリケーションを削除', + 'admin.applications.details.delete.confirm': '{name} を削除しますか?', + 'admin.applications.details.delete.error': 'アプリケーションを削除できませんでした。もう一度お試しください。', + 'admin.applications.details.edit.action': '編集', + 'admin.applications.details.edit.save': '変更を保存', + 'admin.applications.details.edit.error': 'アプリケーションを保存できませんでした。もう一度お試しください。', + 'admin.applications.details.secret.action': 'クライアントシークレットを再生成', + 'admin.applications.details.secret.confirm': + '{name} のクライアントシークレットを再生成しますか?現在のシークレットを使用しているアプリは動作しなくなります。', + 'admin.applications.details.secret.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 89a110da..4b4745a3 100644 --- a/packages/javascript/src/i18n/translations/pt-BR.ts +++ b/packages/javascript/src/i18n/translations/pt-BR.ts @@ -328,6 +328,53 @@ const translations: I18nTranslations = { '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.', + 'admin.applications.list.title': 'Aplicações', + 'admin.applications.list.columns.name': 'Nome', + 'admin.applications.list.columns.client_id': 'ID do cliente', + 'admin.applications.list.columns.type': 'Tipo', + 'admin.applications.list.columns.status': 'Status', + 'admin.applications.list.empty': 'Nenhuma aplicação encontrada.', + 'admin.applications.list.error': 'Não foi possível carregar as aplicações. Tente novamente.', + 'admin.applications.status.active': 'Ativo', + 'admin.applications.status.suspended': 'Suspenso', + 'admin.applications.create.title': 'Criar aplicação', + 'admin.applications.create.submit': 'Criar aplicação', + 'admin.applications.create.error': 'Não foi possível criar a aplicação. Tente novamente.', + 'admin.applications.fields.name': 'Nome', + 'admin.applications.fields.description': 'Descrição', + 'admin.applications.fields.url': 'URL da aplicação', + 'admin.applications.fields.logo_url': 'URL do logotipo', + 'admin.applications.fields.redirect_uris': 'URIs de redirecionamento', + 'admin.applications.fields.redirect_uris_hint': + 'Necessárias para a concessão por código de autorização. Separe as URIs com vírgulas.', + 'admin.applications.fields.grant_types': 'Tipos de concessão', + 'admin.applications.fields.public_client': 'Cliente público (sem segredo do cliente)', + 'admin.applications.grant_types.authorization_code': 'Código de autorização', + 'admin.applications.grant_types.refresh_token': 'Token de atualização', + 'admin.applications.grant_types.client_credentials': 'Credenciais do cliente', + 'admin.applications.secret.title': 'Segredo do cliente', + 'admin.applications.secret.notice': 'Copie o segredo do cliente agora. Ele não será exibido novamente.', + 'admin.applications.secret.done': 'Concluído', + 'admin.applications.details.overview': 'Visão geral', + 'admin.applications.details.oauth': 'OAuth', + 'admin.applications.details.type': 'Tipo', + 'admin.applications.details.public_client': 'Cliente público', + 'admin.applications.details.yes': 'Sim', + 'admin.applications.details.no': 'Não', + 'admin.applications.details.not_set': 'Não definido', + 'admin.applications.details.no_redirect_uris': 'Nenhuma URI de redirecionamento.', + 'admin.applications.details.no_grant_types': 'Nenhum tipo de concessão.', + 'admin.applications.details.error': 'Não foi possível carregar esta aplicação. Tente novamente.', + 'admin.applications.details.delete.action': 'Excluir aplicação', + 'admin.applications.details.delete.confirm': 'Excluir {name}?', + 'admin.applications.details.delete.error': 'Não foi possível excluir a aplicação. Tente novamente.', + 'admin.applications.details.edit.action': 'Editar', + 'admin.applications.details.edit.save': 'Salvar alterações', + 'admin.applications.details.edit.error': 'Não foi possível salvar a aplicação. Tente novamente.', + 'admin.applications.details.secret.action': 'Gerar novo segredo do cliente', + 'admin.applications.details.secret.confirm': + 'Gerar um novo segredo do cliente para {name}? Os apps que usam o segredo atual deixarão de funcionar.', + 'admin.applications.details.secret.error': 'Não foi possível gerar um novo segredo do cliente. 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 6d368dd7..e7253790 100644 --- a/packages/javascript/src/i18n/translations/pt-PT.ts +++ b/packages/javascript/src/i18n/translations/pt-PT.ts @@ -329,6 +329,53 @@ const translations: I18nTranslations = { '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.', + 'admin.applications.list.title': 'Aplicações', + 'admin.applications.list.columns.name': 'Nome', + 'admin.applications.list.columns.client_id': 'ID de cliente', + 'admin.applications.list.columns.type': 'Tipo', + 'admin.applications.list.columns.status': 'Estado', + 'admin.applications.list.empty': 'Nenhuma aplicação encontrada.', + 'admin.applications.list.error': 'Não foi possível carregar as aplicações. Tente novamente.', + 'admin.applications.status.active': 'Ativo', + 'admin.applications.status.suspended': 'Suspenso', + 'admin.applications.create.title': 'Criar aplicação', + 'admin.applications.create.submit': 'Criar aplicação', + 'admin.applications.create.error': 'Não foi possível criar a aplicação. Tente novamente.', + 'admin.applications.fields.name': 'Nome', + 'admin.applications.fields.description': 'Descrição', + 'admin.applications.fields.url': 'URL da aplicação', + 'admin.applications.fields.logo_url': 'URL do logótipo', + 'admin.applications.fields.redirect_uris': 'URIs de redirecionamento', + 'admin.applications.fields.redirect_uris_hint': + 'Necessários para a concessão por código de autorização. Separe os URIs com vírgulas.', + 'admin.applications.fields.grant_types': 'Tipos de concessão', + 'admin.applications.fields.public_client': 'Cliente público (sem segredo de cliente)', + 'admin.applications.grant_types.authorization_code': 'Código de autorização', + 'admin.applications.grant_types.refresh_token': 'Token de atualização', + 'admin.applications.grant_types.client_credentials': 'Credenciais de cliente', + 'admin.applications.secret.title': 'Segredo de cliente', + 'admin.applications.secret.notice': 'Copie o segredo de cliente agora. Não voltará a ser apresentado.', + 'admin.applications.secret.done': 'Concluído', + 'admin.applications.details.overview': 'Visão geral', + 'admin.applications.details.oauth': 'OAuth', + 'admin.applications.details.type': 'Tipo', + 'admin.applications.details.public_client': 'Cliente público', + 'admin.applications.details.yes': 'Sim', + 'admin.applications.details.no': 'Não', + 'admin.applications.details.not_set': 'Não definido', + 'admin.applications.details.no_redirect_uris': 'Nenhum URI de redirecionamento.', + 'admin.applications.details.no_grant_types': 'Nenhum tipo de concessão.', + 'admin.applications.details.error': 'Não foi possível carregar esta aplicação. Tente novamente.', + 'admin.applications.details.delete.action': 'Eliminar aplicação', + 'admin.applications.details.delete.confirm': 'Eliminar {name}?', + 'admin.applications.details.delete.error': 'Não foi possível eliminar a aplicação. Tente novamente.', + 'admin.applications.details.edit.action': 'Editar', + 'admin.applications.details.edit.save': 'Guardar alterações', + 'admin.applications.details.edit.error': 'Não foi possível guardar a aplicação. Tente novamente.', + 'admin.applications.details.secret.action': 'Gerar novo segredo de cliente', + 'admin.applications.details.secret.confirm': + 'Gerar um novo segredo de cliente para {name}? As aplicações que usam o segredo atual deixarão de funcionar.', + 'admin.applications.details.secret.error': 'Não foi possível gerar um novo segredo de cliente. 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 eadde04d..6637ec9b 100644 --- a/packages/javascript/src/i18n/translations/si-LK.ts +++ b/packages/javascript/src/i18n/translations/si-LK.ts @@ -327,6 +327,52 @@ const translations: I18nTranslations = { 'admin.agents.details.delete.action': 'නියෝජිතයා මකන්න', 'admin.agents.details.delete.confirm': '{name} මකන්නද?', 'admin.agents.details.delete.error': 'නියෝජිතයා මැකිය නොහැකි විය. නැවත උත්සාහ කරන්න.', + 'admin.applications.list.title': 'යෙදුම්', + 'admin.applications.list.columns.name': 'නම', + 'admin.applications.list.columns.client_id': 'සේවාලාභී හැඳුනුම්පත', + 'admin.applications.list.columns.type': 'වර්ගය', + 'admin.applications.list.columns.status': 'තත්ත්වය', + 'admin.applications.list.empty': 'යෙදුම් කිසිවක් හමු නොවීය.', + 'admin.applications.list.error': 'යෙදුම් පූරණය කළ නොහැකි විය. නැවත උත්සාහ කරන්න.', + 'admin.applications.status.active': 'සක්‍රිය', + 'admin.applications.status.suspended': 'අත්හිටුවා ඇත', + 'admin.applications.create.title': 'යෙදුමක් සාදන්න', + 'admin.applications.create.submit': 'යෙදුමක් සාදන්න', + 'admin.applications.create.error': 'යෙදුම සෑදිය නොහැකි විය. නැවත උත්සාහ කරන්න.', + 'admin.applications.fields.name': 'නම', + 'admin.applications.fields.description': 'විස්තරය', + 'admin.applications.fields.url': 'යෙදුමේ URL', + 'admin.applications.fields.logo_url': 'ලාංඡන URL', + 'admin.applications.fields.redirect_uris': 'යළි-යොමු URI', + 'admin.applications.fields.redirect_uris_hint': 'අවසර කේත ප්‍රදානය සඳහා අවශ්‍යයි. URI කොමා මගින් වෙන් කරන්න.', + 'admin.applications.fields.grant_types': 'ප්‍රදාන වර්ග', + 'admin.applications.fields.public_client': 'පොදු සේවාලාභියා (සේවාලාභී රහසක් නැත)', + 'admin.applications.grant_types.authorization_code': 'අවසර කේතය', + 'admin.applications.grant_types.refresh_token': 'නැවුම් කිරීමේ ටෝකනය', + 'admin.applications.grant_types.client_credentials': 'සේවාලාභී අක්තපත්‍ර', + 'admin.applications.secret.title': 'සේවාලාභී රහස', + 'admin.applications.secret.notice': 'සේවාලාභී රහස දැන් පිටපත් කරන්න. එය නැවත නොපෙන්වයි.', + 'admin.applications.secret.done': 'නිමයි', + 'admin.applications.details.overview': 'දළ විශ්ලේෂණය', + 'admin.applications.details.oauth': 'OAuth', + 'admin.applications.details.type': 'වර්ගය', + 'admin.applications.details.public_client': 'පොදු සේවාලාභියා', + 'admin.applications.details.yes': 'ඔව්', + 'admin.applications.details.no': 'නැත', + 'admin.applications.details.not_set': 'සකසා නැත', + 'admin.applications.details.no_redirect_uris': 'යළි-යොමු URI නැත.', + 'admin.applications.details.no_grant_types': 'ප්‍රදාන වර්ග නැත.', + 'admin.applications.details.error': 'මෙම යෙදුම පූරණය කළ නොහැකි විය. නැවත උත්සාහ කරන්න.', + 'admin.applications.details.delete.action': 'යෙදුම මකන්න', + 'admin.applications.details.delete.confirm': '{name} මකන්නද?', + 'admin.applications.details.delete.error': 'යෙදුම මැකිය නොහැකි විය. නැවත උත්සාහ කරන්න.', + 'admin.applications.details.edit.action': 'සංස්කරණය', + 'admin.applications.details.edit.save': 'වෙනස්කම් සුරකින්න', + 'admin.applications.details.edit.error': 'යෙදුම සුරැකිය නොහැකි විය. නැවත උත්සාහ කරන්න.', + 'admin.applications.details.secret.action': 'සේවාලාභී රහස නැවත ජනනය කරන්න', + 'admin.applications.details.secret.confirm': + '{name} හි සේවාලාභී රහස නැවත ජනනය කරන්නද? වත්මන් රහස භාවිත කරන යෙදුම් ක්‍රියා කිරීම නවතී.', + 'admin.applications.details.secret.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 81fcb739..24b40dc3 100644 --- a/packages/javascript/src/i18n/translations/ta-IN.ts +++ b/packages/javascript/src/i18n/translations/ta-IN.ts @@ -329,6 +329,54 @@ const translations: I18nTranslations = { 'admin.agents.details.delete.action': 'முகவரை நீக்கு', 'admin.agents.details.delete.confirm': '{name} ஐ நீக்கவா?', 'admin.agents.details.delete.error': 'முகவரை நீக்க முடியவில்லை. மீண்டும் முயற்சிக்கவும்.', + 'admin.applications.list.title': 'பயன்பாடுகள்', + 'admin.applications.list.columns.name': 'பெயர்', + 'admin.applications.list.columns.client_id': 'கிளையன்ட் ஐடி', + 'admin.applications.list.columns.type': 'வகை', + 'admin.applications.list.columns.status': 'நிலை', + 'admin.applications.list.empty': 'பயன்பாடுகள் எதுவும் இல்லை.', + 'admin.applications.list.error': 'பயன்பாடுகளை ஏற்ற முடியவில்லை. மீண்டும் முயற்சிக்கவும்.', + 'admin.applications.status.active': 'செயலில்', + 'admin.applications.status.suspended': 'இடைநிறுத்தப்பட்டது', + 'admin.applications.create.title': 'பயன்பாட்டை உருவாக்கு', + 'admin.applications.create.submit': 'பயன்பாட்டை உருவாக்கு', + 'admin.applications.create.error': 'பயன்பாட்டை உருவாக்க முடியவில்லை. மீண்டும் முயற்சிக்கவும்.', + 'admin.applications.fields.name': 'பெயர்', + 'admin.applications.fields.description': 'விளக்கம்', + 'admin.applications.fields.url': 'பயன்பாட்டு URL', + 'admin.applications.fields.logo_url': 'லோகோ URL', + 'admin.applications.fields.redirect_uris': 'திருப்பிவிடும் URIகள்', + 'admin.applications.fields.redirect_uris_hint': + 'அங்கீகாரக் குறியீடு வழங்கலுக்குத் தேவை. URIகளைக் காற்புள்ளிகளால் பிரிக்கவும்.', + 'admin.applications.fields.grant_types': 'வழங்கல் வகைகள்', + 'admin.applications.fields.public_client': 'பொது கிளையன்ட் (கிளையன்ட் ரகசியம் இல்லை)', + 'admin.applications.grant_types.authorization_code': 'அங்கீகாரக் குறியீடு', + 'admin.applications.grant_types.refresh_token': 'புதுப்பிப்பு டோக்கன்', + 'admin.applications.grant_types.client_credentials': 'கிளையன்ட் சான்றுகள்', + 'admin.applications.secret.title': 'கிளையன்ட் ரகசியம்', + 'admin.applications.secret.notice': 'கிளையன்ட் ரகசியத்தை இப்போதே நகலெடுக்கவும். இது மீண்டும் காட்டப்படாது.', + 'admin.applications.secret.done': 'முடிந்தது', + 'admin.applications.details.overview': 'மேலோட்டம்', + 'admin.applications.details.oauth': 'OAuth', + 'admin.applications.details.type': 'வகை', + 'admin.applications.details.public_client': 'பொது கிளையன்ட்', + 'admin.applications.details.yes': 'ஆம்', + 'admin.applications.details.no': 'இல்லை', + 'admin.applications.details.not_set': 'அமைக்கப்படவில்லை', + 'admin.applications.details.no_redirect_uris': 'திருப்பிவிடும் URIகள் இல்லை.', + 'admin.applications.details.no_grant_types': 'வழங்கல் வகைகள் இல்லை.', + 'admin.applications.details.error': 'இந்தப் பயன்பாட்டை ஏற்ற முடியவில்லை. மீண்டும் முயற்சிக்கவும்.', + 'admin.applications.details.delete.action': 'பயன்பாட்டை நீக்கு', + 'admin.applications.details.delete.confirm': '{name} ஐ நீக்கவா?', + 'admin.applications.details.delete.error': 'பயன்பாட்டை நீக்க முடியவில்லை. மீண்டும் முயற்சிக்கவும்.', + 'admin.applications.details.edit.action': 'திருத்து', + 'admin.applications.details.edit.save': 'மாற்றங்களைச் சேமி', + 'admin.applications.details.edit.error': 'பயன்பாட்டைச் சேமிக்க முடியவில்லை. மீண்டும் முயற்சிக்கவும்.', + 'admin.applications.details.secret.action': 'கிளையன்ட் ரகசியத்தை மீண்டும் உருவாக்கு', + 'admin.applications.details.secret.confirm': + '{name} இன் கிளையன்ட் ரகசியத்தை மீண்டும் உருவாக்கவா? தற்போதைய ரகசியத்தைப் பயன்படுத்தும் பயன்பாடுகள் செயல்படாது.', + 'admin.applications.details.secret.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 9e2f3867..95bdef61 100644 --- a/packages/javascript/src/i18n/translations/te-IN.ts +++ b/packages/javascript/src/i18n/translations/te-IN.ts @@ -329,6 +329,53 @@ const translations: I18nTranslations = { 'admin.agents.details.delete.action': 'ఏజెంట్‌ను తొలగించు', 'admin.agents.details.delete.confirm': '{name} ను తొలగించాలా?', 'admin.agents.details.delete.error': 'ఏజెంట్‌ను తొలగించలేకపోయాము. దయచేసి మళ్లీ ప్రయత్నించండి.', + 'admin.applications.list.title': 'అప్లికేషన్‌లు', + 'admin.applications.list.columns.name': 'పేరు', + 'admin.applications.list.columns.client_id': 'క్లయింట్ ID', + 'admin.applications.list.columns.type': 'రకం', + 'admin.applications.list.columns.status': 'స్థితి', + 'admin.applications.list.empty': 'అప్లికేషన్‌లు ఏవీ కనుగొనబడలేదు.', + 'admin.applications.list.error': 'అప్లికేషన్‌లను లోడ్ చేయలేకపోయాము. దయచేసి మళ్లీ ప్రయత్నించండి.', + 'admin.applications.status.active': 'సక్రియం', + 'admin.applications.status.suspended': 'నిలిపివేయబడింది', + 'admin.applications.create.title': 'అప్లికేషన్‌ను సృష్టించు', + 'admin.applications.create.submit': 'అప్లికేషన్‌ను సృష్టించు', + 'admin.applications.create.error': 'అప్లికేషన్‌ను సృష్టించలేకపోయాము. దయచేసి మళ్లీ ప్రయత్నించండి.', + 'admin.applications.fields.name': 'పేరు', + 'admin.applications.fields.description': 'వివరణ', + 'admin.applications.fields.url': 'అప్లికేషన్ URL', + 'admin.applications.fields.logo_url': 'లోగో URL', + 'admin.applications.fields.redirect_uris': 'దారి మళ్లింపు URIలు', + 'admin.applications.fields.redirect_uris_hint': 'ఆథరైజేషన్ కోడ్ గ్రాంట్‌కు అవసరం. URIలను కామాలతో వేరు చేయండి.', + 'admin.applications.fields.grant_types': 'గ్రాంట్ రకాలు', + 'admin.applications.fields.public_client': 'పబ్లిక్ క్లయింట్ (క్లయింట్ రహస్యం లేదు)', + 'admin.applications.grant_types.authorization_code': 'ఆథరైజేషన్ కోడ్', + 'admin.applications.grant_types.refresh_token': 'రిఫ్రెష్ టోకెన్', + 'admin.applications.grant_types.client_credentials': 'క్లయింట్ ఆధారాలు', + 'admin.applications.secret.title': 'క్లయింట్ రహస్యం', + 'admin.applications.secret.notice': 'క్లయింట్ రహస్యాన్ని ఇప్పుడే కాపీ చేయండి. ఇది మళ్లీ చూపబడదు.', + 'admin.applications.secret.done': 'పూర్తయింది', + 'admin.applications.details.overview': 'అవలోకనం', + 'admin.applications.details.oauth': 'OAuth', + 'admin.applications.details.type': 'రకం', + 'admin.applications.details.public_client': 'పబ్లిక్ క్లయింట్', + 'admin.applications.details.yes': 'అవును', + 'admin.applications.details.no': 'కాదు', + 'admin.applications.details.not_set': 'సెట్ చేయలేదు', + 'admin.applications.details.no_redirect_uris': 'దారి మళ్లింపు URIలు లేవు.', + 'admin.applications.details.no_grant_types': 'గ్రాంట్ రకాలు లేవు.', + 'admin.applications.details.error': 'ఈ అప్లికేషన్‌ను లోడ్ చేయలేకపోయాము. దయచేసి మళ్లీ ప్రయత్నించండి.', + 'admin.applications.details.delete.action': 'అప్లికేషన్‌ను తొలగించు', + 'admin.applications.details.delete.confirm': '{name} ను తొలగించాలా?', + 'admin.applications.details.delete.error': 'అప్లికేషన్‌ను తొలగించలేకపోయాము. దయచేసి మళ్లీ ప్రయత్నించండి.', + 'admin.applications.details.edit.action': 'సవరించు', + 'admin.applications.details.edit.save': 'మార్పులను సేవ్ చేయి', + 'admin.applications.details.edit.error': 'అప్లికేషన్‌ను సేవ్ చేయలేకపోయాము. దయచేసి మళ్లీ ప్రయత్నించండి.', + 'admin.applications.details.secret.action': 'క్లయింట్ రహస్యాన్ని మళ్లీ రూపొందించు', + 'admin.applications.details.secret.confirm': + '{name} క్లయింట్ రహస్యాన్ని మళ్లీ రూపొందించాలా? ప్రస్తుత రహస్యాన్ని ఉపయోగించే యాప్‌లు పనిచేయడం ఆగిపోతాయి.', + 'admin.applications.details.secret.error': + 'క్లయింట్ రహస్యాన్ని మళ్లీ రూపొందించలేకపోయాము. దయచేసి మళ్లీ ప్రయత్నించండి.', }; const metadata: I18nMetadata = { diff --git a/packages/javascript/src/index.ts b/packages/javascript/src/index.ts index 3df3cf26..ec501d2f 100644 --- a/packages/javascript/src/index.ts +++ b/packages/javascript/src/index.ts @@ -9,12 +9,8 @@ export {default as executeEmbeddedRecoveryFlow} from './api/auth/executeEmbedded 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/management/getOrganizationUnitChildren'; +export {default as getOrganizationUnitChildren} from './api/management/organization-units/getOrganizationUnitChildren'; +export type {GetOrganizationUnitChildrenConfig} from './api/management/organization-units/getOrganizationUnitChildren'; export {default as getUserInfo} from './api/auth/getUserInfo'; export {default as getUsersMe} from './api/account/getUsersMe'; export type {GetUsersMeConfig} from './api/account/getUsersMe'; @@ -99,6 +95,303 @@ 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 regenerateAgentSecret} from './api/management/agents/regenerateAgentSecret'; +export type { + RegenerateAgentSecretConfig, + RegenerateAgentSecretResult, +} from './api/management/agents/regenerateAgentSecret'; +export {default as getAgentType} from './api/management/agent-types/getAgentType'; +export type {GetAgentTypeConfig} from './api/management/agent-types/getAgentType'; +export {default as updateAgentType} from './api/management/agent-types/updateAgentType'; +export type {UpdateAgentTypeConfig} from './api/management/agent-types/updateAgentType'; +export {default as getUserUsages} from './api/management/users/getUserUsages'; +export type {GetUserUsagesConfig} from './api/management/users/getUserUsages'; +export {default as updateUserCredentials} from './api/management/users/updateUserCredentials'; +export type {UpdateUserCredentialsConfig} from './api/management/users/updateUserCredentials'; +export {default as getUserType} from './api/management/user-types/getUserType'; +export type {GetUserTypeConfig} from './api/management/user-types/getUserType'; +export {default as createUserType} from './api/management/user-types/createUserType'; +export type {CreateUserTypeConfig} from './api/management/user-types/createUserType'; +export {default as updateUserType} from './api/management/user-types/updateUserType'; +export type {UpdateUserTypeConfig} from './api/management/user-types/updateUserType'; +export {default as deleteUserType} from './api/management/user-types/deleteUserType'; +export type {DeleteUserTypeConfig} from './api/management/user-types/deleteUserType'; +export {default as previewCimdDocument} from './api/management/applications/previewCimdDocument'; +export type {PreviewCimdDocumentConfig} from './api/management/applications/previewCimdDocument'; +export {default as regenerateClientSecret} from './api/management/applications/regenerateClientSecret'; +export type { + RegenerateClientSecretConfig, + RegenerateClientSecretResult, +} from './api/management/applications/regenerateClientSecret'; +export {default as regenerateFlowSecret} from './api/management/applications/regenerateFlowSecret'; +export type { + RegenerateFlowSecretConfig, + RegenerateFlowSecretResult, +} from './api/management/applications/regenerateFlowSecret'; +export {default as executeAdministrationFlow} from './api/management/administration/executeAdministrationFlow'; +export {default as findAdministrationFlows} from './api/management/administration/findAdministrationFlows'; +export {default as resolveAdministrationFlowHandles} from './api/management/administration/resolveAdministrationFlowHandles'; +export {default as resolveAdministrationFlowId} from './api/management/administration/resolveAdministrationFlowId'; +export {default as resolveAdministrationFlows} from './api/management/administration/resolveAdministrationFlows'; +export {default as runAdministrationFlow} from './api/management/administration/runAdministrationFlow'; +export {default as createAgentType} from './api/management/agent-types/createAgentType'; +export type {CreateAgentTypeConfig} from './api/management/agent-types/createAgentType'; +export {default as deleteAgentType} from './api/management/agent-types/deleteAgentType'; +export type {DeleteAgentTypeConfig} from './api/management/agent-types/deleteAgentType'; +export {default as getOrganizationUnits} from './api/management/organization-units/getOrganizationUnits'; +export type {GetOrganizationUnitsConfig} from './api/management/organization-units/getOrganizationUnits'; +export {default as getOrganizationUnit} from './api/management/organization-units/getOrganizationUnit'; +export type {GetOrganizationUnitConfig} from './api/management/organization-units/getOrganizationUnit'; +export {default as getOrganizationUnitByPath} from './api/management/organization-units/getOrganizationUnitByPath'; +export type {GetOrganizationUnitByPathConfig} from './api/management/organization-units/getOrganizationUnitByPath'; +export {default as createOrganizationUnit} from './api/management/organization-units/createOrganizationUnit'; +export type {CreateOrganizationUnitConfig} from './api/management/organization-units/createOrganizationUnit'; +export {default as updateOrganizationUnit} from './api/management/organization-units/updateOrganizationUnit'; +export type {UpdateOrganizationUnitConfig} from './api/management/organization-units/updateOrganizationUnit'; +export {default as updateOrganizationUnitByPath} from './api/management/organization-units/updateOrganizationUnitByPath'; +export type {UpdateOrganizationUnitByPathConfig} from './api/management/organization-units/updateOrganizationUnitByPath'; +export {default as deleteOrganizationUnit} from './api/management/organization-units/deleteOrganizationUnit'; +export type {DeleteOrganizationUnitConfig} from './api/management/organization-units/deleteOrganizationUnit'; +export {default as deleteOrganizationUnitByPath} from './api/management/organization-units/deleteOrganizationUnitByPath'; +export type {DeleteOrganizationUnitByPathConfig} from './api/management/organization-units/deleteOrganizationUnitByPath'; +export {default as getOrganizationUnitUsers} from './api/management/organization-units/getOrganizationUnitUsers'; +export type {GetOrganizationUnitUsersConfig} from './api/management/organization-units/getOrganizationUnitUsers'; +export {default as getOrganizationUnitGroups} from './api/management/organization-units/getOrganizationUnitGroups'; +export type {GetOrganizationUnitGroupsConfig} from './api/management/organization-units/getOrganizationUnitGroups'; +export {default as getOrganizationUnitRoles} from './api/management/organization-units/getOrganizationUnitRoles'; +export type {GetOrganizationUnitRolesConfig} from './api/management/organization-units/getOrganizationUnitRoles'; +export {default as getUsersByPath} from './api/management/users/getUsersByPath'; +export type {GetUsersByPathConfig} from './api/management/users/getUsersByPath'; +export {default as createUserByPath} from './api/management/users/createUserByPath'; +export type {CreateUserByPathConfig} from './api/management/users/createUserByPath'; +export {default as getUserSharingPolicies} from './api/management/users/getUserSharingPolicies'; +export type {GetUserSharingPoliciesConfig} from './api/management/users/getUserSharingPolicies'; +export {default as createUserSharingPolicy} from './api/management/users/createUserSharingPolicy'; +export type {CreateUserSharingPolicyConfig} from './api/management/users/createUserSharingPolicy'; +export {default as getUserSharingPolicy} from './api/management/users/getUserSharingPolicy'; +export type {GetUserSharingPolicyConfig} from './api/management/users/getUserSharingPolicy'; +export {default as updateUserSharingPolicy} from './api/management/users/updateUserSharingPolicy'; +export type {UpdateUserSharingPolicyConfig} from './api/management/users/updateUserSharingPolicy'; +export {default as deleteUserSharingPolicy} from './api/management/users/deleteUserSharingPolicy'; +export type {DeleteUserSharingPolicyConfig} from './api/management/users/deleteUserSharingPolicy'; +export {default as getUserOverlayRules} from './api/management/users/getUserOverlayRules'; +export type {GetUserOverlayRulesConfig} from './api/management/users/getUserOverlayRules'; +export {default as getApplicationSharingPolicies} from './api/management/applications/getApplicationSharingPolicies'; +export type {GetApplicationSharingPoliciesConfig} from './api/management/applications/getApplicationSharingPolicies'; +export {default as createApplicationSharingPolicy} from './api/management/applications/createApplicationSharingPolicy'; +export type {CreateApplicationSharingPolicyConfig} from './api/management/applications/createApplicationSharingPolicy'; +export {default as getApplicationSharingPolicy} from './api/management/applications/getApplicationSharingPolicy'; +export type {GetApplicationSharingPolicyConfig} from './api/management/applications/getApplicationSharingPolicy'; +export {default as updateApplicationSharingPolicy} from './api/management/applications/updateApplicationSharingPolicy'; +export type {UpdateApplicationSharingPolicyConfig} from './api/management/applications/updateApplicationSharingPolicy'; +export {default as deleteApplicationSharingPolicy} from './api/management/applications/deleteApplicationSharingPolicy'; +export type {DeleteApplicationSharingPolicyConfig} from './api/management/applications/deleteApplicationSharingPolicy'; +export {default as getApplicationOverlayRules} from './api/management/applications/getApplicationOverlayRules'; +export type {GetApplicationOverlayRulesConfig} from './api/management/applications/getApplicationOverlayRules'; +export {default as getResourceServers} from './api/management/resource-servers/getResourceServers'; +export type {GetResourceServersConfig} from './api/management/resource-servers/getResourceServers'; +export {default as getResourceServer} from './api/management/resource-servers/getResourceServer'; +export type {GetResourceServerConfig} from './api/management/resource-servers/getResourceServer'; +export {default as createResourceServer} from './api/management/resource-servers/createResourceServer'; +export type {CreateResourceServerConfig} from './api/management/resource-servers/createResourceServer'; +export {default as updateResourceServer} from './api/management/resource-servers/updateResourceServer'; +export type {UpdateResourceServerConfig} from './api/management/resource-servers/updateResourceServer'; +export {default as deleteResourceServer} from './api/management/resource-servers/deleteResourceServer'; +export type {DeleteResourceServerConfig} from './api/management/resource-servers/deleteResourceServer'; +export {default as getResources} from './api/management/resource-servers/getResources'; +export type {GetResourcesConfig} from './api/management/resource-servers/getResources'; +export {default as getResource} from './api/management/resource-servers/getResource'; +export type {GetResourceConfig} from './api/management/resource-servers/getResource'; +export {default as createResource} from './api/management/resource-servers/createResource'; +export type {CreateResourceConfig} from './api/management/resource-servers/createResource'; +export {default as updateResource} from './api/management/resource-servers/updateResource'; +export type {UpdateResourceConfig} from './api/management/resource-servers/updateResource'; +export {default as deleteResource} from './api/management/resource-servers/deleteResource'; +export type {DeleteResourceConfig} from './api/management/resource-servers/deleteResource'; +export {default as getActions} from './api/management/resource-servers/getActions'; +export type {GetActionsConfig} from './api/management/resource-servers/getActions'; +export {default as getAction} from './api/management/resource-servers/getAction'; +export type {GetActionConfig} from './api/management/resource-servers/getAction'; +export {default as createAction} from './api/management/resource-servers/createAction'; +export type {CreateActionConfig} from './api/management/resource-servers/createAction'; +export {default as updateAction} from './api/management/resource-servers/updateAction'; +export type {UpdateActionConfig} from './api/management/resource-servers/updateAction'; +export {default as getConnection} from './api/management/connections/getConnection'; +export type {GetConnectionConfig} from './api/management/connections/getConnection'; +export {default as createConnection} from './api/management/connections/createConnection'; +export type {CreateConnectionConfig} from './api/management/connections/createConnection'; +export {default as updateConnection} from './api/management/connections/updateConnection'; +export type {UpdateConnectionConfig} from './api/management/connections/updateConnection'; +export {default as deleteConnection} from './api/management/connections/deleteConnection'; +export type {DeleteConnectionConfig} from './api/management/connections/deleteConnection'; +export {default as getConnectionUsages} from './api/management/connections/getConnectionUsages'; +export type {GetConnectionUsagesConfig} from './api/management/connections/getConnectionUsages'; +export {default as getConnectionsMeta} from './api/management/connections/getConnectionsMeta'; +export type {GetConnectionsMetaConfig} from './api/management/connections/getConnectionsMeta'; +export {default as getFlow} from './api/management/flows/getFlow'; +export type {GetFlowConfig} from './api/management/flows/getFlow'; +export {default as createFlow} from './api/management/flows/createFlow'; +export type {CreateFlowConfig} from './api/management/flows/createFlow'; +export {default as updateFlow} from './api/management/flows/updateFlow'; +export type {UpdateFlowConfig} from './api/management/flows/updateFlow'; +export {default as deleteFlow} from './api/management/flows/deleteFlow'; +export type {DeleteFlowConfig} from './api/management/flows/deleteFlow'; +export {default as getFlowVersions} from './api/management/flows/getFlowVersions'; +export type {GetFlowVersionsConfig} from './api/management/flows/getFlowVersions'; +export {default as getFlowVersion} from './api/management/flows/getFlowVersion'; +export type {GetFlowVersionConfig} from './api/management/flows/getFlowVersion'; +export {default as restoreFlowVersion} from './api/management/flows/restoreFlowVersion'; +export type {RestoreFlowVersionConfig} from './api/management/flows/restoreFlowVersion'; +export {default as getFlowUsages} from './api/management/flows/getFlowUsages'; +export type {GetFlowUsagesConfig} from './api/management/flows/getFlowUsages'; +export {default as getNotificationTemplates} from './api/management/notification-templates/getNotificationTemplates'; +export type {GetNotificationTemplatesConfig} from './api/management/notification-templates/getNotificationTemplates'; +export {default as getNotificationTemplate} from './api/management/notification-templates/getNotificationTemplate'; +export type {GetNotificationTemplateConfig} from './api/management/notification-templates/getNotificationTemplate'; +export {default as createNotificationTemplate} from './api/management/notification-templates/createNotificationTemplate'; +export type {CreateNotificationTemplateConfig} from './api/management/notification-templates/createNotificationTemplate'; +export {default as updateNotificationTemplate} from './api/management/notification-templates/updateNotificationTemplate'; +export type {UpdateNotificationTemplateConfig} from './api/management/notification-templates/updateNotificationTemplate'; +export {default as deleteNotificationTemplate} from './api/management/notification-templates/deleteNotificationTemplate'; +export type {DeleteNotificationTemplateConfig} from './api/management/notification-templates/deleteNotificationTemplate'; +export {default as getDesignThemes} from './api/management/design/getDesignThemes'; +export type {GetDesignThemesConfig} from './api/management/design/getDesignThemes'; +export {default as getDesignTheme} from './api/management/design/getDesignTheme'; +export type {GetDesignThemeConfig} from './api/management/design/getDesignTheme'; +export {default as createDesignTheme} from './api/management/design/createDesignTheme'; +export type {CreateDesignThemeConfig} from './api/management/design/createDesignTheme'; +export {default as updateDesignTheme} from './api/management/design/updateDesignTheme'; +export type {UpdateDesignThemeConfig} from './api/management/design/updateDesignTheme'; +export {default as deleteDesignTheme} from './api/management/design/deleteDesignTheme'; +export type {DeleteDesignThemeConfig} from './api/management/design/deleteDesignTheme'; +export {default as getDesignThemeUsages} from './api/management/design/getDesignThemeUsages'; +export type {GetDesignThemeUsagesConfig} from './api/management/design/getDesignThemeUsages'; +export {default as getLayouts} from './api/management/design/getLayouts'; +export type {GetLayoutsConfig} from './api/management/design/getLayouts'; +export {default as getLayout} from './api/management/design/getLayout'; +export type {GetLayoutConfig} from './api/management/design/getLayout'; +export {default as createLayout} from './api/management/design/createLayout'; +export type {CreateLayoutConfig} from './api/management/design/createLayout'; +export {default as updateLayout} from './api/management/design/updateLayout'; +export type {UpdateLayoutConfig} from './api/management/design/updateLayout'; +export {default as deleteLayout} from './api/management/design/deleteLayout'; +export type {DeleteLayoutConfig} from './api/management/design/deleteLayout'; +export {default as getLayoutUsages} from './api/management/design/getLayoutUsages'; +export type {GetLayoutUsagesConfig} from './api/management/design/getLayoutUsages'; +export {default as resolveDesign} from './api/management/design/resolveDesign'; +export type {ResolveDesignConfig} from './api/management/design/resolveDesign'; +export {default as getLanguages} from './api/management/i18n/getLanguages'; +export type {GetLanguagesConfig} from './api/management/i18n/getLanguages'; +export {default as resolveTranslations} from './api/management/i18n/resolveTranslations'; +export type {ResolveTranslationsConfig} from './api/management/i18n/resolveTranslations'; +export {default as resolveTranslation} from './api/management/i18n/resolveTranslation'; +export type {ResolveTranslationConfig} from './api/management/i18n/resolveTranslation'; +export {default as setTranslations} from './api/management/i18n/setTranslations'; +export type {SetTranslationsConfig} from './api/management/i18n/setTranslations'; +export {default as deleteTranslations} from './api/management/i18n/deleteTranslations'; +export type {DeleteTranslationsConfig} from './api/management/i18n/deleteTranslations'; +export {default as setTranslation} from './api/management/i18n/setTranslation'; +export type {SetTranslationConfig} from './api/management/i18n/setTranslation'; +export {default as deleteTranslation} from './api/management/i18n/deleteTranslation'; +export type {DeleteTranslationConfig} from './api/management/i18n/deleteTranslation'; +export {default as getServerConfigs} from './api/management/server-config/getServerConfigs'; +export type {GetServerConfigsConfig} from './api/management/server-config/getServerConfigs'; +export {default as getServerConfig} from './api/management/server-config/getServerConfig'; +export type {GetServerConfigConfig} from './api/management/server-config/getServerConfig'; +export {default as updateServerConfig} from './api/management/server-config/updateServerConfig'; +export type {UpdateServerConfigConfig} from './api/management/server-config/updateServerConfig'; +export {default as getGateways} from './api/management/gateways/getGateways'; +export type {GetGatewaysConfig} from './api/management/gateways/getGateways'; +export {default as getGateway} from './api/management/gateways/getGateway'; +export type {GetGatewayConfig} from './api/management/gateways/getGateway'; +export {default as createGateway} from './api/management/gateways/createGateway'; +export type {CreateGatewayConfig} from './api/management/gateways/createGateway'; +export {default as updateGateway} from './api/management/gateways/updateGateway'; +export type {UpdateGatewayConfig} from './api/management/gateways/updateGateway'; +export {default as deleteGateway} from './api/management/gateways/deleteGateway'; +export type {DeleteGatewayConfig} from './api/management/gateways/deleteGateway'; +export {default as applyGatewayConfiguration} from './api/management/gateways/applyGatewayConfiguration'; +export type {ApplyGatewayConfigurationConfig} from './api/management/gateways/applyGatewayConfiguration'; +export {default as revertGatewayConfiguration} from './api/management/gateways/revertGatewayConfiguration'; +export type {RevertGatewayConfigurationConfig} from './api/management/gateways/revertGatewayConfiguration'; +export {default as getGatewayConfigurationDiff} from './api/management/gateways/getGatewayConfigurationDiff'; +export type {GetGatewayConfigurationDiffConfig} from './api/management/gateways/getGatewayConfigurationDiff'; +export {default as getGatewayAppliedConfiguration} from './api/management/gateways/getGatewayAppliedConfiguration'; +export type {GetGatewayAppliedConfigurationConfig} from './api/management/gateways/getGatewayAppliedConfiguration'; +export {default as getGatewayAppliedVersion} from './api/management/gateways/getGatewayAppliedVersion'; +export type {GetGatewayAppliedVersionConfig} from './api/management/gateways/getGatewayAppliedVersion'; +export {default as getVariables} from './api/management/variables/getVariables'; +export type {GetVariablesConfig} from './api/management/variables/getVariables'; +export {default as getVariable} from './api/management/variables/getVariable'; +export type {GetVariableConfig} from './api/management/variables/getVariable'; +export {default as createVariable} from './api/management/variables/createVariable'; +export type {CreateVariableConfig} from './api/management/variables/createVariable'; +export {default as updateVariable} from './api/management/variables/updateVariable'; +export type {UpdateVariableConfig} from './api/management/variables/updateVariable'; +export {default as deleteVariable} from './api/management/variables/deleteVariable'; +export type {DeleteVariableConfig} from './api/management/variables/deleteVariable'; +export {default as getGatewayVariables} from './api/management/gateways/getGatewayVariables'; +export type {GetGatewayVariablesConfig} from './api/management/gateways/getGatewayVariables'; +export {default as getGatewayVariable} from './api/management/gateways/getGatewayVariable'; +export type {GetGatewayVariableConfig} from './api/management/gateways/getGatewayVariable'; +export {default as createGatewayVariable} from './api/management/gateways/createGatewayVariable'; +export type {CreateGatewayVariableConfig} from './api/management/gateways/createGatewayVariable'; +export {default as updateGatewayVariable} from './api/management/gateways/updateGatewayVariable'; +export type {UpdateGatewayVariableConfig} from './api/management/gateways/updateGatewayVariable'; +export {default as deleteGatewayVariable} from './api/management/gateways/deleteGatewayVariable'; +export type {DeleteGatewayVariableConfig} from './api/management/gateways/deleteGatewayVariable'; +export {default as getSecrets} from './api/management/secrets/getSecrets'; +export type {GetSecretsConfig} from './api/management/secrets/getSecrets'; +export {default as getSecret} from './api/management/secrets/getSecret'; +export type {GetSecretConfig} from './api/management/secrets/getSecret'; +export {default as createSecret} from './api/management/secrets/createSecret'; +export type {CreateSecretConfig} from './api/management/secrets/createSecret'; +export {default as updateSecret} from './api/management/secrets/updateSecret'; +export type {UpdateSecretConfig} from './api/management/secrets/updateSecret'; +export {default as deleteSecret} from './api/management/secrets/deleteSecret'; +export type {DeleteSecretConfig} from './api/management/secrets/deleteSecret'; +export {default as getGatewaySecrets} from './api/management/gateways/getGatewaySecrets'; +export type {GetGatewaySecretsConfig} from './api/management/gateways/getGatewaySecrets'; +export {default as getGatewaySecret} from './api/management/gateways/getGatewaySecret'; +export type {GetGatewaySecretConfig} from './api/management/gateways/getGatewaySecret'; +export {default as createGatewaySecret} from './api/management/gateways/createGatewaySecret'; +export type {CreateGatewaySecretConfig} from './api/management/gateways/createGatewaySecret'; +export {default as updateGatewaySecret} from './api/management/gateways/updateGatewaySecret'; +export type {UpdateGatewaySecretConfig} from './api/management/gateways/updateGatewaySecret'; +export {default as deleteGatewaySecret} from './api/management/gateways/deleteGatewaySecret'; +export type {DeleteGatewaySecretConfig} from './api/management/gateways/deleteGatewaySecret'; +export {default as getConfigurationVersions} from './api/management/configuration/getConfigurationVersions'; +export type {GetConfigurationVersionsConfig} from './api/management/configuration/getConfigurationVersions'; +export {default as getConfigurationVersion} from './api/management/configuration/getConfigurationVersion'; +export type {GetConfigurationVersionConfig} from './api/management/configuration/getConfigurationVersion'; +export {default as createConfigurationVersion} from './api/management/configuration/createConfigurationVersion'; +export type {CreateConfigurationVersionConfig} from './api/management/configuration/createConfigurationVersion'; +export {default as exportConfiguration} from './api/management/configuration/exportConfiguration'; +export type {ExportConfigurationConfig} from './api/management/configuration/exportConfiguration'; +export {default as importConfiguration} from './api/management/configuration/importConfiguration'; +export type {ImportConfigurationConfig} from './api/management/configuration/importConfiguration'; +export {default as deleteImportedConfiguration} from './api/management/configuration/deleteImportedConfiguration'; +export type {DeleteImportedConfigurationConfig} from './api/management/configuration/deleteImportedConfiguration'; +export {default as suspendUser} from './api/management/users/suspendUser'; +export type {SuspendUserConfig} from './api/management/users/suspendUser'; +export {default as unsuspendUser} from './api/management/users/unsuspendUser'; +export type {UnsuspendUserConfig} from './api/management/users/unsuspendUser'; +export {default as unlockUser} from './api/management/users/unlockUser'; +export type {UnlockUserConfig} from './api/management/users/unlockUser'; +export {default as suspendAgent} from './api/management/agents/suspendAgent'; +export type {SuspendAgentConfig} from './api/management/agents/suspendAgent'; +export {default as unsuspendAgent} from './api/management/agents/unsuspendAgent'; +export type {UnsuspendAgentConfig} from './api/management/agents/unsuspendAgent'; +export {default as unlockAgent} from './api/management/agents/unlockAgent'; +export type {UnlockAgentConfig} from './api/management/agents/unlockAgent'; +export {default as suspendApplication} from './api/management/applications/suspendApplication'; +export type {SuspendApplicationConfig} from './api/management/applications/suspendApplication'; +export {default as unsuspendApplication} from './api/management/applications/unsuspendApplication'; +export type {UnsuspendApplicationConfig} from './api/management/applications/unsuspendApplication'; +export {default as deleteAction} from './api/management/resource-servers/deleteAction'; +export type {DeleteActionConfig} from './api/management/resource-servers/deleteAction'; +export {default as getConnections} from './api/management/connections/getConnections'; +export type {GetConnectionsConfig, GetConnectionsResult} from './api/management/connections/getConnections'; export {default as ApplicationNativeAuthenticationConstants} from './constants/ApplicationNativeAuthenticationConstants'; export {default as TokenConstants} from './constants/TokenConstants'; @@ -112,6 +405,13 @@ 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 AdministrationModes, + ADMINISTRATION_FLOW_TYPE, + AdministrationFlowConfigKey, + AdministrationFlowData, + AdministrationFlowInput, +} from './constants/AdministrationConstants'; export {default as UserSelectConstants} from './constants/UserSelectConstants'; export {default as AuthFlowSelectConstants} from './constants/AuthFlowSelectConstants'; export {default as UserTypeSelectConstants} from './constants/UserTypeSelectConstants'; @@ -119,6 +419,7 @@ export {default as SelectConstants} from './constants/SelectConstants'; export {default as ThunderIDError} from './errors/ThunderIDError'; export {default as ThunderIDAPIError} from './errors/ThunderIDAPIError'; +export {default as AdministrationFlowError} from './errors/AdministrationFlowError'; export {default as ThunderIDRuntimeError} from './errors/ThunderIDRuntimeError'; export {InvalidLogoutTokenError, ThunderIDAuthException} from './errors/exception'; @@ -239,8 +540,18 @@ export type {ComponentRenderContext, ComponentRenderer, ComponentsExtensions} fr export type {TokenResponse, IdToken, TokenExchangeRequestConfig} from './models/token'; export type {BackchannelLogoutResult, LogoutTokenClaims, LogoutTokenValidationOptions} from './models/logout-token'; export type {AgentConfig} from './models/agent'; -export type {ApiError, ApiFetcher, ApiFilteringParams, ApiPaginationLink, ManagementRequestConfig} from './models/api'; +export type { + ApiError, + ApiFetcher, + ApiFilteringParams, + ApiPaginationLink, + ManagementRequestConfig, + ServerError, + ServerErrorMessage, +} from './models/api'; export { + APPLICATION_GRANT_TYPES, + DEFAULT_APPLICATION_GRANT_TYPES, InboundAuthTypes, OAuth2GrantTypes, OAuth2ResponseTypes, @@ -253,7 +564,10 @@ export type { AndroidAttestationConfig, AppleAttestationConfig, Application, + ApplicationAccessStatus, + ApplicationAccessStatusValue, ApplicationListResponse, + ApplicationSubmission, ApplicationType, AssertionConfig, AttestationConfig, @@ -274,14 +588,197 @@ export type { UpdateApplicationRequest, UserInfoConfig, UserInfoResponseType, + CimdPreviewResponse, } from './models/application'; export type { CreateManagedUserRequest, + CreateUserByPathRequest, ManagedUser, ManagedUserListResponse, UpdateManagedUserRequest, + UpdateUserCredentialsRequest, + UserUsage, + UserUsagesResponse, } from './models/managed-user'; -export type {ManagedFlow, ManagedFlowListResponse} from './models/managed-flow'; +export type { + AccountLifecycleOptions, + AccountLifecycleResult, + AdministrationFlowConfig, + AdministrationFlowConfigKeyValue, + AdministrationFlowExecutionResponse, + AdministrationMode, + AdministrationFlowStepError, + AdministrationOptions, + FlowI18nMessage, +} from './models/administration'; +export type { + AgentTypeDetails, + ArrayItemDefinition, + ArrayPropertyDefinition, + BooleanPropertyDefinition, + CreateAgentTypeRequest, + CreateUserTypeRequest, + EntityTypeSchema, + EntityTypeSystemAttributes, + ManagedUserTypeDetails, + NumberPropertyDefinition, + ObjectPropertyDefinition, + PropertyDefinition, + StringPropertyDefinition, + UpdateAgentTypeRequest, + UpdateUserTypeRequest, +} from './models/entity-type'; +export type { + ManagedFlow, + ManagedFlowDefinition, + ManagedFlowListResponse, + ManagedFlowNode, + ManagedFlowRequest, + ManagedFlowVersion, + ManagedFlowVersionInfo, + ManagedFlowVersionListResponse, +} from './models/managed-flow'; +export type { + CreateOrganizationUnitRequest, + OrganizationUnit, + OrganizationUnitBasic, + OrganizationUnitGroup, + OrganizationUnitGroupListResponse, + OrganizationUnitListResponse, + OrganizationUnitReference, + OrganizationUnitRole, + OrganizationUnitRoleListResponse, + OrganizationUnitUser, + OrganizationUnitUserListResponse, + UpdateOrganizationUnitByPathRequest, + UpdateOrganizationUnitRequest, +} from './models/organization-unit'; +export type { + OverlayRule, + ResolvedOverlay, + SharingPolicy, + SharingPolicyAllUnits, + SharingPolicyChildUnit, + SharingPolicyChildUnits, + SharingPolicyChildUnitsAll, + SharingPolicyChildUnitsSelected, + SharingPolicyListResponse, + SharingPolicyOtherTreeRoots, + SharingPolicyOtherTreeRootsAll, + SharingPolicyOtherTreeRootsSelected, + SharingPolicyRequest, + SharingPolicyUpdateRequest, +} from './models/sharing-policy'; +export type { + CreateActionRequest, + CreateResourceRequest, + CreateResourceServerRequest, + ManagedAction, + ManagedActionKind, + ManagedActionListResponse, + ManagedResource, + ManagedResourceListResponse, + ResourceServer, + ResourceServerAuthorizationEngine, + ResourceServerListResponse, + ResourceServerType, + UpdateActionRequest, + UpdateResourceRequest, + UpdateResourceServerRequest, +} from './models/resource-server'; +export type {ResourceUsage, ResourceUsagesResponse} from './models/resource-usage'; +export type { + Connection, + ConnectionCategory, + ConnectionInstance, + ConnectionListResponse, + ConnectionMetaResponse, + ConnectionRequest, + ConnectionSummary, + ConnectionType, + ConnectionTypeWithUsages, + ConnectionUsage, + ConnectionUsagesResponse, + ConnectionVendorMeta, +} from './models/connection'; +export type { + CreateEmailTemplateRequest, + CreateNotificationTemplateRequest, + CreateSmsTemplateRequest, + EmailNotificationTemplate, + EmailTemplateContent, + NotificationChannel, + NotificationTemplate, + NotificationTemplateDesign, + NotificationTemplateListResponse, + NotificationTemplateSummary, + SmsNotificationTemplate, + SmsTemplateContent, + UpdateEmailTemplateRequest, + UpdateNotificationTemplateRequest, + UpdateSmsTemplateRequest, +} from './models/notification-template'; +export type { + DesignLayout, + DesignLayoutListItem, + DesignLayoutListResponse, + DesignLayoutRequest, + DesignResolveResponse, + DesignResolveType, + DesignTheme, + DesignThemeListItem, + DesignThemeListResponse, + DesignThemeRequest, +} from './models/design'; +export type { + LanguageListResponse, + LanguageTranslationsResponse, + ManagedTranslation, + SetTranslationRequest, + SetTranslationsRequest, + TranslationsByNamespace, +} from './models/i18n-management'; +export type {ServerConfigLayers} from './models/server-config'; +export type { + ApplyGatewayConfigurationRequest, + Gateway, + GatewayApplyResult, + GatewayAppliedConfiguration, + GatewayAppliedVersion, + GatewayConfigurationChange, + GatewayConfigurationDiff, + GatewayRegistration, + GatewayValueChanges, + RegisterGatewayRequest, + RevertGatewayConfigurationRequest, + UpdateGatewayRequest, +} from './models/gateway'; +export type { + Secret, + SecretListResponse, + SecretRequest, + SecretUpdateRequest, + Variable, + VariableListResponse, + VariableRequest, + VariableUpdateRequest, +} from './models/variable-store'; +export type { + CaptureConfigurationVersionRequest, + ConfigurationActor, + ConfigurationExportOptions, + ConfigurationExportRequest, + ConfigurationExportResponse, + ConfigurationImportOptions, + ConfigurationImportOutcome, + ConfigurationImportRequest, + ConfigurationImportResponse, + ConfigurationResourceDeletion, + ConfigurationValueReference, + ConfigurationVersion, + DeleteImportedConfigurationRequest, + DeleteImportedConfigurationResponse, +} from './models/configuration'; export type {ManagedUserType, ManagedUserTypeListResponse} from './models/managed-user-type'; export type { Agent, @@ -349,6 +846,12 @@ 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 getApplicationOAuthConfig} from './utils/getApplicationOAuthConfig'; +export {default as getApplicationClientSecret} from './utils/getApplicationClientSecret'; +export {default as parseRedirectUris} from './utils/parseRedirectUris'; +export {default as isApplicationSubmissionValid} from './utils/isApplicationSubmissionValid'; +export {default as toCreateApplicationRequest} from './utils/toCreateApplicationRequest'; +export {default as toUpdateApplicationRequest} from './utils/toUpdateApplicationRequest'; export {default as toManagementErrorMessage} from './utils/toManagementErrorMessage'; export {default as resolveManagedIdentityName} from './utils/resolveManagedIdentityName'; export {default as hasPermission} from './utils/hasPermission'; diff --git a/packages/javascript/src/models/administration.ts b/packages/javascript/src/models/administration.ts new file mode 100644 index 00000000..3da9e36e --- /dev/null +++ b/packages/javascript/src/models/administration.ts @@ -0,0 +1,87 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import AdministrationModes, {AdministrationFlowConfigKey} from '../constants/AdministrationConstants'; + +/** + * How an operation that changes state is carried out. See {@link AdministrationModes}. + */ +export type AdministrationMode = (typeof AdministrationModes)[keyof typeof AdministrationModes]; + +/** + * A key of the `flow` server-config section. See {@link AdministrationFlowConfigKey}. + */ +export type AdministrationFlowConfigKeyValue = + (typeof AdministrationFlowConfigKey)[keyof typeof AdministrationFlowConfigKey]; + +/** + * Options of an operation that can run through an administration flow. + */ +export interface AdministrationOptions { + /** + * `flow` runs the administration flow the server configures for the operation, and calls the API + * directly only when none is configured. Reading that configuration needs a token that may read + * server configuration. + * @default 'native' + */ + administrationMode?: AdministrationMode; +} + +/** + * One entry of the `flow` server-config section, naming the administration flow for an operation. + */ +export interface AdministrationFlowConfig { + defaultHandle?: string; + expirySeconds?: number; +} + +/** + * One localizable message of a flow error, as the server serializes it. + */ +export interface FlowI18nMessage { + defaultValue?: string; + key?: string; + params?: Record; +} + +/** + * The error a refused flow step carries: the step's own error code and reason. + */ +export interface AdministrationFlowStepError { + code?: string; + description?: FlowI18nMessage; + message?: FlowI18nMessage; +} + +/** + * The `POST /flow/execute` response, narrowed to what an administration operation inspects. + * `data.additionalData` carries the values a flow produces. + */ +export interface AdministrationFlowExecutionResponse { + data?: { + additionalData?: Record; + }; + error?: AdministrationFlowStepError; + executionId?: string; + flowStatus?: string; +} + +/** + * Options of an account lifecycle operation, such as suspending a user. + */ +export interface AccountLifecycleOptions { + /** + * A note from the operator, recorded by the flow (for example the reason for a suspension). + */ + operatorNote?: string; +} + +/** + * The outcome of an account lifecycle operation. + */ +export interface AccountLifecycleResult { + /** + * Whether the flow's email step sent its notification. Absent when the flow has no email step. + */ + emailSent?: boolean; +} diff --git a/packages/javascript/src/models/agent-resource.ts b/packages/javascript/src/models/agent-resource.ts index f1a7c1e2..1b75af15 100644 --- a/packages/javascript/src/models/agent-resource.ts +++ b/packages/javascript/src/models/agent-resource.ts @@ -93,6 +93,11 @@ export interface UpdateAgentRequest { allowedUserTypes?: string[]; attributes?: Record; authFlowId?: string; + /** + * Whether the agent acts on behalf of a signed-in user. The server does not store it, so a full + * update sends it for the agent to be held to the delegated grant rules. + */ + delegated?: boolean; description?: string; inboundAuthConfig?: AgentInboundAuthConfig[]; isRegistrationFlowEnabled?: boolean; diff --git a/packages/javascript/src/models/api.ts b/packages/javascript/src/models/api.ts index 4ede556d..c4d0b22b 100644 --- a/packages/javascript/src/models/api.ts +++ b/packages/javascript/src/models/api.ts @@ -61,3 +61,25 @@ export interface ApiError { description: string; message: string; } + +/** + * A localizable message in a server error: its translation key, its English text, and the values its + * placeholders take. + */ +export interface ServerErrorMessage { + defaultValue?: string; + key?: string; + params?: Record; +} + +/** + * The error envelope the ThunderID server returns with a failed request. + */ +export interface ServerError { + /** + * The server's error code, such as `USR-1027`. + */ + code: string; + description?: ServerErrorMessage; + message?: ServerErrorMessage; +} diff --git a/packages/javascript/src/models/application.ts b/packages/javascript/src/models/application.ts index 601d28da..a079bbbb 100644 --- a/packages/javascript/src/models/application.ts +++ b/packages/javascript/src/models/application.ts @@ -77,6 +77,23 @@ export const REFRESH_TOKEN_ISSUING_GRANTS: readonly string[] = [ OAuth2GrantTypes.CIBA, ]; +/** + * The grant types the application forms offer. Each is labelled by `admin.applications.grant_types.`. + */ +export const APPLICATION_GRANT_TYPES: readonly string[] = [ + OAuth2GrantTypes.AUTHORIZATION_CODE, + OAuth2GrantTypes.REFRESH_TOKEN, + OAuth2GrantTypes.CLIENT_CREDENTIALS, +]; + +/** + * The grant types a new application starts with in the application forms. + */ +export const DEFAULT_APPLICATION_GRANT_TYPES: readonly string[] = [ + OAuth2GrantTypes.AUTHORIZATION_CODE, + OAuth2GrantTypes.REFRESH_TOKEN, +]; + /** * OAuth 2.0 response types an application can be configured with. */ @@ -242,6 +259,19 @@ export interface InboundAuthConfig { type: string; } +/** + * The access status values of an application. Applications are never locked. + */ +export type ApplicationAccessStatusValue = 'ACTIVE' | 'SUSPENDED'; + +/** + * An application's effective access status. The server computes it when the application is read or + * updated, and never accepts it on a write. + */ +export interface ApplicationAccessStatus { + value?: ApplicationAccessStatusValue; +} + /** * An application registered in ThunderID. */ @@ -270,6 +300,10 @@ export interface Application { recoveryFlowId?: string; registrationFlowId?: string; signOutFlowId?: string; + /** + * The effective access status, set by the server. + */ + status?: ApplicationAccessStatus; template?: string; themeId?: string; tosUri?: string; @@ -294,6 +328,7 @@ export type BasicApplication = Pick< | 'type' | 'template' | 'isReadOnly' + | 'status' > & { clientId?: string; }; @@ -308,11 +343,41 @@ export interface ApplicationListResponse { } /** - * The payload used to create or update an application. The server generates `id` and timestamps. + * The payload used to create or update an application. The server generates `id`, the timestamps and + * the access `status`. */ -export type CreateApplicationRequest = Omit; +export type CreateApplicationRequest = Omit; /** * The payload used to update an application. */ export type UpdateApplicationRequest = CreateApplicationRequest; + +/** + * The general fields and OAuth basics the application forms collect. + */ +export interface ApplicationSubmission { + description?: string; + grantTypes: string[]; + logoUrl?: string; + name: string; + /** + * Whether the client is public: it gets no client secret and must use PKCE. Only set on create. + */ + publicClient?: boolean; + redirectUris: string[]; + url?: string; +} + +/** + * What the server read from a client ID metadata document (CIMD): the client's name and links, and the + * OAuth configuration it declares. + */ +export interface CimdPreviewResponse { + contacts?: string[]; + inboundAuthConfig: InboundAuthConfig[]; + name: string; + policyUri?: string; + tosUri?: string; + url?: string; +} diff --git a/packages/javascript/src/models/config.ts b/packages/javascript/src/models/config.ts index 3941c248..257bc223 100644 --- a/packages/javascript/src/models/config.ts +++ b/packages/javascript/src/models/config.ts @@ -252,7 +252,9 @@ 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`, `agentTypes`, `roles`, `groups`) — + * management collections `applications`, `users`, `userTypes`, `flows`, `agents`, `agentTypes`, `roles`, `groups`, + * `organizationUnits`, `resourceServers`, `connections`, `notificationTemplates`, `design`, `i18n`, + * `serverConfig`, `gateways`, `variables`, `secrets`, `configurationVersions`, `import`, `export`) — * by default derived by concatenating `baseUrl` with a fixed path (e.g. `{baseUrl}/flow/execute`). * These do not participate in OIDC discovery. * @@ -286,7 +288,7 @@ export interface BaseConfig extends WithPreferences, WithExtensions */ agents?: string; /** - * The agent types collection URL used by the agent type listing operation. + * The agent types collection URL used by the agent type management operations. * If not provided, defaults to `{baseUrl}/agent-types`. */ agentTypes?: string; @@ -307,11 +309,34 @@ export interface BaseConfig extends WithPreferences, WithExtensions * Browser clients only. Unset entries default to a fixed path under `/api/auth`. */ backend?: BackendEndpoints; + /** + * The configuration versions collection URL used by the configuration versions management operations. + * A single version is addressed as `{configurationVersions}/{version}`. + * If not provided, defaults to `{baseUrl}/configuration-versions`. + */ + configurationVersions?: string; + /** + * The connections collection URL used by the connections management operations. + * The connections of a type are addressed as `{connections}/{type}`, and one of them as `{connections}/{type}/{id}`. + * If not provided, defaults to `{baseUrl}/connections`. + */ + connections?: string; + /** + * The design collection URL used by the theme, layout and design resolution operations. + * Themes are addressed as `{design}/themes`, layouts as `{design}/layouts`. + * If not provided, defaults to `{baseUrl}/design`. + */ + design?: string; /** * The end-session (logout) endpoint URL. * If not provided, resolved from the well-known discovery document. */ endSession?: string; + /** + * The export endpoint URL used by the configuration export operation. + * If not provided, defaults to `{baseUrl}/export`. + */ + export?: string; /** * The flow execution endpoint URL used by the embedded sign-in, sign-up, recovery, and user * onboarding flows. @@ -324,16 +349,35 @@ export interface BaseConfig extends WithPreferences, WithExtensions */ flowMeta?: string; /** - * The flows collection URL used by the flow listing operation. + * The flows collection URL used by the flow management operations. + * A single flow is addressed as `{flows}/{id}`. * If not provided, defaults to `{baseUrl}/flows`. */ flows?: string; + /** + * The gateways collection URL used by the gateway management operations. + * A single gateway is addressed as `{gateways}/{id}`, and its values as `{gateways}/{id}/variables` and + * `{gateways}/{id}/secrets`. + * If not provided, defaults to `{baseUrl}/gateways`. + */ + gateways?: 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 i18n collection URL used by the translation management operations. + * Languages are addressed as `{i18n}/languages`. + * If not provided, defaults to `{baseUrl}/i18n`. + */ + i18n?: string; + /** + * The import endpoint URL used by the configuration import operations. + * If not provided, defaults to `{baseUrl}/import`. + */ + import?: string; /** * The introspection endpoint URL. * If not provided, resolved from the well-known discovery document. @@ -344,6 +388,25 @@ export interface BaseConfig extends WithPreferences, WithExtensions * If not provided, resolved from the well-known discovery document. */ jwks?: string; + /** + * The notification templates collection URL used by the notification template operations. + * The templates of a channel are addressed as `{notificationTemplates}/{channel}/templates`. + * If not provided, defaults to `{baseUrl}/notification-templates`. + */ + notificationTemplates?: string; + /** + * The organization units collection URL used by the organization unit management operations. + * A single organization unit is addressed as `{organizationUnits}/{id}`, or by its handle path as + * `{organizationUnits}/tree/{path}`. + * If not provided, defaults to `{baseUrl}/organization-units`. + */ + organizationUnits?: string; + /** + * The resource servers collection URL used by the resource server, resource and action operations. + * A single resource server is addressed as `{resourceServers}/{id}`. + * If not provided, defaults to `{baseUrl}/resource-servers`. + */ + resourceServers?: string; /** * The token endpoint URL. * If not provided, resolved from the well-known discovery document. @@ -360,6 +423,18 @@ export interface BaseConfig extends WithPreferences, WithExtensions * If not provided, defaults to `{baseUrl}/roles`. */ roles?: string; + /** + * The secrets collection URL used by the server-level secret operations. + * A single secret is addressed as `{secrets}/{name}`. + * If not provided, defaults to `{baseUrl}/secrets`. + */ + secrets?: string; + /** + * The server configuration collection URL used by the server configuration operations. + * A single section is addressed as `{serverConfig}/{name}`. + * If not provided, defaults to `{baseUrl}/server-config`. + */ + serverConfig?: string; /** * The users collection URL used by the user management operations. * A single user is addressed as `{users}/{id}`. @@ -387,6 +462,12 @@ export interface BaseConfig extends WithPreferences, WithExtensions * If not provided, defaults to `{baseUrl}/user-types`. */ userTypes?: string; + /** + * The variables collection URL used by the server-level variable operations. + * A single variable is addressed as `{variables}/{name}`. + * If not provided, defaults to `{baseUrl}/variables`. + */ + variables?: string; /** * The OpenID Connect discovery document URL. * Defaults to `{baseUrl}/oauth2/token/.well-known/openid-configuration`. diff --git a/packages/javascript/src/models/configuration.ts b/packages/javascript/src/models/configuration.ts new file mode 100644 index 00000000..fe3de6b2 --- /dev/null +++ b/packages/javascript/src/models/configuration.ts @@ -0,0 +1,181 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +/** + * Who made a change. + */ +export interface ConfigurationActor { + id: string; + name: string; +} + +/** + * A place in the configuration that refers to a variable or a secret by name. + */ +export interface ConfigurationValueReference { + field?: string; + kind?: 'variable' | 'secret'; + list?: boolean; + name?: string; + resourceId?: string; + resourceName?: string; + resourceType?: string; +} + +/** + * A captured version of the configuration. + */ +export interface ConfigurationVersion { + createdAt?: string; + createdBy?: ConfigurationActor; + current?: boolean; + name?: string; + note?: string; + number?: number; + references?: ConfigurationValueReference[]; + /** + * The captured resources, as a declarative document. + */ + resources?: string; + skipped?: {code?: string; error?: string; resourceId?: string; resourceType?: string}[]; + unchanged?: boolean; + version?: string; +} + +/** + * The payload used to capture a configuration version. + */ +export interface CaptureConfigurationVersionRequest { + current?: boolean; + name?: string; + note?: string; +} + +/** + * Options of a configuration export. + */ +export interface ConfigurationExportOptions { + folderStructure?: { + customStructure?: Record; + fileNamingPattern?: string; + groupByType?: boolean; + }; + format?: 'yaml'; + includeDependencies?: boolean; + includeMetadata?: boolean; + pagination?: {limit?: number; page?: number}; +} + +/** + * The payload used to export resources. Each list names the identifiers of that resource type to export. + */ +export interface ConfigurationExportRequest { + agentTypes?: string[]; + applications?: string[]; + authorizationDetailTypes?: string[]; + connections?: string[]; + credentialConfigurations?: string[]; + flows?: string[]; + groups?: string[]; + layouts?: string[]; + notificationTemplates?: string[]; + options?: ConfigurationExportOptions; + organizationUnits?: string[]; + presentationDefinitions?: string[]; + resourceServers?: string[]; + roles?: string[]; + themes?: string[]; + translations?: string[]; + userTypes?: string[]; + users?: string[]; +} + +/** + * The exported resources, as a declarative document, and the environment variables it refers to. + */ +export interface ConfigurationExportResponse { + environment_variables: string; + resources: string; +} + +/** + * A runtime resource to remove during an import. + */ +export interface ConfigurationResourceDeletion { + id: string; + resourceType: string; +} + +/** + * Options of a configuration import. + */ +export interface ConfigurationImportOptions { + continueOnError?: boolean; + target?: 'runtime'; + upsert?: boolean; +} + +/** + * The payload used to import declarative resources. It carries `content` to upsert, `deletions` to + * remove, or both. + */ +export interface ConfigurationImportRequest { + /** + * One or more YAML documents separated by `---`. + */ + content?: string; + deletions?: ConfigurationResourceDeletion[]; + /** + * Validates the content and returns the expected outcomes without changing anything. + */ + dryRun?: boolean; + options?: ConfigurationImportOptions; + /** + * Values for the `{{.VARIABLE_NAME}}` template variables in `content`. + */ + variables?: Record; +} + +/** + * The outcome of one imported resource. + */ +export interface ConfigurationImportOutcome { + code?: string; + message?: string; + operation?: 'create' | 'update'; + resourceId?: string; + resourceName?: string; + resourceType: string; + status: 'success' | 'failed'; +} + +/** + * The outcome of an import. + */ +export interface ConfigurationImportResponse { + results: ConfigurationImportOutcome[]; + summary: { + deleted?: number; + failed: number; + importedAt: string; + imported: number; + totalDocuments: number; + }; +} + +/** + * The payload used to delete an imported declarative resource file. + */ +export interface DeleteImportedConfigurationRequest { + resourceKey: string; + resourceType: string; +} + +/** + * The outcome of deleting an imported declarative resource file. + */ +export interface DeleteImportedConfigurationResponse { + deletedFile: string; + resourceKey: string; + resourceType: string; +} diff --git a/packages/javascript/src/models/connection.ts b/packages/javascript/src/models/connection.ts new file mode 100644 index 00000000..07860ca1 --- /dev/null +++ b/packages/javascript/src/models/connection.ts @@ -0,0 +1,131 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ApiPaginationLink} from './api'; + +/** + * The vendor or protocol of a connection. + */ +export type ConnectionType = + | 'authzen-pdp' + | 'email-smtp' + | 'github' + | 'google' + | 'http' + | 'oauth' + | 'oidc' + | 'sms-gateway' + | 'twilio' + | 'vonage'; + +/** + * The connection types whose usages can be listed. + */ +export type ConnectionTypeWithUsages = Exclude; + +/** + * What a connection is used for. + */ +export type ConnectionCategory = 'identity-provider' | 'sms-provider' | 'authorization-pdp' | 'email-provider' | 'http'; + +/** + * A connection as listed across every type. + */ +export interface ConnectionInstance { + categories: ConnectionCategory[]; + description?: string; + id: string; + name: string; + type: ConnectionType; +} + +/** + * A page of connections across every type. + */ +export interface ConnectionListResponse { + connections: ConnectionInstance[]; + count: number; + links: ApiPaginationLink[]; + startIndex: number; + totalResults: number; +} + +/** + * A connection as listed for one type. + */ +export interface ConnectionSummary { + description?: string; + id: string; + name: string; +} + +/** + * A connection with its configuration. The fields beyond the common ones depend on `type` (for example + * `clientId`, `redirectUri` and `scopes` for an identity provider, or `url` and `headers` for an HTTP + * connection); see the connections API for each type's shape. A credential such as a client secret is + * returned as the server returns it and is never logged by the SDK. + */ +export interface Connection { + [key: string]: unknown; + description?: string; + id: string; + name: string; + type: ConnectionType; +} + +/** + * The payload used to create or update a connection. The fields beyond the common ones depend on the + * connection type; see the connections API for each type's shape. + */ +export interface ConnectionRequest { + [key: string]: unknown; + description?: string; + name: string; +} + +/** + * A flow that uses a connection. + */ +export interface ConnectionUsage { + /** + * What happens to the flow when the connection is deleted. + */ + behaviorOnDelete: 'fallback' | 'cascade' | 'restrict'; + displayName: string; + id: string; + resourceType: 'flow'; +} + +/** + * The resources that use a connection. + */ +export interface ConnectionUsagesResponse { + count?: number; + /** + * The number of usages by resource type. + */ + summary?: Record; + totalResults?: number | null; + usages?: ConnectionUsage[]; +} + +/** + * The configurable options of one connection vendor. + */ +export interface ConnectionVendorMeta { + [key: string]: unknown; + authentication?: { + /** + * The outbound authentication methods the vendor supports. + */ + methods?: unknown[]; + }; + vendor?: string; +} + +/** + * The configurable options of the connection vendors. + */ +export interface ConnectionMetaResponse { + vendors?: ConnectionVendorMeta[]; +} diff --git a/packages/javascript/src/models/design.ts b/packages/javascript/src/models/design.ts new file mode 100644 index 00000000..5dca7452 --- /dev/null +++ b/packages/javascript/src/models/design.ts @@ -0,0 +1,121 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ApiPaginationLink} from './api'; + +/** + * A theme as listed. + */ +export interface DesignThemeListItem { + createdAt?: string; + defaultColorScheme?: string; + description?: string; + displayName?: string; + handle?: string; + id?: string; + isReadOnly?: boolean; + primaryColor?: string; + updatedAt?: string; +} + +/** + * A page of themes. + */ +export interface DesignThemeListResponse { + count?: number; + links?: ApiPaginationLink[]; + startIndex?: number; + themes?: DesignThemeListItem[]; + totalResults?: number; +} + +/** + * A theme with its configuration. + */ +export interface DesignTheme { + createdAt?: string; + description?: string; + displayName?: string; + handle?: string; + id?: string; + isReadOnly?: boolean; + /** + * The theme configuration: color schemes, typography, shape and the like. + */ + theme?: Record; + updatedAt?: string; +} + +/** + * The payload used to create or update a theme. + */ +export interface DesignThemeRequest { + description?: string; + displayName: string; + handle: string; + theme: Record; +} + +/** + * A layout as listed. + */ +export interface DesignLayoutListItem { + createdAt?: string; + description?: string; + displayName?: string; + handle?: string; + id?: string; + isReadOnly?: boolean; + updatedAt?: string; +} + +/** + * A page of layouts. + */ +export interface DesignLayoutListResponse { + count?: number; + layouts?: DesignLayoutListItem[]; + links?: ApiPaginationLink[]; + startIndex?: number; + totalResults?: number; +} + +/** + * A layout with its configuration. + */ +export interface DesignLayout { + createdAt?: string; + description?: string; + displayName?: string; + handle?: string; + id?: string; + isReadOnly?: boolean; + /** + * The layout configuration. + */ + layout?: Record; + updatedAt?: string; +} + +/** + * The payload used to create or update a layout. + */ +export interface DesignLayoutRequest { + description?: string; + displayName: string; + handle: string; + layout: Record; +} + +/** + * What a design is resolved for: an application or an organization unit. + */ +export type DesignResolveType = 'APP' | 'OU'; + +/** + * The theme and layout that apply to an application or an organization unit. + */ +export interface DesignResolveResponse { + layout?: Record; + theme?: Record; +} diff --git a/packages/javascript/src/models/entity-type.ts b/packages/javascript/src/models/entity-type.ts new file mode 100644 index 00000000..c8c6ded4 --- /dev/null +++ b/packages/javascript/src/models/entity-type.ts @@ -0,0 +1,117 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {AgentType} from './agent-resource'; +import {ManagedUserType} from './managed-user-type'; + +interface BasePropertyDefinition { + displayName?: string; + required?: boolean; + unique?: boolean; +} + +/** + * A text attribute. `email` and `mobile` are text with a known format. + */ +export interface StringPropertyDefinition extends BasePropertyDefinition { + credential?: boolean; + enum?: string[]; + regex?: string; + type: 'string' | 'email' | 'mobile'; +} + +export interface NumberPropertyDefinition extends BasePropertyDefinition { + credential?: boolean; + type: 'number'; +} + +export interface BooleanPropertyDefinition extends BasePropertyDefinition { + type: 'boolean'; +} + +export interface ObjectPropertyDefinition extends BasePropertyDefinition { + properties: Record; + type: 'object'; +} + +/** + * What each item of an array attribute holds. + */ +export type ArrayItemDefinition = + | {enum?: string[]; type: 'string' | 'email' | 'mobile' | 'number' | 'boolean'} + | {properties: Record; type: 'object'}; + +export interface ArrayPropertyDefinition extends BasePropertyDefinition { + items: ArrayItemDefinition; + type: 'array'; +} + +/** + * One attribute of a user type or an agent type. + */ +export type PropertyDefinition = + | StringPropertyDefinition + | NumberPropertyDefinition + | BooleanPropertyDefinition + | ObjectPropertyDefinition + | ArrayPropertyDefinition; + +/** + * The attributes of a user type or an agent type, keyed by attribute name. + */ +export type EntityTypeSchema = Record; + +/** + * The attributes a type maps to the identity's display name and primary contacts. + */ +export interface EntityTypeSystemAttributes { + display?: string; + primaryEmail?: string; + primaryMobile?: string; +} + +/** + * A user type with its attribute schema. + */ +export interface ManagedUserTypeDetails extends ManagedUserType { + allowSelfRegistration: boolean; + displayName: string; + ouId: string; + schema: EntityTypeSchema; + systemAttributes?: EntityTypeSystemAttributes; +} + +export interface CreateUserTypeRequest { + allowSelfRegistration?: boolean; + displayName: string; + handle: string; + ouId: string; + schema: EntityTypeSchema; + systemAttributes?: EntityTypeSystemAttributes; +} + +export type UpdateUserTypeRequest = Omit; + +/** + * An agent type with its attribute schema. + */ +export interface AgentTypeDetails extends AgentType { + schema: EntityTypeSchema; + systemAttributes?: Pick; +} + +export interface UpdateAgentTypeRequest { + displayName: string; + ouId: string; + schema: EntityTypeSchema; + systemAttributes?: Pick; +} + +/** + * The payload used to create the agent type. The server holds a single agent type, whose handle is + * `default`. + */ +export interface CreateAgentTypeRequest extends UpdateAgentTypeRequest { + allowSelfRegistration?: boolean; + handle: string; +} diff --git a/packages/javascript/src/models/gateway.ts b/packages/javascript/src/models/gateway.ts new file mode 100644 index 00000000..2f83206c --- /dev/null +++ b/packages/javascript/src/models/gateway.ts @@ -0,0 +1,144 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ConfigurationActor, ConfigurationValueReference} from './configuration'; + +/** + * A registered gateway. + */ +export interface Gateway { + baseUrl?: string; + caCertificate?: string; + createdAt?: string; + id?: string; + isDefault?: boolean; + name?: string; + updatedAt?: string; +} + +/** + * A newly registered gateway with its key. The key is returned only here. + */ +export interface GatewayRegistration extends Gateway { + key: string; +} + +/** + * The payload used to register a gateway. + */ +export interface RegisterGatewayRequest { + baseUrl: string; + caCertificate?: string; + isDefault?: boolean; + /** + * The gateway's key. Generated by the server when omitted. + */ + key?: string; + name: string; +} + +/** + * The payload used to change a gateway. + */ +export interface UpdateGatewayRequest { + baseUrl?: string; + caCertificate?: string; + key?: string; + name?: string; +} + +/** + * One resource that differs between two configuration versions. + */ +export interface GatewayConfigurationChange { + change?: 'added' | 'updated' | 'deleted' | 'unchanged'; + excluded?: boolean; + id?: string; + key?: string; + lines?: {kind?: ' ' | '+' | '-'; text?: string}[]; + name?: string; + resourceType?: string; + skipped?: boolean; +} + +/** + * The difference between a configuration version and what a gateway holds. + */ +export interface GatewayConfigurationDiff { + changes?: GatewayConfigurationChange[]; + fromVersion?: string; + misplacedSecrets?: ConfigurationValueReference[]; + summary?: {added?: number; deleted?: number; unchanged?: number; updated?: number}; + toVersion?: string; +} + +/** + * The payload used to apply a configuration version to a gateway. + */ +export interface ApplyGatewayConfigurationRequest { + confirmRemovals?: boolean; + dryRun?: boolean; + selection?: string[]; + skip?: string[]; + /** + * The version to apply. Defaults to the latest. + */ + version?: string; +} + +/** + * The payload used to revert a gateway to the version it held before. + */ +export interface RevertGatewayConfigurationRequest { + confirmRemovals?: boolean; + dryRun?: boolean; +} + +/** + * The variables and secrets an apply found missing or changed. + */ +export interface GatewayValueChanges { + references?: ConfigurationValueReference[]; + secrets?: string[]; + variables?: string[]; +} + +/** + * The outcome of applying or reverting a gateway's configuration. + */ +export interface GatewayApplyResult { + changed?: GatewayValueChanges; + diff?: GatewayConfigurationDiff; + dryRun?: boolean; + gatewayId?: string; + /** + * The gateway's own account of the import. + */ + import?: Record; + missing?: GatewayValueChanges; + queued?: boolean; + queuedVersion?: string; + recorded?: boolean; +} + +/** + * The configuration version a gateway holds. + */ +export interface GatewayAppliedVersion { + appliedAt?: string; + appliedBy?: ConfigurationActor; + appliedVersion?: string; + gatewayId?: string; + previousVersion?: string; +} + +/** + * The configuration a gateway runs. + */ +export interface GatewayAppliedConfiguration { + appliedAt?: string; + gatewayId: string; + resources: {id: string; parts?: Record; resource: Record; resourceType: string}[]; + skipped?: {code: string; id?: string; reason: string; resourceType: string}[]; + version?: string; +} diff --git a/packages/javascript/src/models/i18n-management.ts b/packages/javascript/src/models/i18n-management.ts new file mode 100644 index 00000000..3e8ca3a0 --- /dev/null +++ b/packages/javascript/src/models/i18n-management.ts @@ -0,0 +1,47 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +/** + * The languages the server holds translations for. + */ +export interface LanguageListResponse { + languages?: string[]; +} + +/** + * Translations keyed by namespace, then by key. + */ +export type TranslationsByNamespace = Record>; + +/** + * The translations of one language. + */ +export interface LanguageTranslationsResponse { + language: string; + totalResults?: number; + translations: TranslationsByNamespace; +} + +/** + * The payload used to set the translations of a language. + */ +export interface SetTranslationsRequest { + translations: TranslationsByNamespace; +} + +/** + * One translation. + */ +export interface ManagedTranslation { + key: string; + language: string; + namespace: string; + value: string; +} + +/** + * The payload used to set one translation. + */ +export interface SetTranslationRequest { + value: string; +} diff --git a/packages/javascript/src/models/managed-flow.ts b/packages/javascript/src/models/managed-flow.ts index 0e0b36d7..e57be591 100644 --- a/packages/javascript/src/models/managed-flow.ts +++ b/packages/javascript/src/models/managed-flow.ts @@ -27,3 +27,75 @@ export interface ManagedFlowListResponse { startIndex: number; totalResults: number; } + +/** + * One node of a flow graph. + */ +export interface ManagedFlowNode { + condition?: {key: string; onSkip: string; value: string}; + executor?: {inputs?: Record[]; mode?: string; name: string}; + flow?: {ref: string}; + id: string; + layout?: {position?: {x: number; y: number}; size?: {height: number; width: number}}; + message?: string; + meta?: Record; + next?: string; + onFailure?: string; + onIncomplete?: string; + onSuccess?: string; + prompts?: Record[]; + properties?: Record; + /** + * The node type: `START`, `PROMPT`, `TASK_EXECUTION`, `CALL` or `END`. + */ + type: string; + variant?: string; +} + +/** + * A flow with its graph. + */ +export interface ManagedFlowDefinition extends ManagedFlow { + nodes: ManagedFlowNode[]; +} + +/** + * The payload used to create or update a flow. + */ +export interface ManagedFlowRequest { + flowType: string; + handle: string; + name: string; + nodes: ManagedFlowNode[]; +} + +/** + * One version in a flow's history. + */ +export interface ManagedFlowVersionInfo { + createdAt: string; + isActive: boolean; + version: number; +} + +/** + * A flow's version history. + */ +export interface ManagedFlowVersionListResponse { + totalVersions?: number; + versions?: ManagedFlowVersionInfo[]; +} + +/** + * One version of a flow, with its graph. + */ +export interface ManagedFlowVersion { + createdAt?: string; + flowType: string; + handle: string; + id: string; + isActive: boolean; + name: string; + nodes: ManagedFlowNode[]; + version: number; +} diff --git a/packages/javascript/src/models/managed-user.ts b/packages/javascript/src/models/managed-user.ts index 63bd05aa..83166b6f 100644 --- a/packages/javascript/src/models/managed-user.ts +++ b/packages/javascript/src/models/managed-user.ts @@ -48,3 +48,45 @@ export interface UpdateManagedUserRequest { ouId?: string; type?: string; } + +/** + * A resource that references a user, such as an agent that names the user as its owner. + */ +export interface UserUsage { + /** + * What happens to the resource when the user is deleted. + */ + behaviorOnDelete: 'fallback' | 'cascade' | 'restrict'; + displayName: string; + id: string; + resourceType: string; +} + +/** + * The resources that reference a user. `totalResults` and `summary` are `null` when the server could + * not tell; `0` means there are none. + */ +export interface UserUsagesResponse { + count: number; + /** + * The number of usages of each resource type, such as `agent`. + */ + summary: Record | null; + totalResults: number | null; + usages: UserUsage[]; +} + +/** + * New values for some of a user's credential attributes, keyed by attribute name. + */ +export interface UpdateUserCredentialsRequest { + credentials: Record; +} + +/** + * The payload used to create a user in the organization unit at a handle path. + */ +export interface CreateUserByPathRequest { + attributes?: Record; + type: string; +} diff --git a/packages/javascript/src/models/notification-template.ts b/packages/javascript/src/models/notification-template.ts new file mode 100644 index 00000000..b3a6c6f0 --- /dev/null +++ b/packages/javascript/src/models/notification-template.ts @@ -0,0 +1,131 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ApiPaginationLink} from './api'; + +/** + * The channel a notification template is sent through. + */ +export type NotificationChannel = 'email' | 'sms'; + +/** + * A notification template as listed. + */ +export interface NotificationTemplateSummary { + description?: string; + displayName: string; + handle: string; + id: string; + /** + * The template's own URL. + */ + self: string; +} + +/** + * A page of notification templates. + */ +export interface NotificationTemplateListResponse { + count?: number; + links?: ApiPaginationLink[]; + startIndex?: number; + templates: NotificationTemplateSummary[]; + totalResults?: number; +} + +/** + * How an email template is laid out. + */ +export interface NotificationTemplateDesign { + colorScheme?: 'light' | 'dark'; +} + +/** + * The content of an email template. + */ +export interface EmailTemplateContent { + body: string; + subject: string; +} + +/** + * The content of an SMS template. + */ +export interface SmsTemplateContent { + body: string; +} + +/** + * An email notification template. + */ +export interface EmailNotificationTemplate { + content: EmailTemplateContent; + description?: string; + design?: NotificationTemplateDesign; + displayName: string; + handle: string; + id: string; + isReadOnly?: boolean; + ouId: string; +} + +/** + * An SMS notification template. + */ +export interface SmsNotificationTemplate { + content: SmsTemplateContent; + description?: string; + displayName: string; + handle: string; + id: string; + isReadOnly?: boolean; + ouId: string; +} + +/** + * A notification template. + */ +export type NotificationTemplate = EmailNotificationTemplate | SmsNotificationTemplate; + +/** + * The payload used to create an email template. + */ +export interface CreateEmailTemplateRequest { + content: EmailTemplateContent; + description?: string; + design?: NotificationTemplateDesign; + displayName: string; + handle: string; + ouId: string; +} + +/** + * The payload used to create an SMS template. + */ +export interface CreateSmsTemplateRequest { + content: SmsTemplateContent; + description?: string; + displayName: string; + handle: string; + ouId: string; +} + +/** + * The payload used to create a notification template. + */ +export type CreateNotificationTemplateRequest = CreateEmailTemplateRequest | CreateSmsTemplateRequest; + +/** + * The payload used to update an email template. + */ +export type UpdateEmailTemplateRequest = Omit; + +/** + * The payload used to update an SMS template. + */ +export type UpdateSmsTemplateRequest = Omit; + +/** + * The payload used to update a notification template. + */ +export type UpdateNotificationTemplateRequest = UpdateEmailTemplateRequest | UpdateSmsTemplateRequest; diff --git a/packages/javascript/src/models/organization-unit.ts b/packages/javascript/src/models/organization-unit.ts new file mode 100644 index 00000000..51e18d80 --- /dev/null +++ b/packages/javascript/src/models/organization-unit.ts @@ -0,0 +1,160 @@ +// Copyright 2025-2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ApiPaginationLink} from './api'; + +/** + * An organization unit as listed by the management API. + */ +export interface OrganizationUnitBasic { + createdAt?: string; + description?: string; + handle: string; + id: string; + isReadOnly?: boolean; + logoUrl?: string; + name: string; + updatedAt?: string; +} + +/** + * An organization unit with its configuration. + */ +export interface OrganizationUnit { + authFlowId?: string; + cookiePolicyUri?: string; + createdAt?: string; + description?: string; + handle: string; + id: string; + isRecoveryFlowEnabled?: boolean; + isRegistrationFlowEnabled?: boolean; + layoutId?: string; + logoUrl?: string; + name: string; + /** + * Identifier of the parent organization unit, or `null` for a root. + */ + parent?: string | null; + policyUri?: string; + recoveryFlowId?: string; + registrationFlowId?: string; + signOutFlowId?: string; + themeId?: string; + tosUri?: string; + updatedAt?: string; +} + +/** + * A page of organization units. + */ +export interface OrganizationUnitListResponse { + count?: number; + links?: ApiPaginationLink[]; + organizationUnits: OrganizationUnitBasic[]; + startIndex?: number; + totalResults?: number; +} + +/** + * The payload used to update an organization unit addressed by its handle path. + */ +export interface UpdateOrganizationUnitByPathRequest { + authFlowId?: string; + cookiePolicyUri?: string; + description?: string; + handle: string; + isRecoveryFlowEnabled?: boolean; + isRegistrationFlowEnabled?: boolean; + layoutId?: string; + logoUrl?: string; + name: string; + policyUri?: string; + recoveryFlowId?: string; + registrationFlowId?: string; + signOutFlowId?: string; + themeId?: string; + tosUri?: string; +} + +/** + * The payload used to create an organization unit. + */ +export interface CreateOrganizationUnitRequest extends UpdateOrganizationUnitByPathRequest { + /** + * Identifier of the parent organization unit, or `null` to create a root. + */ + parent: string | null; +} + +/** + * The payload used to update an organization unit addressed by its identifier. + */ +export type UpdateOrganizationUnitRequest = CreateOrganizationUnitRequest; + +/** + * Addresses an organization unit either by its identifier or by its handle path (for example + * `engineering/platform`). + */ +export type OrganizationUnitReference = {ouId: string; path?: undefined} | {ouId?: undefined; path: string}; + +/** + * A user that belongs to an organization unit. + */ +export interface OrganizationUnitUser { + display?: string; + id: string; + isOwned?: boolean; + type?: string; +} + +/** + * A page of the users in an organization unit. + */ +export interface OrganizationUnitUserListResponse { + count?: number; + links?: ApiPaginationLink[]; + startIndex?: number; + totalResults?: number; + users: OrganizationUnitUser[]; +} + +/** + * A group that belongs to an organization unit. + */ +export interface OrganizationUnitGroup { + id: string; + name: string; +} + +/** + * A page of the groups in an organization unit. + */ +export interface OrganizationUnitGroupListResponse { + count?: number; + groups: OrganizationUnitGroup[]; + links?: ApiPaginationLink[]; + startIndex?: number; + totalResults?: number; +} + +/** + * A role that belongs to an organization unit. + */ +export interface OrganizationUnitRole { + description?: string; + id: string; + isReadOnly?: boolean; + name: string; +} + +/** + * A page of the roles in an organization unit. + */ +export interface OrganizationUnitRoleListResponse { + count?: number; + links?: ApiPaginationLink[]; + roles: OrganizationUnitRole[]; + startIndex?: number; + totalResults?: number; +} diff --git a/packages/javascript/src/models/resource-server.ts b/packages/javascript/src/models/resource-server.ts new file mode 100644 index 00000000..07e72ea6 --- /dev/null +++ b/packages/javascript/src/models/resource-server.ts @@ -0,0 +1,167 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ApiPaginationLink} from './api'; + +/** + * The kind of API a resource server describes. + */ +export type ResourceServerType = 'API' | 'MCP' | 'CUSTOM'; + +/** + * How a resource server's permissions are evaluated. + */ +export interface ResourceServerAuthorizationEngine { + properties?: { + /** + * The AuthZEN PDP connection that evaluates requests, when `type` is `authzen_pdp`. + */ + pdpConnectionId?: string; + }; + type?: 'rbac' | 'authzen_pdp'; +} + +/** + * A resource server: an API whose resources and actions roles grant permissions on. + */ +export interface ResourceServer { + authorizationEngine?: ResourceServerAuthorizationEngine; + delimiter: string; + description?: string | null; + id: string; + identifier: string; + isReadOnly?: boolean; + name: string; + ouId: string; + type: ResourceServerType; +} + +/** + * A page of resource servers. + */ +export interface ResourceServerListResponse { + count?: number; + links?: ApiPaginationLink[]; + resourceServers: ResourceServer[]; + startIndex?: number; + totalResults?: number; +} + +/** + * The payload used to create a resource server. + */ +export interface CreateResourceServerRequest { + delimiter?: string; + description?: string; + identifier: string; + name: string; + ouId: string; + type?: ResourceServerType; +} + +/** + * The payload used to update a resource server. + */ +export interface UpdateResourceServerRequest { + authorizationEngine?: ResourceServerAuthorizationEngine; + description?: string; + identifier?: string; + name: string; + ouId: string; +} + +/** + * A resource of a resource server. + */ +export interface ManagedResource { + description?: string | null; + handle: string; + id: string; + name: string; + /** + * Identifier of the parent resource, for a nested resource. + */ + parent?: string; + /** + * The permission string the resource grants. + */ + permission: string; +} + +/** + * A page of resources. + */ +export interface ManagedResourceListResponse { + count?: number; + links?: ApiPaginationLink[]; + resources: ManagedResource[]; + startIndex?: number; + totalResults?: number; +} + +/** + * The payload used to create a resource. + */ +export interface CreateResourceRequest { + description?: string; + handle: string; + name: string; + parent?: string | null; +} + +/** + * The payload used to update a resource. + */ +export interface UpdateResourceRequest { + description?: string; + name?: string; +} + +/** + * What an action is: a tool an agent calls, or an operation on a resource. + */ +export type ManagedActionKind = 'tool' | 'resource'; + +/** + * An action on a resource server or on one of its resources. + */ +export interface ManagedAction { + description?: string | null; + handle: string; + id: string; + kind?: ManagedActionKind; + name: string; + /** + * The permission string the action grants. + */ + permission: string; +} + +/** + * A page of actions. + */ +export interface ManagedActionListResponse { + actions: ManagedAction[]; + count?: number; + links?: ApiPaginationLink[]; + startIndex?: number; + totalResults?: number; +} + +/** + * The payload used to create an action. + */ +export interface CreateActionRequest { + description?: string; + handle: string; + kind?: ManagedActionKind; + name: string; +} + +/** + * The payload used to update an action. + */ +export interface UpdateActionRequest { + description?: string; + name?: string; +} diff --git a/packages/javascript/src/models/resource-usage.ts b/packages/javascript/src/models/resource-usage.ts new file mode 100644 index 00000000..cfe50755 --- /dev/null +++ b/packages/javascript/src/models/resource-usage.ts @@ -0,0 +1,28 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +/** + * A resource that references another, such as an application that uses a flow or a theme. + */ +export interface ResourceUsage { + /** + * What happens to the referencing resource when the referenced one is deleted. + */ + behaviorOnDelete: 'fallback' | 'cascade'; + displayName: string; + id: string; + resourceType: 'application' | 'agent'; +} + +/** + * The resources that reference another. + */ +export interface ResourceUsagesResponse { + count?: number; + /** + * The number of usages by resource type. + */ + summary?: Record; + totalResults?: number | null; + usages?: ResourceUsage[]; +} diff --git a/packages/javascript/src/models/server-config.ts b/packages/javascript/src/models/server-config.ts new file mode 100644 index 00000000..cd715c07 --- /dev/null +++ b/packages/javascript/src/models/server-config.ts @@ -0,0 +1,12 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +/** + * A server configuration section in its layers: the read-only layer from the deployment, the writable + * layer the management API changes, and the two merged, which is what the server applies. + */ +export interface ServerConfigLayers { + merged: T; + readOnly: T; + writable: T; +} diff --git a/packages/javascript/src/models/sharing-policy.ts b/packages/javascript/src/models/sharing-policy.ts new file mode 100644 index 00000000..c3ef8261 --- /dev/null +++ b/packages/javascript/src/models/sharing-policy.ts @@ -0,0 +1,143 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ApiPaginationLink} from './api'; + +/** + * Every organization unit in the deployment. Only a resource file or an imported document may introduce + * it; an edit restates it to change its exclusions. + */ +export interface SharingPolicyAllUnits { + excludedOuIds?: string[]; +} + +/** + * Everything beneath the issuing organization unit, including units created later. + */ +export interface SharingPolicyChildUnitsAll { + /** + * Organization units directly beneath the issuer to withhold, with everything beneath them. + */ + excludedOuIds?: string[]; + mode: 'all'; +} + +/** + * A named organization unit directly beneath the issuer. + */ +export interface SharingPolicyChildUnit { + ouId: string; + /** + * `unit` reaches the named organization unit only; `subtree` reaches it and everything below it. + */ + reach: 'unit' | 'subtree'; +} + +/** + * Named organization units directly beneath the issuer. + */ +export interface SharingPolicyChildUnitsSelected { + mode: 'selected'; + units: SharingPolicyChildUnit[]; +} + +/** + * The organization units beneath the issuer that a policy reaches. + */ +export type SharingPolicyChildUnits = SharingPolicyChildUnitsAll | SharingPolicyChildUnitsSelected; + +/** + * Named root organization units of other trees. + */ +export interface SharingPolicyOtherTreeRootsSelected { + mode: 'selected'; + units: {ouId: string}[]; +} + +/** + * The root organization unit of every other tree. Only a resource file or an imported document may + * introduce it. + */ +export interface SharingPolicyOtherTreeRootsAll { + excludedOuIds?: string[]; + mode: 'all'; +} + +/** + * The roots of other trees that a policy reaches. + */ +export type SharingPolicyOtherTreeRoots = SharingPolicyOtherTreeRootsSelected | SharingPolicyOtherTreeRootsAll; + +/** + * A sharing policy: one organization unit's decision about which other organization units a user or an + * application reaches. + */ +export interface SharingPolicy { + allUnits?: SharingPolicyAllUnits; + childUnits?: SharingPolicyChildUnits; + id?: string; + initiatingOuId?: string; + isReadOnly?: boolean; + otherTreeRoots?: SharingPolicyOtherTreeRoots; + parentPolicyId?: string; + stage?: 'share' | 'reshare'; + version?: number; +} + +/** + * A page of sharing policies. + */ +export interface SharingPolicyListResponse { + count?: number; + links?: ApiPaginationLink[]; + policies: SharingPolicy[]; + startIndex?: number; + totalResults?: number; +} + +/** + * The payload used to create a sharing policy. It states `childUnits`, `otherTreeRoots`, or both. + */ +export interface SharingPolicyRequest { + childUnits?: SharingPolicyChildUnits; + /** + * The organization unit whose decision this is. Omitted means the owner. + */ + initiatingOuId?: string; + otherTreeRoots?: SharingPolicyOtherTreeRootsSelected; + version?: number; +} + +/** + * The payload used to replace a sharing policy's sections. + */ +export interface SharingPolicyUpdateRequest { + allUnits?: SharingPolicyAllUnits; + childUnits?: SharingPolicyChildUnits; + otherTreeRoots?: SharingPolicyOtherTreeRoots; + /** + * The policy version being edited. An edit against any other version is refused. + */ + version: number; +} + +/** + * The rule a sharing policy places on one attribute in an organization unit. + */ +export interface OverlayRule { + allowedValues?: string[]; + editable?: boolean; + excludedValues?: string[]; + value?: string[]; +} + +/** + * The overlay rules that apply to a user or an application in one organization unit. + */ +export interface ResolvedOverlay { + isOwned?: boolean; + ouId?: string; + policyIds?: string[]; + rules?: Record; + visible?: boolean; +} diff --git a/packages/javascript/src/models/variable-store.ts b/packages/javascript/src/models/variable-store.ts new file mode 100644 index 00000000..8e0581c3 --- /dev/null +++ b/packages/javascript/src/models/variable-store.ts @@ -0,0 +1,85 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ApiPaginationLink} from './api'; + +/** + * A named variable. Its value is readable. + */ +export interface Variable { + createdAt?: string; + description?: string; + name: string; + updatedAt?: string; + value: string; +} + +/** + * A named secret. Its value is write-only: the server only says whether one is held. + */ +export interface Secret { + createdAt?: string; + description?: string; + /** + * Whether a value is held. + */ + exists: boolean; + name: string; + updatedAt?: string; +} + +/** + * A page of variables. + */ +export interface VariableListResponse { + count: number; + links?: ApiPaginationLink[]; + startIndex: number; + totalResults: number; + variables: Variable[]; +} + +/** + * A page of secrets. + */ +export interface SecretListResponse { + count: number; + links?: ApiPaginationLink[]; + secrets: Secret[]; + startIndex: number; + totalResults: number; +} + +/** + * The payload used to add a variable. + */ +export interface VariableRequest { + description?: string; + name: string; + value: string; +} + +/** + * The payload used to create or replace a variable. + */ +export interface VariableUpdateRequest { + description?: string; + value: string; +} + +/** + * The payload used to add a secret. The value is write-only. + */ +export interface SecretRequest { + description?: string; + name: string; + value: string; +} + +/** + * The payload used to set or replace a secret. The value is write-only. + */ +export interface SecretUpdateRequest { + description?: string; + value: string; +} diff --git a/packages/javascript/src/utils/__tests__/getApplicationClientSecret.test.ts b/packages/javascript/src/utils/__tests__/getApplicationClientSecret.test.ts new file mode 100644 index 00000000..88de9613 --- /dev/null +++ b/packages/javascript/src/utils/__tests__/getApplicationClientSecret.test.ts @@ -0,0 +1,46 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {describe, expect, it} from 'vitest'; +import {Application} from '../../models/application'; +import getApplicationClientSecret from '../getApplicationClientSecret'; + +const application: Application = { + createdAt: '2026-01-01T00:00:00Z', + id: 'app-1', + inboundAuthConfig: [ + { + config: { + clientId: 'client-1', + clientSecret: 'secret-1', + grantTypes: ['authorization_code'], + redirectUris: ['https://old.example.com/callback'], + responseTypes: ['code', 'id_token'], + tokenEndpointAuthMethod: 'client_secret_basic', + }, + type: 'oauth2', + }, + ], + name: 'Old name', + ouId: 'ou-1', + status: {value: 'ACTIVE'}, + themeId: 'theme-1', + updatedAt: '2026-01-02T00:00:00Z', +}; + +describe('getApplicationClientSecret', () => { + it('returns the client secret the server returned', () => { + expect(getApplicationClientSecret(application)).toBe('secret-1'); + }); + + it('treats an empty or missing secret as no secret', () => { + expect( + getApplicationClientSecret({ + id: 'a', + inboundAuthConfig: [{config: {clientSecret: ''}, type: 'oauth2'}], + name: 'A', + }), + ).toBeUndefined(); + expect(getApplicationClientSecret({id: 'a', name: 'A'})).toBeUndefined(); + }); +}); diff --git a/packages/javascript/src/utils/__tests__/getApplicationOAuthConfig.test.ts b/packages/javascript/src/utils/__tests__/getApplicationOAuthConfig.test.ts new file mode 100644 index 00000000..7747bcf5 --- /dev/null +++ b/packages/javascript/src/utils/__tests__/getApplicationOAuthConfig.test.ts @@ -0,0 +1,40 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {describe, expect, it} from 'vitest'; +import {Application} from '../../models/application'; +import getApplicationOAuthConfig from '../getApplicationOAuthConfig'; + +const application: Application = { + createdAt: '2026-01-01T00:00:00Z', + id: 'app-1', + inboundAuthConfig: [ + { + config: { + clientId: 'client-1', + clientSecret: 'secret-1', + grantTypes: ['authorization_code'], + redirectUris: ['https://old.example.com/callback'], + responseTypes: ['code', 'id_token'], + tokenEndpointAuthMethod: 'client_secret_basic', + }, + type: 'oauth2', + }, + ], + name: 'Old name', + ouId: 'ou-1', + status: {value: 'ACTIVE'}, + themeId: 'theme-1', + updatedAt: '2026-01-02T00:00:00Z', +}; + +describe('getApplicationOAuthConfig', () => { + it('returns the OAuth configuration of an application', () => { + expect(getApplicationOAuthConfig(application)?.clientId).toBe('client-1'); + }); + + it('returns undefined for an application without an OAuth client', () => { + expect(getApplicationOAuthConfig({inboundAuthConfig: []})).toBeUndefined(); + expect(getApplicationOAuthConfig(undefined)).toBeUndefined(); + }); +}); diff --git a/packages/javascript/src/utils/__tests__/isApplicationSubmissionValid.test.ts b/packages/javascript/src/utils/__tests__/isApplicationSubmissionValid.test.ts new file mode 100644 index 00000000..4e51c824 --- /dev/null +++ b/packages/javascript/src/utils/__tests__/isApplicationSubmissionValid.test.ts @@ -0,0 +1,42 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {describe, expect, it} from 'vitest'; +import {ApplicationSubmission} from '../../models/application'; +import isApplicationSubmissionValid from '../isApplicationSubmissionValid'; + +const submission: ApplicationSubmission = { + description: 'Storefront', + grantTypes: ['authorization_code', 'refresh_token'], + logoUrl: 'https://cdn.example.com/logo.png', + name: 'My SPA', + redirectUris: ['https://app.example.com/callback'], + url: 'https://app.example.com', +}; + +describe('isApplicationSubmissionValid', () => { + it('accepts a named application with grant types and a redirect URI', () => { + expect(isApplicationSubmissionValid(submission)).toBe(true); + }); + + it('refuses a blank name', () => { + expect(isApplicationSubmissionValid({...submission, name: ' '})).toBe(false); + }); + + it('refuses no grant types', () => { + expect(isApplicationSubmissionValid({...submission, grantTypes: []})).toBe(false); + }); + + it('requires a redirect URI only for the authorization code grant', () => { + expect(isApplicationSubmissionValid({...submission, redirectUris: []})).toBe(false); + expect(isApplicationSubmissionValid({...submission, grantTypes: ['client_credentials'], redirectUris: []})).toBe( + true, + ); + }); + + it('refuses the client credentials grant for a public client', () => { + expect(isApplicationSubmissionValid({...submission, grantTypes: ['client_credentials'], publicClient: true})).toBe( + false, + ); + }); +}); diff --git a/packages/javascript/src/utils/__tests__/parseRedirectUris.test.ts b/packages/javascript/src/utils/__tests__/parseRedirectUris.test.ts new file mode 100644 index 00000000..696ee8ee --- /dev/null +++ b/packages/javascript/src/utils/__tests__/parseRedirectUris.test.ts @@ -0,0 +1,17 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {describe, expect, it} from 'vitest'; +import parseRedirectUris from '../parseRedirectUris'; + +describe('parseRedirectUris', () => { + it('splits on whitespace, new lines and commas and drops blanks', () => { + expect( + parseRedirectUris(' https://a.example.com/cb,\nhttps://b.example.com/cb https://c.example.com/cb ,'), + ).toEqual(['https://a.example.com/cb', 'https://b.example.com/cb', 'https://c.example.com/cb']); + }); + + it('returns no URIs for blank text', () => { + expect(parseRedirectUris(' \n ')).toEqual([]); + }); +}); diff --git a/packages/javascript/src/utils/__tests__/resolveApplicationResponseTypes.test.ts b/packages/javascript/src/utils/__tests__/resolveApplicationResponseTypes.test.ts new file mode 100644 index 00000000..cf56d23c --- /dev/null +++ b/packages/javascript/src/utils/__tests__/resolveApplicationResponseTypes.test.ts @@ -0,0 +1,17 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {describe, expect, it} from 'vitest'; +import resolveApplicationResponseTypes from '../resolveApplicationResponseTypes'; + +describe('resolveApplicationResponseTypes', () => { + it('adds code for the authorization code grant and keeps the other response types', () => { + expect(resolveApplicationResponseTypes(['authorization_code'])).toEqual(['code']); + expect(resolveApplicationResponseTypes(['authorization_code'], ['id_token'])).toEqual(['id_token', 'code']); + expect(resolveApplicationResponseTypes(['authorization_code'], ['code'])).toEqual(['code']); + }); + + it('drops code without the authorization code grant', () => { + expect(resolveApplicationResponseTypes(['client_credentials'], ['code', 'id_token'])).toEqual(['id_token']); + }); +}); diff --git a/packages/javascript/src/utils/__tests__/resolveResourceEndpoint.test.ts b/packages/javascript/src/utils/__tests__/resolveResourceEndpoint.test.ts index 30375afc..52e62fd8 100644 --- a/packages/javascript/src/utils/__tests__/resolveResourceEndpoint.test.ts +++ b/packages/javascript/src/utils/__tests__/resolveResourceEndpoint.test.ts @@ -58,16 +58,29 @@ describe('resolveResourceEndpoint', (): void => { 'agentTypes', 'agents', 'applications', + 'configurationVersions', + 'connections', + 'design', + 'export', 'flowExecute', 'flowMeta', 'flows', + 'gateways', 'groups', + 'i18n', + 'import', + 'notificationTemplates', + 'organizationUnits', + 'resourceServers', 'roles', + 'secrets', + 'serverConfig', 'userTypes', 'users', 'usersMe', 'usersMeCredentials', 'usersMeMeta', + 'variables', ]); }); }); diff --git a/packages/javascript/src/utils/__tests__/toCreateApplicationRequest.test.ts b/packages/javascript/src/utils/__tests__/toCreateApplicationRequest.test.ts new file mode 100644 index 00000000..e3ee13bf --- /dev/null +++ b/packages/javascript/src/utils/__tests__/toCreateApplicationRequest.test.ts @@ -0,0 +1,61 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {describe, expect, it} from 'vitest'; +import {Application, ApplicationSubmission, CreateApplicationRequest, OAuth2Config} from '../../models/application'; +import toCreateApplicationRequest from '../toCreateApplicationRequest'; + +const submission: ApplicationSubmission = { + description: 'Storefront', + grantTypes: ['authorization_code', 'refresh_token'], + logoUrl: 'https://cdn.example.com/logo.png', + name: 'My SPA', + redirectUris: ['https://app.example.com/callback'], + url: 'https://app.example.com', +}; + +// The request types omit keys from a type with an index signature, so their known fields read as +// `unknown`; read the OAuth configuration back through the application shape. +const oauthConfig = (request: CreateApplicationRequest): OAuth2Config | undefined => + (request as Pick).inboundAuthConfig?.[0]?.config; + +describe('toCreateApplicationRequest', () => { + it('builds a confidential OAuth client by default', () => { + expect(toCreateApplicationRequest(submission, 'ou-1')).toEqual({ + description: 'Storefront', + inboundAuthConfig: [ + { + config: { + grantTypes: ['authorization_code', 'refresh_token'], + pkceRequired: undefined, + publicClient: false, + redirectUris: ['https://app.example.com/callback'], + responseTypes: ['code'], + tokenEndpointAuthMethod: 'client_secret_basic', + }, + type: 'oauth2', + }, + ], + logoUrl: 'https://cdn.example.com/logo.png', + name: 'My SPA', + ouId: 'ou-1', + url: 'https://app.example.com', + }); + }); + + it('builds a public client that must use PKCE and does not authenticate at the token endpoint', () => { + const config: OAuth2Config | undefined = oauthConfig( + toCreateApplicationRequest({...submission, publicClient: true}, 'ou-1'), + ); + + expect(config).toMatchObject({pkceRequired: true, publicClient: true, tokenEndpointAuthMethod: 'none'}); + }); + + it('sets no code response type without the authorization code grant', () => { + const config: OAuth2Config | undefined = oauthConfig( + toCreateApplicationRequest({...submission, grantTypes: ['client_credentials']}, 'ou-1'), + ); + + expect(config?.responseTypes).toEqual([]); + }); +}); diff --git a/packages/javascript/src/utils/__tests__/toUpdateApplicationRequest.test.ts b/packages/javascript/src/utils/__tests__/toUpdateApplicationRequest.test.ts new file mode 100644 index 00000000..2b395c5b --- /dev/null +++ b/packages/javascript/src/utils/__tests__/toUpdateApplicationRequest.test.ts @@ -0,0 +1,80 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {describe, expect, it} from 'vitest'; +import {Application, ApplicationSubmission, CreateApplicationRequest, OAuth2Config} from '../../models/application'; +import toUpdateApplicationRequest from '../toUpdateApplicationRequest'; + +const submission: ApplicationSubmission = { + description: 'Storefront', + grantTypes: ['authorization_code', 'refresh_token'], + logoUrl: 'https://cdn.example.com/logo.png', + name: 'My SPA', + redirectUris: ['https://app.example.com/callback'], + url: 'https://app.example.com', +}; + +const application: Application = { + createdAt: '2026-01-01T00:00:00Z', + id: 'app-1', + inboundAuthConfig: [ + { + config: { + clientId: 'client-1', + clientSecret: 'secret-1', + grantTypes: ['authorization_code'], + redirectUris: ['https://old.example.com/callback'], + responseTypes: ['code', 'id_token'], + tokenEndpointAuthMethod: 'client_secret_basic', + }, + type: 'oauth2', + }, + ], + name: 'Old name', + ouId: 'ou-1', + status: {value: 'ACTIVE'}, + themeId: 'theme-1', + updatedAt: '2026-01-02T00:00:00Z', +}; + +// The request types omit keys from a type with an index signature, so their known fields read as +// `unknown`; read the OAuth configuration back through the application shape. +const oauthConfig = (request: CreateApplicationRequest): OAuth2Config | undefined => + (request as Pick).inboundAuthConfig?.[0]?.config; + +describe('toUpdateApplicationRequest', () => { + it('applies the form values, keeps the untouched fields and leaves out what the server sets', () => { + const request = toUpdateApplicationRequest(application, { + ...submission, + grantTypes: ['client_credentials'], + redirectUris: [], + }); + + expect(request).not.toHaveProperty('id'); + expect(request).not.toHaveProperty('createdAt'); + expect(request).not.toHaveProperty('updatedAt'); + expect(request).not.toHaveProperty('status'); + expect(request).toMatchObject({ + description: 'Storefront', + logoUrl: 'https://cdn.example.com/logo.png', + name: 'My SPA', + ouId: 'ou-1', + themeId: 'theme-1', + url: 'https://app.example.com', + }); + expect(oauthConfig(request)).toEqual({ + clientId: 'client-1', + clientSecret: 'secret-1', + grantTypes: ['client_credentials'], + redirectUris: [], + responseTypes: ['id_token'], + tokenEndpointAuthMethod: 'client_secret_basic', + }); + }); + + it('ignores publicClient, which can only be set on create', () => { + const request = toUpdateApplicationRequest(application, {...submission, publicClient: true}); + + expect(oauthConfig(request)?.publicClient).toBeUndefined(); + }); +}); diff --git a/packages/javascript/src/utils/encodeHandlePath.ts b/packages/javascript/src/utils/encodeHandlePath.ts new file mode 100644 index 00000000..6bf3b8aa --- /dev/null +++ b/packages/javascript/src/utils/encodeHandlePath.ts @@ -0,0 +1,22 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import encodePathSegments from './encodePathSegments'; + +/** + * Encodes a hierarchical handle path, such as an organization unit path `engineering/platform`, for a + * `tree/{path}` endpoint. Each handle is percent-encoded and the slashes between them are kept, so the + * server still sees one segment per level. Leading, trailing and repeated slashes are ignored; a path + * with no handle is refused. + * + * @param operation - Name of the calling operation, used as the prefix of the error code. + * @param path - The handle path. + * @returns The encoded path. + */ +const encodeHandlePath = (operation: string, path: string | undefined): string => { + const handles: string[] = typeof path === 'string' ? path.split('/').filter((handle: string) => handle !== '') : []; + + return encodePathSegments(operation, handles.length > 0 ? handles : [undefined]); +}; + +export default encodeHandlePath; diff --git a/packages/javascript/src/utils/encodePathSegments.ts b/packages/javascript/src/utils/encodePathSegments.ts new file mode 100644 index 00000000..3effe7b0 --- /dev/null +++ b/packages/javascript/src/utils/encodePathSegments.ts @@ -0,0 +1,33 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import ThunderIDAPIError from '../errors/ThunderIDAPIError'; + +/** + * Encodes the segments of a management sub-resource path, such as a nested identifier, for use as the + * `subPath` of a management request. Each segment is percent-encoded on its own, so an identifier can + * never add a path segment of its own, and a missing or blank segment is refused with the same code the + * request raises for a blank resource identifier. + * + * @param operation - Name of the calling operation, used as the prefix of the error code. + * @param segments - The path segments, in order. + * @returns The encoded segments joined with `/`. + */ +const encodePathSegments = (operation: string, segments: readonly (string | undefined)[]): string => + segments + .map((segment: string | undefined) => { + if (typeof segment !== 'string' || segment.trim() === '') { + throw new ThunderIDAPIError( + 'A resource identifier is required.', + `${operation}-ValidationError-002`, + 'javascript', + 400, + 'The provided resource identifier is empty.', + ); + } + + return encodeURIComponent(segment); + }) + .join('/'); + +export default encodePathSegments; diff --git a/packages/javascript/src/utils/generateSecret.ts b/packages/javascript/src/utils/generateSecret.ts new file mode 100644 index 00000000..7a2f9b00 --- /dev/null +++ b/packages/javascript/src/utils/generateSecret.ts @@ -0,0 +1,12 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import arrayBufferToBase64url from './arrayBufferToBase64url'; + +/** + * Generates a random secret: 32 bytes from the platform's cryptographically secure generator, encoded + * as base64url. + */ +const generateSecret = (): string => arrayBufferToBase64url(crypto.getRandomValues(new Uint8Array(32)).buffer); + +export default generateSecret; diff --git a/packages/javascript/src/utils/getApplicationClientSecret.ts b/packages/javascript/src/utils/getApplicationClientSecret.ts new file mode 100644 index 00000000..73e499e3 --- /dev/null +++ b/packages/javascript/src/utils/getApplicationClientSecret.ts @@ -0,0 +1,21 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import getApplicationOAuthConfig from './getApplicationOAuthConfig'; +import {Application} from '../models/application'; + +/** + * The client secret the server returned with an application, if any. It is readable only right after + * the application is created. + * + * @param application - The application the server returned. + * @returns The client secret, or `undefined` when there is none. + */ +const getApplicationClientSecret = (application?: Application): string | undefined => { + const secret: string | undefined = getApplicationOAuthConfig(application)?.clientSecret; + + // An empty secret is no secret. + return secret === '' ? undefined : secret; +}; + +export default getApplicationClientSecret; diff --git a/packages/javascript/src/utils/getApplicationOAuthConfig.ts b/packages/javascript/src/utils/getApplicationOAuthConfig.ts new file mode 100644 index 00000000..fc6f785a --- /dev/null +++ b/packages/javascript/src/utils/getApplicationOAuthConfig.ts @@ -0,0 +1,16 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {Application, InboundAuthConfig, InboundAuthTypes, OAuth2Config} from '../models/application'; + +/** + * Returns the OAuth configuration of an application, if it has one. + * + * @param application - The application, or only its inbound authentication configuration. + * @returns The OAuth 2.0 configuration, or `undefined` when the application has no OAuth client. + */ +const getApplicationOAuthConfig = (application?: Pick): OAuth2Config | undefined => + application?.inboundAuthConfig?.find((inbound: InboundAuthConfig) => inbound.type === InboundAuthTypes.OAUTH2) + ?.config; + +export default getApplicationOAuthConfig; diff --git a/packages/javascript/src/utils/isApplicationSubmissionValid.ts b/packages/javascript/src/utils/isApplicationSubmissionValid.ts new file mode 100644 index 00000000..afc1c153 --- /dev/null +++ b/packages/javascript/src/utils/isApplicationSubmissionValid.ts @@ -0,0 +1,25 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ApplicationSubmission, OAuth2GrantTypes} from '../models/application'; + +/** + * Whether an application form's values can be submitted: a name, at least one grant type, a redirect + * URI when the authorization code grant is chosen, and no client credentials grant for a public client, + * which has no secret to authenticate with. + * + * @param submission - The form's values. + * @returns `true` when the values can be submitted. + */ +const isApplicationSubmissionValid = ({ + grantTypes, + name, + publicClient = false, + redirectUris, +}: ApplicationSubmission): boolean => + Boolean(name.trim()) && + grantTypes.length > 0 && + (!grantTypes.includes(OAuth2GrantTypes.AUTHORIZATION_CODE) || redirectUris.length > 0) && + !(publicClient && grantTypes.includes(OAuth2GrantTypes.CLIENT_CREDENTIALS)); + +export default isApplicationSubmissionValid; diff --git a/packages/javascript/src/utils/isDelegatedAgent.ts b/packages/javascript/src/utils/isDelegatedAgent.ts new file mode 100644 index 00000000..22d51af3 --- /dev/null +++ b/packages/javascript/src/utils/isDelegatedAgent.ts @@ -0,0 +1,17 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import DelegationGrantTypes from '../constants/DelegationGrantTypes'; +import {AgentInboundAuthConfig} from '../models/agent-resource'; + +/** + * Whether an agent acts on behalf of a signed-in user, read from the grant types of its OAuth client. + * The server does not store the flag, so a full agent update has to send it for the agent to stay + * held to the delegated grant rules. + */ +const isDelegatedAgent = (inboundAuthConfig?: AgentInboundAuthConfig[]): boolean => + ( + inboundAuthConfig?.find((inbound: AgentInboundAuthConfig) => inbound.type === 'oauth2')?.config?.grantTypes ?? [] + ).some((grantType: string) => DelegationGrantTypes.includes(grantType)); + +export default isDelegatedAgent; diff --git a/packages/javascript/src/utils/parseRedirectUris.ts b/packages/javascript/src/utils/parseRedirectUris.ts new file mode 100644 index 00000000..986582f7 --- /dev/null +++ b/packages/javascript/src/utils/parseRedirectUris.ts @@ -0,0 +1,17 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +/** + * Splits the text of a redirect URIs field into URIs. URIs may be separated by whitespace, new lines + * or commas, and blank entries are dropped. + * + * @param text - The text of the field. + * @returns The redirect URIs. + */ +const parseRedirectUris = (text: string): string[] => + text + .split(/[\s,]+/) + .map((uri: string) => uri.trim()) + .filter(Boolean); + +export default parseRedirectUris; diff --git a/packages/javascript/src/utils/resolveApplicationResponseTypes.ts b/packages/javascript/src/utils/resolveApplicationResponseTypes.ts new file mode 100644 index 00000000..3d12c9a6 --- /dev/null +++ b/packages/javascript/src/utils/resolveApplicationResponseTypes.ts @@ -0,0 +1,22 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {OAuth2GrantTypes, OAuth2ResponseTypes} from '../models/application'; + +/** + * The response types an OAuth client needs for its grant types: `code` when the authorization code + * grant is chosen, and no `code` otherwise. Every other response type the client has is kept. + * + * @param grantTypes - The client's grant types. + * @param current - The client's current response types. + * @returns The response types to save. + */ +const resolveApplicationResponseTypes = (grantTypes: string[], current: string[] = []): string[] => { + if (!grantTypes.includes(OAuth2GrantTypes.AUTHORIZATION_CODE)) { + return current.filter((type: string) => type !== OAuth2ResponseTypes.CODE); + } + + return current.includes(OAuth2ResponseTypes.CODE) ? current : [...current, OAuth2ResponseTypes.CODE]; +}; + +export default resolveApplicationResponseTypes; diff --git a/packages/javascript/src/utils/resolveResourceEndpoint.ts b/packages/javascript/src/utils/resolveResourceEndpoint.ts index 8e6c7d93..ca0b0b2d 100644 --- a/packages/javascript/src/utils/resolveResourceEndpoint.ts +++ b/packages/javascript/src/utils/resolveResourceEndpoint.ts @@ -16,16 +16,29 @@ export type ResourceEndpointKey = | 'agents' | 'agentTypes' | 'applications' + | 'configurationVersions' + | 'connections' + | 'design' + | 'export' | 'flowExecute' | 'flowMeta' | 'flows' + | 'gateways' | 'groups' + | 'i18n' + | 'import' + | 'notificationTemplates' + | 'organizationUnits' + | 'resourceServers' | 'roles' + | 'secrets' + | 'serverConfig' | 'users' | 'usersMe' | 'usersMeCredentials' | 'usersMeMeta' - | 'userTypes'; + | 'userTypes' + | 'variables'; /** * The `config.endpoints` keys that address resource-server endpoints rather than OIDC/OAuth @@ -35,16 +48,29 @@ export const RESOURCE_ENDPOINT_KEYS: readonly ResourceEndpointKey[] = [ 'agents', 'agentTypes', 'applications', + 'configurationVersions', + 'connections', + 'design', + 'export', 'flowExecute', 'flowMeta', 'flows', + 'gateways', 'groups', + 'i18n', + 'import', + 'notificationTemplates', + 'organizationUnits', + 'resourceServers', 'roles', + 'secrets', + 'serverConfig', 'users', 'usersMe', 'usersMeCredentials', 'usersMeMeta', 'userTypes', + 'variables', ]; /** diff --git a/packages/javascript/src/utils/toCreateApplicationRequest.ts b/packages/javascript/src/utils/toCreateApplicationRequest.ts new file mode 100644 index 00000000..84a5c041 --- /dev/null +++ b/packages/javascript/src/utils/toCreateApplicationRequest.ts @@ -0,0 +1,46 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import resolveApplicationResponseTypes from './resolveApplicationResponseTypes'; +import { + ApplicationSubmission, + CreateApplicationRequest, + InboundAuthTypes, + TokenEndpointAuthMethods, +} from '../models/application'; + +/** + * Builds the request that creates an application with an OAuth client from an application form's values. + * A public client gets no client secret, must use PKCE, and does not authenticate at the token endpoint. + * + * @param submission - The form's values. + * @param ouId - The organization unit the application belongs to. + * @returns The create request. + */ +const toCreateApplicationRequest = ( + {description, grantTypes, logoUrl, name, publicClient = false, redirectUris, url}: ApplicationSubmission, + ouId: string, +): CreateApplicationRequest => ({ + description, + inboundAuthConfig: [ + { + config: { + grantTypes, + pkceRequired: publicClient || undefined, + publicClient, + redirectUris, + responseTypes: resolveApplicationResponseTypes(grantTypes), + tokenEndpointAuthMethod: publicClient + ? TokenEndpointAuthMethods.NONE + : TokenEndpointAuthMethods.CLIENT_SECRET_BASIC, + }, + type: InboundAuthTypes.OAUTH2, + }, + ], + logoUrl, + name, + ouId, + url, +}); + +export default toCreateApplicationRequest; diff --git a/packages/javascript/src/utils/toUpdateApplicationRequest.ts b/packages/javascript/src/utils/toUpdateApplicationRequest.ts new file mode 100644 index 00000000..a47569a2 --- /dev/null +++ b/packages/javascript/src/utils/toUpdateApplicationRequest.ts @@ -0,0 +1,55 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import resolveApplicationResponseTypes from './resolveApplicationResponseTypes'; +import { + Application, + ApplicationSubmission, + InboundAuthConfig, + InboundAuthTypes, + UpdateApplicationRequest, +} from '../models/application'; + +/** + * Applies an application form's values to an application, keeping every field the form does not touch. + * The update replaces the application, so the result carries everything the server returned, except the + * fields the server sets. + * + * @param application - The application as the server returned it. + * @param submission - The form's values. `publicClient` is ignored: it can only be set on create. + * @returns The update request. + */ +const toUpdateApplicationRequest = ( + application: Application, + {description, grantTypes, logoUrl, name, redirectUris, url}: ApplicationSubmission, +): UpdateApplicationRequest => { + // The server sets these, so they do not go back in the update. + const {createdAt, id, status, updatedAt, ...current}: Application = application; + void createdAt; + void id; + void status; + void updatedAt; + + return { + ...current, + description, + inboundAuthConfig: current.inboundAuthConfig?.map((inbound: InboundAuthConfig) => + inbound.type === InboundAuthTypes.OAUTH2 + ? { + ...inbound, + config: { + ...inbound.config, + grantTypes, + redirectUris, + responseTypes: resolveApplicationResponseTypes(grantTypes, inbound.config.responseTypes), + }, + } + : inbound, + ), + logoUrl, + name, + url, + }; +}; + +export default toUpdateApplicationRequest; diff --git a/packages/nextjs/src/client/admin.ts b/packages/nextjs/src/client/admin.ts index 2691cecc..b9706c5e 100644 --- a/packages/nextjs/src/client/admin.ts +++ b/packages/nextjs/src/client/admin.ts @@ -4,10 +4,11 @@ '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. +// unstyled `Base*` admin components, which take their data as props, and `useManagementRequestConfig`, +// whose config lets an app call the management API functions 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, @@ -50,54 +51,19 @@ export { BaseAgentDetails, type BaseAgentDetailsProps, type BaseAgentDetailsRenderProps, + BaseApplicationList, + type BaseApplicationListProps, + type BaseApplicationListRenderProps, + BaseApplicationCreate, + type BaseApplicationCreateProps, + type BaseApplicationCreateRenderProps, + type ApplicationCreateSubmission, + BaseApplicationDetails, + type BaseApplicationDetailsProps, + type BaseApplicationDetailsRenderProps, + type ApplicationUpdateSubmission, + type ApplicationFormValues, + type ApplicationTextField, } 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'; +export {useManagementRequestConfig, type ManagementCollection, type GrantedPermissions} from '@thunderid/react'; diff --git a/packages/nextjs/src/client/components/admin/ApplicationCreate/ApplicationCreate.tsx b/packages/nextjs/src/client/components/admin/ApplicationCreate/ApplicationCreate.tsx new file mode 100644 index 00000000..195d26d3 --- /dev/null +++ b/packages/nextjs/src/client/components/admin/ApplicationCreate/ApplicationCreate.tsx @@ -0,0 +1,32 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +'use client'; + +import { + ApplicationCreate as ReactApplicationCreate, + ApplicationCreateProps as ReactApplicationCreateProps, +} from '@thunderid/react'; +import {FC, ReactElement} from 'react'; + +export type ApplicationCreateProps = ReactApplicationCreateProps; + +/** + * Creates an application with an OAuth client, and shows its client secret once when the server + * returns one. + * + * 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/applications/${application.id}`)} /> + * ``` + */ +const ApplicationCreate: FC = (props: ApplicationCreateProps): ReactElement => ( + +); + +ApplicationCreate.displayName = 'ApplicationCreate'; + +export default ApplicationCreate; diff --git a/packages/nextjs/src/client/components/admin/ApplicationDetails/ApplicationDetails.tsx b/packages/nextjs/src/client/components/admin/ApplicationDetails/ApplicationDetails.tsx new file mode 100644 index 00000000..6f53bbc7 --- /dev/null +++ b/packages/nextjs/src/client/components/admin/ApplicationDetails/ApplicationDetails.tsx @@ -0,0 +1,32 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +'use client'; + +import { + ApplicationDetails as ReactApplicationDetails, + ApplicationDetailsProps as ReactApplicationDetailsProps, +} from '@thunderid/react'; +import {FC, ReactElement} from 'react'; + +export type ApplicationDetailsProps = ReactApplicationDetailsProps; + +/** + * Shows an application and its OAuth configuration, and optionally edits it, regenerates its client + * secret or deletes it. + * + * 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/applications')} /> + * ``` + */ +const ApplicationDetails: FC = (props: ApplicationDetailsProps): ReactElement => ( + +); + +ApplicationDetails.displayName = 'ApplicationDetails'; + +export default ApplicationDetails; diff --git a/packages/nextjs/src/client/components/admin/ApplicationList/ApplicationList.tsx b/packages/nextjs/src/client/components/admin/ApplicationList/ApplicationList.tsx new file mode 100644 index 00000000..5392567e --- /dev/null +++ b/packages/nextjs/src/client/components/admin/ApplicationList/ApplicationList.tsx @@ -0,0 +1,31 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +'use client'; + +import { + ApplicationList as ReactApplicationList, + ApplicationListProps as ReactApplicationListProps, +} from '@thunderid/react'; +import {FC, ReactElement} from 'react'; + +export type ApplicationListProps = ReactApplicationListProps; + +/** + * Lists applications, 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/applications/${application.id}`)} /> + * ``` + */ +const ApplicationList: FC = (props: ApplicationListProps): ReactElement => ( + +); + +ApplicationList.displayName = 'ApplicationList'; + +export default ApplicationList; diff --git a/packages/nextjs/src/client/index.ts b/packages/nextjs/src/client/index.ts index 59432593..777df5b1 100644 --- a/packages/nextjs/src/client/index.ts +++ b/packages/nextjs/src/client/index.ts @@ -89,6 +89,15 @@ 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 ApplicationList} from './components/admin/ApplicationList/ApplicationList'; +export type {ApplicationListProps} from './components/admin/ApplicationList/ApplicationList'; + +export {default as ApplicationCreate} from './components/admin/ApplicationCreate/ApplicationCreate'; +export type {ApplicationCreateProps} from './components/admin/ApplicationCreate/ApplicationCreate'; + +export {default as ApplicationDetails} from './components/admin/ApplicationDetails/ApplicationDetails'; +export type {ApplicationDetailsProps} from './components/admin/ApplicationDetails/ApplicationDetails'; + export {default as useGrantedPermissions} from './hooks/management/useGrantedPermissions'; export * from './admin'; diff --git a/packages/node/src/__tests__/ThunderIDNodeClient.management.test.ts b/packages/node/src/__tests__/ThunderIDNodeClient.management.test.ts index 354a8020..f588c0da 100644 --- a/packages/node/src/__tests__/ThunderIDNodeClient.management.test.ts +++ b/packages/node/src/__tests__/ThunderIDNodeClient.management.test.ts @@ -100,7 +100,12 @@ describe('ThunderIDNodeClient management()', () => { 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.userTypes.list(), + method: 'GET', + name: 'userTypes.list', + url: `${BASE_URL}/user-types?include=display`, + }, { call: () => admin.groups.list({limit: 2, offset: 4}), method: 'GET', @@ -148,8 +153,13 @@ describe('ThunderIDNodeClient management()', () => { 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`}, + { + call: () => admin.roles.list({limit: 1}), + method: 'GET', + name: 'roles.list', + url: `${BASE_URL}/roles?include=display&limit=1`, + }, + {call: () => admin.roles.get('r1'), method: 'GET', name: 'roles.get', url: `${BASE_URL}/roles/r1?include=display`}, { body: {name: 'Auditor', ouId: 'ou1'}, call: () => admin.roles.create({name: 'Auditor', ouId: 'ou1'} as never), @@ -212,6 +222,83 @@ describe('ThunderIDNodeClient management()', () => { url: `${BASE_URL}/agents/a1`, }, {call: () => admin.agents.delete('a1'), method: 'DELETE', name: 'agents.delete', url: `${BASE_URL}/agents/a1`}, + { + call: () => admin.applications.list({limit: 5, offset: 10}), + method: 'GET', + name: 'applications.list', + url: `${BASE_URL}/applications?limit=5&offset=10`, + }, + { + call: () => admin.applications.get('app1'), + method: 'GET', + name: 'applications.get', + url: `${BASE_URL}/applications/app1`, + }, + { + body: {name: 'My SPA'}, + call: () => admin.applications.create({name: 'My SPA'} as never), + method: 'POST', + name: 'applications.create', + url: `${BASE_URL}/applications`, + }, + { + body: {name: 'Renamed'}, + call: () => admin.applications.update('app1', {name: 'Renamed'} as never), + method: 'PUT', + name: 'applications.update', + url: `${BASE_URL}/applications/app1`, + }, + { + call: () => admin.applications.delete('app1'), + method: 'DELETE', + name: 'applications.delete', + url: `${BASE_URL}/applications/app1`, + }, + { + body: {clientId: 'https://client.example.com/metadata.json'}, + call: () => admin.applications.previewCimdDocument('https://client.example.com/metadata.json'), + method: 'POST', + name: 'applications.previewCimdDocument', + url: `${BASE_URL}/cimd/preview`, + }, + { + call: () => admin.applications.listSharingPolicies('app1', {limit: 3}), + method: 'GET', + name: 'applications.listSharingPolicies', + url: `${BASE_URL}/applications/app1/sharing-policies?limit=3`, + }, + { + body: {version: 1}, + call: () => admin.applications.createSharingPolicy('app1', {version: 1} as never), + method: 'POST', + name: 'applications.createSharingPolicy', + url: `${BASE_URL}/applications/app1/sharing-policies`, + }, + { + call: () => admin.applications.getSharingPolicy('app1', 'p1'), + method: 'GET', + name: 'applications.getSharingPolicy', + url: `${BASE_URL}/applications/app1/sharing-policies/p1`, + }, + { + body: {version: 2}, + call: () => admin.applications.updateSharingPolicy('app1', 'p1', {version: 2}), + method: 'PUT', + name: 'applications.updateSharingPolicy', + url: `${BASE_URL}/applications/app1/sharing-policies/p1`, + }, + { + call: () => admin.applications.deleteSharingPolicy('app1', 'p1'), + method: 'DELETE', + name: 'applications.deleteSharingPolicy', + url: `${BASE_URL}/applications/app1/sharing-policies/p1`, + }, + { + call: () => admin.applications.getOverlayRules('app1', 'ou2'), + method: 'GET', + name: 'applications.getOverlayRules', + url: `${BASE_URL}/applications/app1/overlay-rules?ouId=ou2`, + }, { call: () => admin.agents.listGroups('a1'), method: 'GET', @@ -224,13 +311,200 @@ describe('ThunderIDNodeClient management()', () => { 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.agentTypes.list(), + method: 'GET', + name: 'agentTypes.list', + url: `${BASE_URL}/agent-types?include=display`, + }, { call: () => admin.flows.list({flowType: 'AUTHENTICATION'}), method: 'GET', name: 'flows.list', url: `${BASE_URL}/flows?flowType=AUTHENTICATION`, }, + { + body: {displayName: 'Default', handle: 'default', ouId: 'ou1', schema: {}}, + call: () => admin.agentTypes.create({displayName: 'Default', handle: 'default', ouId: 'ou1', schema: {}}), + method: 'POST', + name: 'agentTypes.create', + url: `${BASE_URL}/agent-types`, + }, + { + call: () => admin.agentTypes.delete('t1'), + method: 'DELETE', + name: 'agentTypes.delete', + url: `${BASE_URL}/agent-types/t1`, + }, + { + call: () => admin.users.listByPath('eng/plat form', {limit: 2}), + method: 'GET', + name: 'users.listByPath', + url: `${BASE_URL}/users/tree/eng/plat%20form?include=display&limit=2`, + }, + { + body: {type: 'customer'}, + call: () => admin.users.createByPath('eng', {type: 'customer'}), + method: 'POST', + name: 'users.createByPath', + url: `${BASE_URL}/users/tree/eng`, + }, + { + call: () => admin.users.listSharingPolicies('u1'), + method: 'GET', + name: 'users.listSharingPolicies', + url: `${BASE_URL}/users/u1/sharing-policies`, + }, + { + body: {version: 2}, + call: () => admin.users.updateSharingPolicy('u1', 'p1', {version: 2}), + method: 'PUT', + name: 'users.updateSharingPolicy', + url: `${BASE_URL}/users/u1/sharing-policies/p1`, + }, + { + call: () => admin.users.getOverlayRules('u1', 'ou2'), + method: 'GET', + name: 'users.getOverlayRules', + url: `${BASE_URL}/users/u1/overlay-rules?ouId=ou2`, + }, + { + call: () => admin.organizationUnits.list({filter: 'handle eq "eng"'}), + method: 'GET', + name: 'organizationUnits.list', + url: `${BASE_URL}/organization-units?filter=handle+eq+%22eng%22`, + }, + { + call: () => admin.organizationUnits.getByPath('eng/platform'), + method: 'GET', + name: 'organizationUnits.getByPath', + url: `${BASE_URL}/organization-units/tree/eng/platform`, + }, + { + call: () => admin.organizationUnits.listChildren({path: 'eng'}, {limit: 3}), + method: 'GET', + name: 'organizationUnits.listChildren', + url: `${BASE_URL}/organization-units/tree/eng/ous?limit=3`, + }, + { + call: () => admin.organizationUnits.listUsers({ouId: 'ou1'}), + method: 'GET', + name: 'organizationUnits.listUsers', + url: `${BASE_URL}/organization-units/ou1/users?include=display`, + }, + { + call: () => admin.resourceServers.listActions('rs1', {kind: 'tool', resourceId: 'r1'}), + method: 'GET', + name: 'resourceServers.listActions', + url: `${BASE_URL}/resource-servers/rs1/resources/r1/actions?kind=tool`, + }, + { + call: () => admin.resourceServers.deleteAction('rs1', 'a1'), + method: 'DELETE', + name: 'resourceServers.deleteAction', + url: `${BASE_URL}/resource-servers/rs1/actions/a1`, + }, + { + call: () => admin.connections.list({category: 'http'}), + method: 'GET', + name: 'connections.list', + url: `${BASE_URL}/connections?category=http`, + }, + { + call: () => admin.connections.listByType('google'), + method: 'GET', + name: 'connections.listByType', + url: `${BASE_URL}/connections/google`, + }, + { + call: () => admin.connections.listUsages('oidc', 'c1'), + method: 'GET', + name: 'connections.listUsages', + url: `${BASE_URL}/connections/oidc/c1/usages`, + }, + { + body: {version: 2}, + call: () => admin.flows.restoreVersion('f1', 2), + method: 'POST', + name: 'flows.restoreVersion', + url: `${BASE_URL}/flows/f1/restore`, + }, + { + call: () => admin.notificationTemplates.get('email', 't1'), + method: 'GET', + name: 'notificationTemplates.get', + url: `${BASE_URL}/notification-templates/email/templates/t1`, + }, + { + call: () => admin.design.listThemes({limit: 4}), + method: 'GET', + name: 'design.listThemes', + url: `${BASE_URL}/design/themes?limit=4`, + }, + { + call: () => admin.design.resolve('OU', 'ou1'), + method: 'GET', + name: 'design.resolve', + url: `${BASE_URL}/design/resolve?id=ou1&type=OU`, + }, + { + body: {value: 'Hi'}, + call: () => admin.i18n.setTranslation('en-US', 'auth', 'title', {value: 'Hi'}), + method: 'POST', + name: 'i18n.setTranslation', + url: `${BASE_URL}/i18n/languages/en-US/translations/ns/auth/keys/title`, + }, + { + call: () => admin.serverConfig.get('flow'), + method: 'GET', + name: 'serverConfig.get', + url: `${BASE_URL}/server-config/flow`, + }, + { + call: () => admin.gateways.getConfigurationDiff('gw1', {version: 'latest'}), + method: 'GET', + name: 'gateways.getConfigurationDiff', + url: `${BASE_URL}/gateways/gw1/diff?version=latest`, + }, + { + body: {value: 'x'}, + call: () => admin.gateways.updateSecret('gw1', 'S', {value: 'x'}), + method: 'PUT', + name: 'gateways.updateSecret', + url: `${BASE_URL}/gateways/gw1/secrets/S`, + }, + { + call: () => admin.variables.list({names: ['A', 'B']}), + method: 'GET', + name: 'variables.list', + url: `${BASE_URL}/variables?names=A%2CB`, + }, + { + call: () => admin.secrets.delete('S'), + method: 'DELETE', + name: 'secrets.delete', + url: `${BASE_URL}/secrets/S`, + }, + { + call: () => admin.configuration.listVersions(), + method: 'GET', + name: 'configuration.listVersions', + url: `${BASE_URL}/configuration-versions`, + }, + { + body: {roles: ['r1']}, + call: () => admin.configuration.export({roles: ['r1']}), + method: 'POST', + name: 'configuration.export', + url: `${BASE_URL}/export`, + }, + { + body: {resourceKey: 'k', resourceType: 'role'}, + call: () => admin.configuration.deleteImported({resourceKey: 'k', resourceType: 'role'}), + method: 'POST', + name: 'configuration.deleteImported', + url: `${BASE_URL}/import/delete`, + }, ]; it.each(cases)('$name sends $method $url with the session bearer token', async ({body, call, method, url}) => { @@ -265,10 +539,10 @@ describe('ThunderIDNodeClient management()', () => { const scoped: ThunderIDNodeManagementClient = overridden.management('session-1'); await scoped.roles.get('r1'); - expect(lastCall(fetchMock).url).toBe('https://rs.example.com/v2/roles/r1'); + expect(lastCall(fetchMock).url).toBe('https://rs.example.com/v2/roles/r1?include=display'); await scoped.roles.get('r1', {url: 'https://other.example.com/roles'}); - expect(lastCall(fetchMock).url).toBe('https://other.example.com/roles/r1'); + expect(lastCall(fetchMock).url).toBe('https://other.example.com/roles/r1?include=display'); await scoped.groups.get('g1'); expect(lastCall(fetchMock).url).toBe(`${BASE_URL}/groups/g1?include=display`); @@ -319,4 +593,108 @@ describe('ThunderIDNodeClient management()', () => { expect(String((error as Error).message)).not.toContain(TOKEN); }, ); + + it('runs an account lifecycle operation through its flow and reports whether the email was sent', async () => { + // The flow configuration, the flow listing, then the flow execution. + fetchMock + .mockResolvedValueOnce(jsonResponse({merged: {userSuspendFlow: {defaultHandle: 'suspend-user'}}})) + .mockResolvedValueOnce(jsonResponse({flows: [{flowType: 'ADMINISTRATION', handle: 'suspend-user', id: 'f1'}]})) + .mockResolvedValueOnce(jsonResponse({data: {additionalData: {emailSent: 'false'}}, flowStatus: 'COMPLETE'})); + + await expect(admin.users.suspend('u1', {operatorNote: 'Ticket 7'})).resolves.toEqual({emailSent: false}); + + const captured: Captured = lastCall(fetchMock); + expect(captured.url).toBe(`${BASE_URL}/flow/execute`); + expect(captured.headers['Authorization']).toBe(`Bearer ${TOKEN}`); + expect(captured.body).toEqual({flowId: 'f1', inputs: {operatorNote: 'Ticket 7', subject: 'u1'}}); + expect(fetchMock.mock.calls.map(([url]) => url as string)).toEqual([ + `${BASE_URL}/server-config/flow`, + `${BASE_URL}/flows?flowType=ADMINISTRATION&limit=100&offset=0`, + `${BASE_URL}/flow/execute`, + ]); + }); + + it('refuses an account lifecycle operation when no flow is configured', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse({merged: {}})); + + await expect(admin.agents.unlock('a1')).rejects.toMatchObject({code: 'unlockAgent-NotConfiguredError-001'}); + }); + + it('regenerates an application client secret by reading the application and writing it back', async () => { + fetchMock + .mockResolvedValueOnce( + jsonResponse({ + id: 'app1', + inboundAuthConfig: [{config: {clientId: 'c1'}, type: 'oauth2'}], + name: 'My SPA', + }), + ) + .mockResolvedValueOnce(jsonResponse({id: 'app1', name: 'My SPA'})); + + const {clientSecret} = await admin.applications.regenerateClientSecret('app1'); + + const captured: Captured = lastCall(fetchMock); + expect(captured.method).toBe('PUT'); + expect(captured.url).toBe(`${BASE_URL}/applications/app1`); + expect(captured.headers['Authorization']).toBe(`Bearer ${TOKEN}`); + expect(captured.body).toEqual({ + inboundAuthConfig: [{config: {clientId: 'c1', clientSecret}, type: 'oauth2'}], + name: 'My SPA', + }); + }); + + it('regenerates an application flow secret by reading the application and writing it back', async () => { + fetchMock + .mockResolvedValueOnce(jsonResponse({id: 'app1', name: 'My SPA'})) + .mockResolvedValueOnce(jsonResponse({id: 'app1', name: 'My SPA'})); + + const {flowSecret} = await admin.applications.regenerateFlowSecret('app1'); + + const captured: Captured = lastCall(fetchMock); + expect(captured.method).toBe('PUT'); + expect(captured.url).toBe(`${BASE_URL}/applications/app1`); + expect(captured.body).toEqual({flowSecret, name: 'My SPA'}); + }); + + it('suspends an application through its account lifecycle flow', async () => { + fetchMock + .mockResolvedValueOnce(jsonResponse({merged: {applicationSuspendFlow: {defaultHandle: 'suspend-app'}}})) + .mockResolvedValueOnce(jsonResponse({flows: [{flowType: 'ADMINISTRATION', handle: 'suspend-app', id: 'f2'}]})) + .mockResolvedValueOnce(jsonResponse({data: {additionalData: {emailSent: 'true'}}, flowStatus: 'COMPLETE'})); + + await expect(admin.applications.suspend('app1', {operatorNote: 'Ticket 9'})).resolves.toEqual({emailSent: true}); + expect(lastCall(fetchMock).body).toEqual({flowId: 'f2', inputs: {operatorNote: 'Ticket 9', subject: 'app1'}}); + }); + + it('refuses an application lifecycle operation when no flow is configured', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse({merged: {}})); + + await expect(admin.applications.unsuspend('app1')).rejects.toMatchObject({ + code: 'unsuspendApplication-NotConfiguredError-001', + }); + }); + + it('refuses a missing application identifier before reading the token or sending anything', async () => { + await expect(admin.applications.delete('' as never)).rejects.toMatchObject({ + code: 'deleteApplication-ValidationError-002', + }); + await expect(admin.applications.getSharingPolicy('app1', undefined as never)).rejects.toMatchObject({ + code: 'getApplicationSharingPolicy-ValidationError-002', + }); + await expect(admin.applications.regenerateClientSecret(undefined as never)).rejects.toMatchObject({ + code: 'regenerateClientSecret-ValidationError-002', + }); + expect(getAccessToken).not.toHaveBeenCalled(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('refuses a missing identifier on the new accessors before sending anything', async () => { + await expect(admin.gateways.getSecret('gw1', '' as never)).rejects.toMatchObject({ + code: 'getGatewaySecret-ValidationError-002', + }); + await expect(admin.connections.get('google', undefined as never)).rejects.toMatchObject({ + code: 'getConnection-ValidationError-002', + }); + expect(fetchMock).not.toHaveBeenCalled(); + }); }); diff --git a/packages/node/src/models/management.ts b/packages/node/src/models/management.ts index aa047246..f46069df 100644 --- a/packages/node/src/models/management.ts +++ b/packages/node/src/models/management.ts @@ -2,36 +2,136 @@ // SPDX-License-Identifier: Apache-2.0 import { + AccountLifecycleOptions, + AccountLifecycleResult, + AdministrationOptions, Agent, AgentListResponse, AgentRoleListResponse, + AgentTypeDetails, AgentTypeListResponse, ApiFetcher, ApiFilteringParams, + Application, + ApplicationListResponse, + ApplyGatewayConfigurationRequest, + CaptureConfigurationVersionRequest, + CimdPreviewResponse, + ConfigurationExportRequest, + ConfigurationExportResponse, + ConfigurationImportRequest, + ConfigurationImportResponse, + ConfigurationVersion, + Connection, + ConnectionCategory, + ConnectionListResponse, + ConnectionMetaResponse, + ConnectionRequest, + ConnectionSummary, + ConnectionType, + ConnectionTypeWithUsages, + ConnectionUsagesResponse, + CreateActionRequest, CreateAgentRequest, + CreateAgentTypeRequest, + CreateApplicationRequest, CreateGroupRequest, CreateManagedUserRequest, + CreateNotificationTemplateRequest, + CreateOrganizationUnitRequest, + CreateResourceRequest, + CreateResourceServerRequest, CreateRoleRequest, + CreateUserByPathRequest, + DeleteImportedConfigurationRequest, + DeleteImportedConfigurationResponse, + DesignLayout, + DesignLayoutListResponse, + DesignLayoutRequest, + DesignResolveResponse, + DesignResolveType, + DesignTheme, + DesignThemeListResponse, + DesignThemeRequest, + Gateway, + GatewayAppliedConfiguration, + GatewayAppliedVersion, + GatewayApplyResult, + GatewayConfigurationDiff, + GatewayRegistration, Group, GroupListResponse, GroupMember, GroupMemberListResponse, + LanguageListResponse, + LanguageTranslationsResponse, + ManagedAction, + ManagedActionKind, + ManagedActionListResponse, + ManagedFlowDefinition, ManagedFlowListResponse, + ManagedFlowRequest, + ManagedFlowVersion, + ManagedFlowVersionListResponse, + ManagedResource, + ManagedResourceListResponse, + ManagedTranslation, ManagedUser, ManagedUserListResponse, ManagedUserTypeListResponse, ManagementRequestConfig, MembershipGroupListResponse, + NotificationChannel, + NotificationTemplate, + NotificationTemplateListResponse, + OrganizationUnit, + OrganizationUnitGroupListResponse, + OrganizationUnitListResponse, + OrganizationUnitReference, + OrganizationUnitRoleListResponse, + OrganizationUnitUserListResponse, + RegenerateClientSecretResult, + RegenerateFlowSecretResult, + RegisterGatewayRequest, + ResolvedOverlay, ResourceEndpointKey, + ResourceServer, + ResourceServerListResponse, + ResourceUsagesResponse, + RevertGatewayConfigurationRequest, Role, RoleAssigneeType, RoleAssignmentInput, RoleAssignmentListResponse, RoleListResponse, + Secret, + SecretListResponse, + SecretRequest, + SecretUpdateRequest, + ServerConfigLayers, + SetTranslationRequest, + SetTranslationsRequest, + SharingPolicy, + SharingPolicyListResponse, + SharingPolicyRequest, + SharingPolicyUpdateRequest, + UpdateActionRequest, UpdateAgentRequest, + UpdateAgentTypeRequest, + UpdateApplicationRequest, + UpdateGatewayRequest, UpdateGroupRequest, UpdateManagedUserRequest, + UpdateNotificationTemplateRequest, + UpdateOrganizationUnitByPathRequest, + UpdateOrganizationUnitRequest, + UpdateResourceRequest, + UpdateResourceServerRequest, UpdateRoleRequest, + Variable, + VariableListResponse, + VariableRequest, + VariableUpdateRequest, } from '@thunderid/javascript'; /** @@ -39,7 +139,27 @@ import { */ export type ManagementResourceKey = Extract< ResourceEndpointKey, - 'agents' | 'agentTypes' | 'flows' | 'groups' | 'roles' | 'users' | 'userTypes' + | 'agents' + | 'agentTypes' + | 'applications' + | 'configurationVersions' + | 'connections' + | 'design' + | 'export' + | 'flows' + | 'gateways' + | 'groups' + | 'i18n' + | 'import' + | 'notificationTemplates' + | 'organizationUnits' + | 'resourceServers' + | 'roles' + | 'secrets' + | 'serverConfig' + | 'users' + | 'userTypes' + | 'variables' >; /** @@ -68,6 +188,22 @@ export interface ManagementCallOptions { */ export type ManagementListOptions = ManagementCallOptions & Pick; +/** + * Options for a paginated, filterable listing of variables or secrets. + */ +export type ManagementValueListOptions = ManagementCallOptions & ApiFilteringParams & {names?: string[]}; + +/** + * Options of an account lifecycle operation, such as suspending a user. + */ +export type ManagementLifecycleOptions = ManagementCallOptions & AccountLifecycleOptions; + +/** + * Options of an operation on an action, which belongs to a resource server or, when `resourceId` is given, + * to one of its resources. + */ +export type ManagementActionOptions = ManagementCallOptions & {resourceId?: string}; + /** * Member reference accepted when adding or removing group members. */ @@ -96,7 +232,15 @@ export type ManagementRequestConfigResolver = ( */ export interface ThunderIDNodeManagementClient { agentTypes: { + create(payload: CreateAgentTypeRequest, options?: ManagementCallOptions): Promise; + delete(agentTypeId: string, options?: ManagementCallOptions): Promise; + get(agentTypeId: string, options?: ManagementCallOptions): Promise; list(options?: ManagementListOptions): Promise; + update( + agentTypeId: string, + payload: UpdateAgentTypeRequest, + options?: ManagementCallOptions, + ): Promise; }; agents: { create(payload: CreateAgentRequest, options?: ManagementCallOptions): Promise; @@ -105,10 +249,176 @@ export interface ThunderIDNodeManagementClient { list(options?: ManagementCallOptions & ApiFilteringParams): Promise; listGroups(agentId: string, options?: ManagementListOptions): Promise; listRoles(agentId: string, options?: ManagementListOptions): Promise; + suspend(agentId: string, options?: ManagementLifecycleOptions): Promise; + unlock(agentId: string, options?: ManagementLifecycleOptions): Promise; + unsuspend(agentId: string, options?: ManagementLifecycleOptions): Promise; update(agentId: string, payload: UpdateAgentRequest, options?: ManagementCallOptions): Promise; }; + applications: { + create(payload: CreateApplicationRequest, options?: ManagementCallOptions): Promise; + createSharingPolicy( + applicationId: string, + payload: SharingPolicyRequest, + options?: ManagementCallOptions, + ): Promise; + /** + * Deletes the application, through the server's deletion flow when `administrationMode` is `flow`. + */ + delete(applicationId: string, options?: ManagementCallOptions & AdministrationOptions): Promise; + deleteSharingPolicy(applicationId: string, policyId: string, options?: ManagementCallOptions): Promise; + get(applicationId: string, options?: ManagementCallOptions): Promise; + getOverlayRules(applicationId: string, ouId: string, options?: ManagementCallOptions): Promise; + getSharingPolicy(applicationId: string, policyId: string, options?: ManagementCallOptions): Promise; + list(options?: ManagementListOptions): Promise; + listSharingPolicies(applicationId: string, options?: ManagementListOptions): Promise; + /** + * Reads a client ID metadata document through the server. The preview is an endpoint of its own + * under `baseUrl`, so a collection `url` passed to the call does not apply. + */ + previewCimdDocument(clientId: string, options?: ManagementCallOptions): Promise; + /** + * Issues a new client secret. The secret is returned once and cannot be read again. + */ + regenerateClientSecret( + applicationId: string, + options?: ManagementCallOptions & AdministrationOptions, + ): Promise; + /** + * Issues a new flow secret. The secret is returned once and cannot be read again. + */ + regenerateFlowSecret(applicationId: string, options?: ManagementCallOptions): Promise; + suspend(applicationId: string, options?: ManagementLifecycleOptions): Promise; + unsuspend(applicationId: string, options?: ManagementLifecycleOptions): Promise; + update( + applicationId: string, + payload: UpdateApplicationRequest, + options?: ManagementCallOptions, + ): Promise; + updateSharingPolicy( + applicationId: string, + policyId: string, + payload: SharingPolicyUpdateRequest, + options?: ManagementCallOptions, + ): Promise; + }; + /** + * Captured configuration versions, and the import and export of declarative resources. + */ + configuration: { + captureVersion( + payload?: CaptureConfigurationVersionRequest, + options?: ManagementCallOptions, + ): Promise; + deleteImported( + payload: DeleteImportedConfigurationRequest, + options?: ManagementCallOptions, + ): Promise; + export(payload: ConfigurationExportRequest, options?: ManagementCallOptions): Promise; + getVersion(version: string, options?: ManagementCallOptions): Promise; + import(payload: ConfigurationImportRequest, options?: ManagementCallOptions): Promise; + listVersions(options?: ManagementCallOptions): Promise; + }; + connections: { + create(type: ConnectionType, payload: ConnectionRequest, options?: ManagementCallOptions): Promise; + delete(type: ConnectionType, connectionId: string, options?: ManagementCallOptions): Promise; + get(type: ConnectionType, connectionId: string, options?: ManagementCallOptions): Promise; + getMeta(options?: ManagementCallOptions & {vendor?: ConnectionType}): Promise; + /** + * A page of connections across every type. + */ + list(options?: ManagementListOptions & {category?: ConnectionCategory}): Promise; + /** + * Every connection of one type. + */ + listByType(type: ConnectionType, options?: ManagementCallOptions): Promise; + listUsages( + type: ConnectionTypeWithUsages, + connectionId: string, + options?: ManagementCallOptions, + ): Promise; + update( + type: ConnectionType, + connectionId: string, + payload: ConnectionRequest, + options?: ManagementCallOptions, + ): Promise; + }; + design: { + createLayout(payload: DesignLayoutRequest, options?: ManagementCallOptions): Promise; + createTheme(payload: DesignThemeRequest, options?: ManagementCallOptions): Promise; + deleteLayout(layoutId: string, options?: ManagementCallOptions): Promise; + deleteTheme(themeId: string, options?: ManagementCallOptions): Promise; + getLayout(layoutId: string, options?: ManagementCallOptions): Promise; + getTheme(themeId: string, options?: ManagementCallOptions): Promise; + listLayoutUsages(layoutId: string, options?: ManagementListOptions): Promise; + listLayouts(options?: ManagementListOptions): Promise; + listThemeUsages(themeId: string, options?: ManagementListOptions): Promise; + listThemes(options?: ManagementListOptions): Promise; + resolve(type: DesignResolveType, id: string, options?: ManagementCallOptions): Promise; + updateLayout( + layoutId: string, + payload: DesignLayoutRequest, + options?: ManagementCallOptions, + ): Promise; + updateTheme(themeId: string, payload: DesignThemeRequest, options?: ManagementCallOptions): Promise; + }; flows: { + create(payload: ManagedFlowRequest, options?: ManagementCallOptions): Promise; + delete(flowId: string, options?: ManagementCallOptions): Promise; + get(flowId: string, options?: ManagementCallOptions): Promise; + getVersion(flowId: string, version: number, options?: ManagementCallOptions): Promise; list(options?: ManagementListOptions & {flowType?: string}): Promise; + listUsages(flowId: string, options?: ManagementCallOptions): Promise; + listVersions(flowId: string, options?: ManagementCallOptions): Promise; + restoreVersion(flowId: string, version: number, options?: ManagementCallOptions): Promise; + update( + flowId: string, + payload: ManagedFlowRequest, + options?: ManagementCallOptions, + ): Promise; + }; + gateways: { + applyConfiguration( + gatewayId: string, + payload?: ApplyGatewayConfigurationRequest, + options?: ManagementCallOptions, + ): Promise; + create(payload: RegisterGatewayRequest, options?: ManagementCallOptions): Promise; + createSecret(gatewayId: string, payload: SecretRequest, options?: ManagementCallOptions): Promise; + createVariable(gatewayId: string, payload: VariableRequest, options?: ManagementCallOptions): Promise; + delete(gatewayId: string, options?: ManagementCallOptions): Promise; + deleteSecret(gatewayId: string, name: string, options?: ManagementCallOptions): Promise; + deleteVariable(gatewayId: string, name: string, options?: ManagementCallOptions): Promise; + get(gatewayId: string, options?: ManagementCallOptions): Promise; + getAppliedConfiguration(gatewayId: string, options?: ManagementCallOptions): Promise; + getAppliedVersion(gatewayId: string, options?: ManagementCallOptions): Promise; + getConfigurationDiff( + gatewayId: string, + options?: ManagementCallOptions & {version?: string}, + ): Promise; + getSecret(gatewayId: string, name: string, options?: ManagementCallOptions): Promise; + getVariable(gatewayId: string, name: string, options?: ManagementCallOptions): Promise; + list(options?: ManagementCallOptions): Promise; + listSecrets(gatewayId: string, options?: ManagementValueListOptions): Promise; + listVariables(gatewayId: string, options?: ManagementValueListOptions): Promise; + revertConfiguration( + gatewayId: string, + payload?: RevertGatewayConfigurationRequest, + options?: ManagementCallOptions, + ): Promise; + update(gatewayId: string, payload: UpdateGatewayRequest, options?: ManagementCallOptions): Promise; + updateSecret( + gatewayId: string, + name: string, + payload: SecretUpdateRequest, + options?: ManagementCallOptions, + ): Promise; + updateVariable( + gatewayId: string, + name: string, + payload: VariableUpdateRequest, + options?: ManagementCallOptions, + ): Promise; }; groups: { addMembers(groupId: string, members: ManagementGroupMemberInput[], options?: ManagementCallOptions): Promise; @@ -124,6 +434,147 @@ export interface ThunderIDNodeManagementClient { ): Promise; update(groupId: string, payload: UpdateGroupRequest, options?: ManagementCallOptions): Promise; }; + i18n: { + deleteTranslation(language: string, namespace: string, key: string, options?: ManagementCallOptions): Promise; + deleteTranslations(language: string, options?: ManagementCallOptions): Promise; + listLanguages(options?: ManagementCallOptions): Promise; + resolveTranslation( + language: string, + namespace: string, + key: string, + options?: ManagementCallOptions, + ): Promise; + resolveTranslations( + language: string, + options?: ManagementCallOptions & {namespace?: string}, + ): Promise; + setTranslation( + language: string, + namespace: string, + key: string, + payload: SetTranslationRequest, + options?: ManagementCallOptions, + ): Promise; + setTranslations( + language: string, + payload: SetTranslationsRequest, + options?: ManagementCallOptions, + ): Promise; + }; + notificationTemplates: { + create( + channel: NotificationChannel, + payload: CreateNotificationTemplateRequest, + options?: ManagementCallOptions, + ): Promise; + delete(channel: NotificationChannel, templateId: string, options?: ManagementCallOptions): Promise; + get( + channel: NotificationChannel, + templateId: string, + options?: ManagementCallOptions, + ): Promise; + list(channel: NotificationChannel, options?: ManagementListOptions): Promise; + update( + channel: NotificationChannel, + templateId: string, + payload: UpdateNotificationTemplateRequest, + options?: ManagementCallOptions, + ): Promise; + }; + /** + * Organization units. A unit is addressed by its identifier, or by its handle path (`engineering/platform`) + * through the `ByPath` operations and an {@link OrganizationUnitReference}. + */ + organizationUnits: { + create(payload: CreateOrganizationUnitRequest, options?: ManagementCallOptions): Promise; + delete(ouId: string, options?: ManagementCallOptions): Promise; + deleteByPath(path: string, options?: ManagementCallOptions): Promise; + get(ouId: string, options?: ManagementCallOptions): Promise; + getByPath(path: string, options?: ManagementCallOptions): Promise; + list(options?: ManagementCallOptions & ApiFilteringParams): Promise; + listChildren( + unit: OrganizationUnitReference, + options?: ManagementCallOptions & ApiFilteringParams, + ): Promise; + listGroups( + unit: OrganizationUnitReference, + options?: ManagementListOptions, + ): Promise; + listRoles( + unit: OrganizationUnitReference, + options?: ManagementListOptions, + ): Promise; + listUsers( + unit: OrganizationUnitReference, + options?: ManagementListOptions, + ): Promise; + update( + ouId: string, + payload: UpdateOrganizationUnitRequest, + options?: ManagementCallOptions, + ): Promise; + updateByPath( + path: string, + payload: UpdateOrganizationUnitByPathRequest, + options?: ManagementCallOptions, + ): Promise; + }; + /** + * Resource servers, their resources, and the actions on either. + */ + resourceServers: { + create(payload: CreateResourceServerRequest, options?: ManagementCallOptions): Promise; + createAction( + resourceServerId: string, + payload: CreateActionRequest, + options?: ManagementActionOptions, + ): Promise; + createResource( + resourceServerId: string, + payload: CreateResourceRequest, + options?: ManagementCallOptions, + ): Promise; + delete(resourceServerId: string, options?: ManagementCallOptions): Promise; + deleteAction( + resourceServerId: string, + actionId: string, + options?: ManagementActionOptions & AdministrationOptions, + ): Promise; + deleteResource(resourceServerId: string, resourceId: string, options?: ManagementCallOptions): Promise; + get(resourceServerId: string, options?: ManagementCallOptions): Promise; + getAction(resourceServerId: string, actionId: string, options?: ManagementActionOptions): Promise; + getResource( + resourceServerId: string, + resourceId: string, + options?: ManagementCallOptions, + ): Promise; + list(options?: ManagementListOptions): Promise; + listActions( + resourceServerId: string, + options?: ManagementListOptions & {kind?: ManagedActionKind; resourceId?: string}, + ): Promise; + listResources( + resourceServerId: string, + options?: ManagementListOptions & {parentId?: string}, + ): Promise; + update( + resourceServerId: string, + payload: UpdateResourceServerRequest, + options?: ManagementCallOptions, + ): Promise; + updateAction( + resourceServerId: string, + actionId: string, + payload: UpdateActionRequest, + options?: ManagementActionOptions, + ): Promise; + updateResource( + resourceServerId: string, + resourceId: string, + payload: UpdateResourceRequest, + options?: ManagementCallOptions, + ): Promise; + }; roles: { addAssignments(roleId: string, assignments: RoleAssignmentInput[], options?: ManagementCallOptions): Promise; create(payload: CreateRoleRequest, options?: ManagementCallOptions): Promise; @@ -141,15 +592,60 @@ export interface ThunderIDNodeManagementClient { ): Promise; update(roleId: string, payload: UpdateRoleRequest, options?: ManagementCallOptions): Promise; }; + /** + * Server-level secrets. A secret's value is write-only: it is never returned, and never logged. + */ + secrets: { + create(payload: SecretRequest, options?: ManagementCallOptions): Promise; + delete(name: string, options?: ManagementCallOptions): Promise; + get(name: string, options?: ManagementCallOptions): Promise; + list(options?: ManagementValueListOptions): Promise; + update(name: string, payload: SecretUpdateRequest, options?: ManagementCallOptions): Promise; + }; + serverConfig: { + get(name: string, options?: ManagementCallOptions): Promise>; + list(options?: ManagementCallOptions): Promise; + update(name: string, payload: T, options?: ManagementCallOptions): Promise>; + }; userTypes: { list(options?: ManagementListOptions): Promise; }; users: { create(payload: CreateManagedUserRequest, options?: ManagementCallOptions): Promise; + createByPath(path: string, payload: CreateUserByPathRequest, options?: ManagementCallOptions): Promise; + createSharingPolicy( + userId: string, + payload: SharingPolicyRequest, + options?: ManagementCallOptions, + ): Promise; delete(userId: string, options?: ManagementCallOptions): Promise; + deleteSharingPolicy(userId: string, policyId: string, options?: ManagementCallOptions): Promise; get(userId: string, options?: ManagementCallOptions): Promise; + getOverlayRules(userId: string, ouId: string, options?: ManagementCallOptions): Promise; + getSharingPolicy(userId: string, policyId: string, options?: ManagementCallOptions): Promise; list(options?: ManagementCallOptions & ApiFilteringParams): Promise; + listByPath(path: string, options?: ManagementListOptions): Promise; listGroups(userId: string, options?: ManagementListOptions): Promise; + listSharingPolicies(userId: string, options?: ManagementListOptions): Promise; + suspend(userId: string, options?: ManagementLifecycleOptions): Promise; + unlock(userId: string, options?: ManagementLifecycleOptions): Promise; + unsuspend(userId: string, options?: ManagementLifecycleOptions): Promise; update(userId: string, payload: UpdateManagedUserRequest, options?: ManagementCallOptions): Promise; + updateSharingPolicy( + userId: string, + policyId: string, + payload: SharingPolicyUpdateRequest, + options?: ManagementCallOptions, + ): Promise; + }; + /** + * Server-level variables. + */ + variables: { + create(payload: VariableRequest, options?: ManagementCallOptions): Promise; + delete(name: string, options?: ManagementCallOptions): Promise; + get(name: string, options?: ManagementCallOptions): Promise; + list(options?: ManagementValueListOptions): Promise; + update(name: string, payload: VariableUpdateRequest, options?: ManagementCallOptions): Promise; }; } diff --git a/packages/node/src/utils/__tests__/managementCollections.test.ts b/packages/node/src/utils/__tests__/managementCollections.test.ts index 81090a24..ca52e5dd 100644 --- a/packages/node/src/utils/__tests__/managementCollections.test.ts +++ b/packages/node/src/utils/__tests__/managementCollections.test.ts @@ -17,12 +17,39 @@ describe('getManagementCollectionUrls', () => { 'https://id.example.com/groups', 'https://id.example.com/roles', 'https://id.example.com/agents', + 'https://id.example.com/applications', + 'https://id.example.com/o/applications', 'https://id.example.com/user-types', 'https://id.example.com/agent-types', + 'https://id.example.com/flows', + 'https://id.example.com/organization-units', + 'https://id.example.com/o/organization-units', + 'https://id.example.com/resource-servers', + 'https://id.example.com/connections', + 'https://id.example.com/notification-templates', + 'https://id.example.com/design', + 'https://id.example.com/i18n', + 'https://id.example.com/server-config', + 'https://id.example.com/gateways', + 'https://id.example.com/variables', + 'https://id.example.com/secrets', + 'https://id.example.com/configuration-versions', + 'https://id.example.com/import', + 'https://id.example.com/export', ]), ); }); + it('uses an endpoints override for a new collection too', () => { + const urls: string[] = getManagementCollectionUrls({ + ...baseConfig, + endpoints: {secrets: 'https://vault.example.com/secrets'}, + }).map((url: URL) => url.toString()); + + expect(urls).toContain('https://vault.example.com/secrets'); + expect(urls).not.toContain('https://id.example.com/secrets'); + }); + it('uses an endpoints override instead of baseUrl for that collection', () => { const urls: string[] = getManagementCollectionUrls({ ...baseConfig, @@ -48,6 +75,24 @@ describe('matchManagementUrl', () => { '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', + 'https://id.example.com/applications', + 'https://id.example.com/applications?limit=10&offset=0', + 'https://id.example.com/applications/app-1', + 'https://id.example.com/applications/app-1/sharing-policies/p-1', + 'https://id.example.com/organization-units/tree/engineering/platform/users?include=display', + 'https://id.example.com/resource-servers/rs-1/resources/r-1/actions/a-1', + 'https://id.example.com/connections/google/c-1/usages', + 'https://id.example.com/flows/f-1/restore', + 'https://id.example.com/notification-templates/email/templates/t-1', + 'https://id.example.com/design/themes/th-1', + 'https://id.example.com/i18n/languages/en-US/translations/resolve', + 'https://id.example.com/server-config/flow', + 'https://id.example.com/gateways/gw-1/secrets/S', + 'https://id.example.com/variables/A', + 'https://id.example.com/secrets', + 'https://id.example.com/configuration-versions/abc1234', + 'https://id.example.com/import/delete', + 'https://id.example.com/export', ])('allows %s', (target: string) => { expect(matchManagementUrl(target, collections)).toBeInstanceOf(URL); }); @@ -58,6 +103,12 @@ describe('matchManagementUrl', () => { ['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 prefix of a new collection', 'https://id.example.com/exports'], + ['an application collection name prefix', 'https://id.example.com/applicationsx'], + ['the client metadata preview, which is not a management collection', 'https://id.example.com/cimd/preview'], + ['flow execution, which is not a management collection', 'https://id.example.com/flow/execute'], + ['a path outside the collections on the same host', 'https://id.example.com/scim2/Users'], + ['an encoded slash in a tree path', 'https://id.example.com/organization-units/tree/a%2Fb'], ['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'], diff --git a/packages/node/src/utils/createManagementClient.ts b/packages/node/src/utils/createManagementClient.ts index 34b72352..418d157b 100644 --- a/packages/node/src/utils/createManagementClient.ts +++ b/packages/node/src/utils/createManagementClient.ts @@ -4,38 +4,178 @@ import { addGroupMembers, addRoleAssignments, + applyGatewayConfiguration, + createAction, createAgent, + createAgentType, + createApplication, + createApplicationSharingPolicy, + createConfigurationVersion, + createConnection, + createDesignTheme, + createFlow, + createGateway, + createGatewaySecret, + createGatewayVariable, createGroup, + createLayout, + createNotificationTemplate, + createOrganizationUnit, + createResource, + createResourceServer, createRole, + createSecret, createUser, + createUserByPath, + createUserSharingPolicy, + createVariable, + deleteAction, deleteAgent, + deleteAgentType, + deleteApplication, + deleteApplicationSharingPolicy, + deleteConnection, + deleteDesignTheme, + deleteFlow, + deleteGateway, + deleteGatewaySecret, + deleteGatewayVariable, deleteGroup, + deleteImportedConfiguration, + deleteLayout, + deleteNotificationTemplate, + deleteOrganizationUnit, + deleteOrganizationUnitByPath, + deleteResource, + deleteResourceServer, deleteRole, + deleteSecret, + deleteTranslation, + deleteTranslations, deleteUser, + deleteUserSharingPolicy, + deleteVariable, + exportConfiguration, + getAction, + getActions, getAgent, getAgentGroups, getAgentRoles, getAgents, + getAgentType, getAgentTypes, + getApplication, + getApplicationOverlayRules, + getApplications, + getApplicationSharingPolicies, + getApplicationSharingPolicy, + getConfigurationVersion, + getConfigurationVersions, + getConnection, + getConnections, + getConnectionsMeta, + getConnectionUsages, + getDesignTheme, + getDesignThemes, + getDesignThemeUsages, + getFlow, getFlows, + getFlowUsages, + getFlowVersion, + getFlowVersions, + getGateway, + getGatewayAppliedConfiguration, + getGatewayAppliedVersion, + getGatewayConfigurationDiff, + getGateways, + getGatewaySecret, + getGatewaySecrets, + getGatewayVariable, + getGatewayVariables, getGroup, getGroupMembers, getGroups, + getLanguages, + getLayout, + getLayouts, + getLayoutUsages, + getNotificationTemplate, + getNotificationTemplates, + getOrganizationUnit, + getOrganizationUnitByPath, + getOrganizationUnitChildren, + getOrganizationUnitGroups, + getOrganizationUnitRoles, + getOrganizationUnits, + getOrganizationUnitUsers, + getResource, + getResources, + getResourceServer, + getResourceServers, getRole, getRoleAssignments, getRoles, + getSecret, + getSecrets, + getServerConfig, + getServerConfigs, getUser, getUserGroups, + getUserOverlayRules, getUsers, + getUsersByPath, + getUserSharingPolicies, + getUserSharingPolicy, getUserTypes, + getVariable, + getVariables, + importConfiguration, ManagementRequestConfig, + previewCimdDocument, + regenerateClientSecret, + regenerateFlowSecret, removeGroupMembers, removeRoleAssignments, + resolveDesign, + resolveTranslation, + resolveTranslations, + restoreFlowVersion, + revertGatewayConfiguration, + setTranslation, + setTranslations, + suspendAgent, + suspendApplication, + suspendUser, ThunderIDAPIError, + unlockAgent, + unlockUser, + unsuspendAgent, + unsuspendApplication, + unsuspendUser, + updateAction, updateAgent, + updateAgentType, + updateApplication, + updateApplicationSharingPolicy, + updateConnection, + updateDesignTheme, + updateFlow, + updateGateway, + updateGatewaySecret, + updateGatewayVariable, updateGroup, + updateLayout, + updateNotificationTemplate, + updateOrganizationUnit, + updateOrganizationUnitByPath, + updateResource, + updateResourceServer, updateRole, + updateSecret, + updateServerConfig, updateUser, + updateUserSharingPolicy, + updateVariable, } from '@thunderid/javascript'; import { ManagementCallOptions, @@ -49,7 +189,7 @@ import { * 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 => { +const requireId = (id: T, operation: string): T => { if (typeof id !== 'string' || id.trim() === '') { throw new ThunderIDAPIError( 'A resource identifier is required.', @@ -86,8 +226,22 @@ const createManagementClient = (resolve: ManagementRequestConfigResolver): Thund return { agentTypes: { + create: async (payload, options) => createAgentType({payload, ...(await config('agentTypes', options))}), + delete: async (agentTypeId, options) => + deleteAgentType({ + agentTypeId: requireId(agentTypeId, 'deleteAgentType'), + ...(await config('agentTypes', options)), + }), + get: async (agentTypeId, options) => + getAgentType({agentTypeId: requireId(agentTypeId, 'getAgentType'), ...(await config('agentTypes', options))}), list: async ({limit, offset, ...options} = {}) => getAgentTypes({limit, offset, ...(await config('agentTypes', options))}), + update: async (agentTypeId, payload, options) => + updateAgentType({ + agentTypeId: requireId(agentTypeId, 'updateAgentType'), + payload, + ...(await config('agentTypes', options)), + }), }, agents: { create: async (payload, options) => createAgent({payload, ...(await config('agents', options))}), @@ -111,12 +265,332 @@ const createManagementClient = (resolve: ManagementRequestConfigResolver): Thund offset, ...(await config('agents', options)), }), + suspend: async (agentId, {operatorNote, ...options} = {}) => + suspendAgent({agentId: requireId(agentId, 'suspendAgent'), operatorNote, ...(await config('agents', options))}), + unlock: async (agentId, {operatorNote, ...options} = {}) => + unlockAgent({agentId: requireId(agentId, 'unlockAgent'), operatorNote, ...(await config('agents', options))}), + unsuspend: async (agentId, {operatorNote, ...options} = {}) => + unsuspendAgent({ + agentId: requireId(agentId, 'unsuspendAgent'), + operatorNote, + ...(await config('agents', options)), + }), update: async (agentId, payload, options) => updateAgent({agentId: requireId(agentId, 'updateAgent'), payload, ...(await config('agents', options))}), }, + applications: { + create: async (payload, options) => createApplication({payload, ...(await config('applications', options))}), + createSharingPolicy: async (applicationId, payload, options) => + createApplicationSharingPolicy({ + applicationId: requireId(applicationId, 'createApplicationSharingPolicy'), + payload, + ...(await config('applications', options)), + }), + delete: async (applicationId, {administrationMode, ...options} = {}) => + deleteApplication({ + administrationMode, + applicationId: requireId(applicationId, 'deleteApplication'), + ...(await config('applications', options)), + }), + deleteSharingPolicy: async (applicationId, policyId, options) => + deleteApplicationSharingPolicy({ + applicationId: requireId(applicationId, 'deleteApplicationSharingPolicy'), + policyId: requireId(policyId, 'deleteApplicationSharingPolicy'), + ...(await config('applications', options)), + }), + get: async (applicationId, options) => + getApplication({ + applicationId: requireId(applicationId, 'getApplication'), + ...(await config('applications', options)), + }), + getOverlayRules: async (applicationId, ouId, options) => + getApplicationOverlayRules({ + applicationId: requireId(applicationId, 'getApplicationOverlayRules'), + ouId: requireId(ouId, 'getApplicationOverlayRules'), + ...(await config('applications', options)), + }), + getSharingPolicy: async (applicationId, policyId, options) => + getApplicationSharingPolicy({ + applicationId: requireId(applicationId, 'getApplicationSharingPolicy'), + policyId: requireId(policyId, 'getApplicationSharingPolicy'), + ...(await config('applications', options)), + }), + list: async ({limit, offset, ...options} = {}) => + getApplications({limit, offset, ...(await config('applications', options))}), + listSharingPolicies: async (applicationId, {limit, offset, ...options} = {}) => + getApplicationSharingPolicies({ + applicationId: requireId(applicationId, 'getApplicationSharingPolicies'), + limit, + offset, + ...(await config('applications', options)), + }), + previewCimdDocument: async (clientId, options) => + previewCimdDocument({ + clientId: requireId(clientId, 'previewCimdDocument'), + ...(await config('applications', options)), + }), + regenerateClientSecret: async (applicationId, {administrationMode, ...options} = {}) => + regenerateClientSecret({ + administrationMode, + applicationId: requireId(applicationId, 'regenerateClientSecret'), + ...(await config('applications', options)), + }), + regenerateFlowSecret: async (applicationId, options) => + regenerateFlowSecret({ + applicationId: requireId(applicationId, 'regenerateFlowSecret'), + ...(await config('applications', options)), + }), + suspend: async (applicationId, {operatorNote, ...options} = {}) => + suspendApplication({ + applicationId: requireId(applicationId, 'suspendApplication'), + operatorNote, + ...(await config('applications', options)), + }), + unsuspend: async (applicationId, {operatorNote, ...options} = {}) => + unsuspendApplication({ + applicationId: requireId(applicationId, 'unsuspendApplication'), + operatorNote, + ...(await config('applications', options)), + }), + update: async (applicationId, payload, options) => + updateApplication({ + applicationId: requireId(applicationId, 'updateApplication'), + payload, + ...(await config('applications', options)), + }), + updateSharingPolicy: async (applicationId, policyId, payload, options) => + updateApplicationSharingPolicy({ + applicationId: requireId(applicationId, 'updateApplicationSharingPolicy'), + payload, + policyId: requireId(policyId, 'updateApplicationSharingPolicy'), + ...(await config('applications', options)), + }), + }, + configuration: { + captureVersion: async (payload, options) => + createConfigurationVersion({payload, ...(await config('configurationVersions', options))}), + deleteImported: async (payload, options) => + deleteImportedConfiguration({payload, ...(await config('import', options))}), + export: async (payload, options) => exportConfiguration({payload, ...(await config('export', options))}), + getVersion: async (version, options) => + getConfigurationVersion({ + version: requireId(version, 'getConfigurationVersion'), + ...(await config('configurationVersions', options)), + }), + import: async (payload, options) => importConfiguration({payload, ...(await config('import', options))}), + listVersions: async (options) => getConfigurationVersions(await config('configurationVersions', options)), + }, + connections: { + create: async (type, payload, options) => + createConnection({ + payload, + type: requireId(type, 'createConnection'), + ...(await config('connections', options)), + }), + delete: async (type, connectionId, options) => + deleteConnection({ + connectionId: requireId(connectionId, 'deleteConnection'), + type: requireId(type, 'deleteConnection'), + ...(await config('connections', options)), + }), + get: async (type, connectionId, options) => + getConnection({ + connectionId: requireId(connectionId, 'getConnection'), + type: requireId(type, 'getConnection'), + ...(await config('connections', options)), + }), + getMeta: async ({vendor, ...options} = {}) => + getConnectionsMeta({vendor, ...(await config('connections', options))}), + list: async ({category, limit, offset, ...options} = {}) => + getConnections({category, limit, offset, ...(await config('connections', options))}), + listByType: async (type, options) => + getConnections({type: requireId(type, 'getConnections'), ...(await config('connections', options))}), + listUsages: async (type, connectionId, options) => + getConnectionUsages({ + connectionId: requireId(connectionId, 'getConnectionUsages'), + type: requireId(type, 'getConnectionUsages'), + ...(await config('connections', options)), + }), + update: async (type, connectionId, payload, options) => + updateConnection({ + connectionId: requireId(connectionId, 'updateConnection'), + payload, + type: requireId(type, 'updateConnection'), + ...(await config('connections', options)), + }), + }, + design: { + createLayout: async (payload, options) => createLayout({payload, ...(await config('design', options))}), + createTheme: async (payload, options) => createDesignTheme({payload, ...(await config('design', options))}), + deleteLayout: async (layoutId, options) => + deleteLayout({layoutId: requireId(layoutId, 'deleteLayout'), ...(await config('design', options))}), + deleteTheme: async (themeId, options) => + deleteDesignTheme({themeId: requireId(themeId, 'deleteDesignTheme'), ...(await config('design', options))}), + getLayout: async (layoutId, options) => + getLayout({layoutId: requireId(layoutId, 'getLayout'), ...(await config('design', options))}), + getTheme: async (themeId, options) => + getDesignTheme({themeId: requireId(themeId, 'getDesignTheme'), ...(await config('design', options))}), + listLayoutUsages: async (layoutId, {limit, offset, ...options} = {}) => + getLayoutUsages({ + layoutId: requireId(layoutId, 'getLayoutUsages'), + limit, + offset, + ...(await config('design', options)), + }), + listLayouts: async ({limit, offset, ...options} = {}) => + getLayouts({limit, offset, ...(await config('design', options))}), + listThemeUsages: async (themeId, {limit, offset, ...options} = {}) => + getDesignThemeUsages({ + limit, + offset, + themeId: requireId(themeId, 'getDesignThemeUsages'), + ...(await config('design', options)), + }), + listThemes: async ({limit, offset, ...options} = {}) => + getDesignThemes({limit, offset, ...(await config('design', options))}), + resolve: async (type, id, options) => + resolveDesign({id: requireId(id, 'resolveDesign'), type, ...(await config('design', options))}), + updateLayout: async (layoutId, payload, options) => + updateLayout({layoutId: requireId(layoutId, 'updateLayout'), payload, ...(await config('design', options))}), + updateTheme: async (themeId, payload, options) => + updateDesignTheme({ + payload, + themeId: requireId(themeId, 'updateDesignTheme'), + ...(await config('design', options)), + }), + }, flows: { + create: async (payload, options) => createFlow({payload, ...(await config('flows', options))}), + delete: async (flowId, options) => + deleteFlow({flowId: requireId(flowId, 'deleteFlow'), ...(await config('flows', options))}), + get: async (flowId, options) => + getFlow({flowId: requireId(flowId, 'getFlow'), ...(await config('flows', options))}), + getVersion: async (flowId, version, options) => + getFlowVersion({flowId: requireId(flowId, 'getFlowVersion'), version, ...(await config('flows', options))}), list: async ({flowType, limit, offset, ...options} = {}) => getFlows({flowType, limit, offset, ...(await config('flows', options))}), + listUsages: async (flowId, options) => + getFlowUsages({flowId: requireId(flowId, 'getFlowUsages'), ...(await config('flows', options))}), + listVersions: async (flowId, options) => + getFlowVersions({flowId: requireId(flowId, 'getFlowVersions'), ...(await config('flows', options))}), + restoreVersion: async (flowId, version, options) => + restoreFlowVersion({ + flowId: requireId(flowId, 'restoreFlowVersion'), + version, + ...(await config('flows', options)), + }), + update: async (flowId, payload, options) => + updateFlow({flowId: requireId(flowId, 'updateFlow'), payload, ...(await config('flows', options))}), + }, + gateways: { + applyConfiguration: async (gatewayId, payload, options) => + applyGatewayConfiguration({ + gatewayId: requireId(gatewayId, 'applyGatewayConfiguration'), + payload, + ...(await config('gateways', options)), + }), + create: async (payload, options) => createGateway({payload, ...(await config('gateways', options))}), + createSecret: async (gatewayId, payload, options) => + createGatewaySecret({ + gatewayId: requireId(gatewayId, 'createGatewaySecret'), + payload, + ...(await config('gateways', options)), + }), + createVariable: async (gatewayId, payload, options) => + createGatewayVariable({ + gatewayId: requireId(gatewayId, 'createGatewayVariable'), + payload, + ...(await config('gateways', options)), + }), + delete: async (gatewayId, options) => + deleteGateway({gatewayId: requireId(gatewayId, 'deleteGateway'), ...(await config('gateways', options))}), + deleteSecret: async (gatewayId, name, options) => + deleteGatewaySecret({ + gatewayId: requireId(gatewayId, 'deleteGatewaySecret'), + name: requireId(name, 'deleteGatewaySecret'), + ...(await config('gateways', options)), + }), + deleteVariable: async (gatewayId, name, options) => + deleteGatewayVariable({ + gatewayId: requireId(gatewayId, 'deleteGatewayVariable'), + name: requireId(name, 'deleteGatewayVariable'), + ...(await config('gateways', options)), + }), + get: async (gatewayId, options) => + getGateway({gatewayId: requireId(gatewayId, 'getGateway'), ...(await config('gateways', options))}), + getAppliedConfiguration: async (gatewayId, options) => + getGatewayAppliedConfiguration({ + gatewayId: requireId(gatewayId, 'getGatewayAppliedConfiguration'), + ...(await config('gateways', options)), + }), + getAppliedVersion: async (gatewayId, options) => + getGatewayAppliedVersion({ + gatewayId: requireId(gatewayId, 'getGatewayAppliedVersion'), + ...(await config('gateways', options)), + }), + getConfigurationDiff: async (gatewayId, {version, ...options} = {}) => + getGatewayConfigurationDiff({ + gatewayId: requireId(gatewayId, 'getGatewayConfigurationDiff'), + version, + ...(await config('gateways', options)), + }), + getSecret: async (gatewayId, name, options) => + getGatewaySecret({ + gatewayId: requireId(gatewayId, 'getGatewaySecret'), + name: requireId(name, 'getGatewaySecret'), + ...(await config('gateways', options)), + }), + getVariable: async (gatewayId, name, options) => + getGatewayVariable({ + gatewayId: requireId(gatewayId, 'getGatewayVariable'), + name: requireId(name, 'getGatewayVariable'), + ...(await config('gateways', options)), + }), + list: async (options) => getGateways(await config('gateways', options)), + listSecrets: async (gatewayId, {filter, limit, names, offset, ...options} = {}) => + getGatewaySecrets({ + filter, + gatewayId: requireId(gatewayId, 'getGatewaySecrets'), + limit, + names, + offset, + ...(await config('gateways', options)), + }), + listVariables: async (gatewayId, {filter, limit, names, offset, ...options} = {}) => + getGatewayVariables({ + filter, + gatewayId: requireId(gatewayId, 'getGatewayVariables'), + limit, + names, + offset, + ...(await config('gateways', options)), + }), + revertConfiguration: async (gatewayId, payload, options) => + revertGatewayConfiguration({ + gatewayId: requireId(gatewayId, 'revertGatewayConfiguration'), + payload, + ...(await config('gateways', options)), + }), + update: async (gatewayId, payload, options) => + updateGateway({ + gatewayId: requireId(gatewayId, 'updateGateway'), + payload, + ...(await config('gateways', options)), + }), + updateSecret: async (gatewayId, name, payload, options) => + updateGatewaySecret({ + gatewayId: requireId(gatewayId, 'updateGatewaySecret'), + name: requireId(name, 'updateGatewaySecret'), + payload, + ...(await config('gateways', options)), + }), + updateVariable: async (gatewayId, name, payload, options) => + updateGatewayVariable({ + gatewayId: requireId(gatewayId, 'updateGatewayVariable'), + name: requireId(name, 'updateGatewayVariable'), + payload, + ...(await config('gateways', options)), + }), }, groups: { addMembers: async (groupId, members, options) => @@ -148,6 +622,192 @@ const createManagementClient = (resolve: ManagementRequestConfigResolver): Thund update: async (groupId, payload, options) => updateGroup({groupId: requireId(groupId, 'updateGroup'), payload, ...(await config('groups', options))}), }, + i18n: { + deleteTranslation: async (language, namespace, key, options) => + deleteTranslation({key, language, namespace, ...(await config('i18n', options))}), + deleteTranslations: async (language, options) => + deleteTranslations({language: requireId(language, 'deleteTranslations'), ...(await config('i18n', options))}), + listLanguages: async (options) => getLanguages(await config('i18n', options)), + resolveTranslation: async (language, namespace, key, options) => + resolveTranslation({key, language, namespace, ...(await config('i18n', options))}), + resolveTranslations: async (language, {namespace, ...options} = {}) => + resolveTranslations({language, namespace, ...(await config('i18n', options))}), + setTranslation: async (language, namespace, key, payload, options) => + setTranslation({key, language, namespace, payload, ...(await config('i18n', options))}), + setTranslations: async (language, payload, options) => + setTranslations({language, payload, ...(await config('i18n', options))}), + }, + notificationTemplates: { + create: async (channel, payload, options) => + createNotificationTemplate({ + channel: requireId(channel, 'createNotificationTemplate'), + payload, + ...(await config('notificationTemplates', options)), + }), + delete: async (channel, templateId, options) => + deleteNotificationTemplate({ + channel: requireId(channel, 'deleteNotificationTemplate'), + templateId: requireId(templateId, 'deleteNotificationTemplate'), + ...(await config('notificationTemplates', options)), + }), + get: async (channel, templateId, options) => + getNotificationTemplate({ + channel: requireId(channel, 'getNotificationTemplate'), + templateId: requireId(templateId, 'getNotificationTemplate'), + ...(await config('notificationTemplates', options)), + }), + list: async (channel, {limit, offset, ...options} = {}) => + getNotificationTemplates({ + channel: requireId(channel, 'getNotificationTemplates'), + limit, + offset, + ...(await config('notificationTemplates', options)), + }), + update: async (channel, templateId, payload, options) => + updateNotificationTemplate({ + channel: requireId(channel, 'updateNotificationTemplate'), + payload, + templateId: requireId(templateId, 'updateNotificationTemplate'), + ...(await config('notificationTemplates', options)), + }), + }, + organizationUnits: { + create: async (payload, options) => + createOrganizationUnit({payload, ...(await config('organizationUnits', options))}), + delete: async (ouId, options) => + deleteOrganizationUnit({ + ouId: requireId(ouId, 'deleteOrganizationUnit'), + ...(await config('organizationUnits', options)), + }), + deleteByPath: async (path, options) => + deleteOrganizationUnitByPath({path, ...(await config('organizationUnits', options))}), + get: async (ouId, options) => + getOrganizationUnit({ + ouId: requireId(ouId, 'getOrganizationUnit'), + ...(await config('organizationUnits', options)), + }), + getByPath: async (path, options) => + getOrganizationUnitByPath({path, ...(await config('organizationUnits', options))}), + list: async ({filter, limit, offset, ...options} = {}) => + getOrganizationUnits({filter, limit, offset, ...(await config('organizationUnits', options))}), + listChildren: async (unit, {filter, limit, offset, ...options} = {}) => + getOrganizationUnitChildren({ + filter, + limit, + offset, + ouId: unit?.ouId, + path: unit?.path, + ...(await config('organizationUnits', options)), + }), + listGroups: async (unit, {limit, offset, ...options} = {}) => + getOrganizationUnitGroups({...unit, limit, offset, ...(await config('organizationUnits', options))}), + listRoles: async (unit, {limit, offset, ...options} = {}) => + getOrganizationUnitRoles({...unit, limit, offset, ...(await config('organizationUnits', options))}), + listUsers: async (unit, {limit, offset, ...options} = {}) => + getOrganizationUnitUsers({...unit, limit, offset, ...(await config('organizationUnits', options))}), + update: async (ouId, payload, options) => + updateOrganizationUnit({ + ouId: requireId(ouId, 'updateOrganizationUnit'), + payload, + ...(await config('organizationUnits', options)), + }), + updateByPath: async (path, payload, options) => + updateOrganizationUnitByPath({path, payload, ...(await config('organizationUnits', options))}), + }, + resourceServers: { + create: async (payload, options) => + createResourceServer({payload, ...(await config('resourceServers', options))}), + createAction: async (resourceServerId, payload, {resourceId, ...options} = {}) => + createAction({ + payload, + resourceId, + resourceServerId: requireId(resourceServerId, 'createAction'), + ...(await config('resourceServers', options)), + }), + createResource: async (resourceServerId, payload, options) => + createResource({ + payload, + resourceServerId: requireId(resourceServerId, 'createResource'), + ...(await config('resourceServers', options)), + }), + delete: async (resourceServerId, options) => + deleteResourceServer({ + resourceServerId: requireId(resourceServerId, 'deleteResourceServer'), + ...(await config('resourceServers', options)), + }), + deleteAction: async (resourceServerId, actionId, {administrationMode, resourceId, ...options} = {}) => + deleteAction({ + actionId: requireId(actionId, 'deleteAction'), + administrationMode, + resourceId, + resourceServerId: requireId(resourceServerId, 'deleteAction'), + ...(await config('resourceServers', options)), + }), + deleteResource: async (resourceServerId, resourceId, options) => + deleteResource({ + resourceId: requireId(resourceId, 'deleteResource'), + resourceServerId: requireId(resourceServerId, 'deleteResource'), + ...(await config('resourceServers', options)), + }), + get: async (resourceServerId, options) => + getResourceServer({ + resourceServerId: requireId(resourceServerId, 'getResourceServer'), + ...(await config('resourceServers', options)), + }), + getAction: async (resourceServerId, actionId, {resourceId, ...options} = {}) => + getAction({ + actionId: requireId(actionId, 'getAction'), + resourceId, + resourceServerId: requireId(resourceServerId, 'getAction'), + ...(await config('resourceServers', options)), + }), + getResource: async (resourceServerId, resourceId, options) => + getResource({ + resourceId: requireId(resourceId, 'getResource'), + resourceServerId: requireId(resourceServerId, 'getResource'), + ...(await config('resourceServers', options)), + }), + list: async ({limit, offset, ...options} = {}) => + getResourceServers({limit, offset, ...(await config('resourceServers', options))}), + listActions: async (resourceServerId, {kind, limit, offset, resourceId, ...options} = {}) => + getActions({ + kind, + limit, + offset, + resourceId, + resourceServerId: requireId(resourceServerId, 'getActions'), + ...(await config('resourceServers', options)), + }), + listResources: async (resourceServerId, {limit, offset, parentId, ...options} = {}) => + getResources({ + limit, + offset, + parentId, + resourceServerId: requireId(resourceServerId, 'getResources'), + ...(await config('resourceServers', options)), + }), + update: async (resourceServerId, payload, options) => + updateResourceServer({ + payload, + resourceServerId: requireId(resourceServerId, 'updateResourceServer'), + ...(await config('resourceServers', options)), + }), + updateAction: async (resourceServerId, actionId, payload, {resourceId, ...options} = {}) => + updateAction({ + actionId: requireId(actionId, 'updateAction'), + payload, + resourceId, + resourceServerId: requireId(resourceServerId, 'updateAction'), + ...(await config('resourceServers', options)), + }), + updateResource: async (resourceServerId, resourceId, payload, options) => + updateResource({ + payload, + resourceId: requireId(resourceId, 'updateResource'), + resourceServerId: requireId(resourceServerId, 'updateResource'), + ...(await config('resourceServers', options)), + }), + }, roles: { addAssignments: async (roleId, assignments, options) => addRoleAssignments({ @@ -178,18 +838,68 @@ const createManagementClient = (resolve: ManagementRequestConfigResolver): Thund update: async (roleId, payload, options) => updateRole({payload, roleId: requireId(roleId, 'updateRole'), ...(await config('roles', options))}), }, + secrets: { + create: async (payload, options) => createSecret({payload, ...(await config('secrets', options))}), + delete: async (name, options) => + deleteSecret({name: requireId(name, 'deleteSecret'), ...(await config('secrets', options))}), + get: async (name, options) => + getSecret({name: requireId(name, 'getSecret'), ...(await config('secrets', options))}), + list: async ({filter, limit, names, offset, ...options} = {}) => + getSecrets({filter, limit, names, offset, ...(await config('secrets', options))}), + update: async (name, payload, options) => + updateSecret({name: requireId(name, 'updateSecret'), payload, ...(await config('secrets', options))}), + }, + serverConfig: { + get: async (name: string, options?: ManagementCallOptions) => + getServerConfig({name: requireId(name, 'getServerConfig'), ...(await config('serverConfig', options))}), + list: async (options) => getServerConfigs(await config('serverConfig', options)), + update: async (name: string, payload: T, options?: ManagementCallOptions) => + updateServerConfig({ + name: requireId(name, 'updateServerConfig'), + payload, + ...(await config('serverConfig', 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))}), + createByPath: async (path, payload, options) => + createUserByPath({path, payload, ...(await config('users', options))}), + createSharingPolicy: async (userId, payload, options) => + createUserSharingPolicy({ + payload, + userId: requireId(userId, 'createUserSharingPolicy'), + ...(await config('users', options)), + }), delete: async (userId, options) => deleteUser({userId: requireId(userId, 'deleteUser'), ...(await config('users', options))}), + deleteSharingPolicy: async (userId, policyId, options) => + deleteUserSharingPolicy({ + policyId: requireId(policyId, 'deleteUserSharingPolicy'), + userId: requireId(userId, 'deleteUserSharingPolicy'), + ...(await config('users', options)), + }), get: async (userId, options) => getUser({userId: requireId(userId, 'getUser'), ...(await config('users', options))}), + getOverlayRules: async (userId, ouId, options) => + getUserOverlayRules({ + ouId: requireId(ouId, 'getUserOverlayRules'), + userId: requireId(userId, 'getUserOverlayRules'), + ...(await config('users', options)), + }), + getSharingPolicy: async (userId, policyId, options) => + getUserSharingPolicy({ + policyId: requireId(policyId, 'getUserSharingPolicy'), + userId: requireId(userId, 'getUserSharingPolicy'), + ...(await config('users', options)), + }), list: async ({filter, limit, offset, ...options} = {}) => getUsers({filter, limit, offset, ...(await config('users', options))}), + listByPath: async (path, {limit, offset, ...options} = {}) => + getUsersByPath({limit, offset, path, ...(await config('users', options))}), listGroups: async (userId, {limit, offset, ...options} = {}) => getUserGroups({ limit, @@ -197,8 +907,39 @@ const createManagementClient = (resolve: ManagementRequestConfigResolver): Thund userId: requireId(userId, 'getUserGroups'), ...(await config('users', options)), }), + listSharingPolicies: async (userId, {limit, offset, ...options} = {}) => + getUserSharingPolicies({ + limit, + offset, + userId: requireId(userId, 'getUserSharingPolicies'), + ...(await config('users', options)), + }), + suspend: async (userId, {operatorNote, ...options} = {}) => + suspendUser({operatorNote, userId: requireId(userId, 'suspendUser'), ...(await config('users', options))}), + unlock: async (userId, {operatorNote, ...options} = {}) => + unlockUser({operatorNote, userId: requireId(userId, 'unlockUser'), ...(await config('users', options))}), + unsuspend: async (userId, {operatorNote, ...options} = {}) => + unsuspendUser({operatorNote, userId: requireId(userId, 'unsuspendUser'), ...(await config('users', options))}), update: async (userId, payload, options) => updateUser({payload, userId: requireId(userId, 'updateUser'), ...(await config('users', options))}), + updateSharingPolicy: async (userId, policyId, payload, options) => + updateUserSharingPolicy({ + payload, + policyId: requireId(policyId, 'updateUserSharingPolicy'), + userId: requireId(userId, 'updateUserSharingPolicy'), + ...(await config('users', options)), + }), + }, + variables: { + create: async (payload, options) => createVariable({payload, ...(await config('variables', options))}), + delete: async (name, options) => + deleteVariable({name: requireId(name, 'deleteVariable'), ...(await config('variables', options))}), + get: async (name, options) => + getVariable({name: requireId(name, 'getVariable'), ...(await config('variables', options))}), + list: async ({filter, limit, names, offset, ...options} = {}) => + getVariables({filter, limit, names, offset, ...(await config('variables', options))}), + update: async (name, payload, options) => + updateVariable({name: requireId(name, 'updateVariable'), payload, ...(await config('variables', options))}), }, }; }; diff --git a/packages/node/src/utils/managementCollections.ts b/packages/node/src/utils/managementCollections.ts index d1443a64..aa5cc97e 100644 --- a/packages/node/src/utils/managementCollections.ts +++ b/packages/node/src/utils/managementCollections.ts @@ -12,14 +12,55 @@ import type {ResourceEndpointConfig, ResourceEndpointKey} from '@thunderid/javas * their `endpoints` override key, with the path each one has under `baseUrl`. */ export const MANAGEMENT_COLLECTIONS: Readonly< - Record, string> + Record< + Extract< + ResourceEndpointKey, + | 'agents' + | 'agentTypes' + | 'applications' + | 'configurationVersions' + | 'connections' + | 'design' + | 'export' + | 'flows' + | 'gateways' + | 'groups' + | 'i18n' + | 'import' + | 'notificationTemplates' + | 'organizationUnits' + | 'resourceServers' + | 'roles' + | 'secrets' + | 'serverConfig' + | 'users' + | 'userTypes' + | 'variables' + >, + string + > > = { agentTypes: 'agent-types', agents: 'agents', + applications: 'applications', + configurationVersions: 'configuration-versions', + connections: 'connections', + design: 'design', + export: 'export', + flows: 'flows', + gateways: 'gateways', groups: 'groups', + i18n: 'i18n', + import: 'import', + notificationTemplates: 'notification-templates', + organizationUnits: 'organization-units', + resourceServers: 'resource-servers', roles: 'roles', + secrets: 'secrets', + serverConfig: 'server-config', userTypes: 'user-types', users: 'users', + variables: 'variables', }; export type ManagementCollectionKey = keyof typeof MANAGEMENT_COLLECTIONS; diff --git a/packages/nuxt/src/runtime/constants/ManagementAutoImports.ts b/packages/nuxt/src/runtime/constants/ManagementAutoImports.ts index f742b23a..05339118 100644 --- a/packages/nuxt/src/runtime/constants/ManagementAutoImports.ts +++ b/packages/nuxt/src/runtime/constants/ManagementAutoImports.ts @@ -3,7 +3,7 @@ /** * 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 + * `management` is on. The containers fetch through the management API functions, so they need no Nuxt * wrapper: the module installs the `http.fetcher` they use. The `Base*` variants take their data as * props. */ @@ -21,6 +21,9 @@ export const MANAGEMENT_COMPONENTS: readonly string[] = [ 'AgentList', 'AgentCreate', 'AgentDetails', + 'ApplicationList', + 'ApplicationCreate', + 'ApplicationDetails', 'BaseUserList', 'BaseUserCreate', 'BaseUserDetails', @@ -34,43 +37,14 @@ export const MANAGEMENT_COMPONENTS: readonly string[] = [ 'BaseAgentList', 'BaseAgentCreate', 'BaseAgentDetails', + 'BaseApplicationList', + 'BaseApplicationCreate', + 'BaseApplicationDetails', ]; /** * The management composables of `@thunderid/vue` that the module auto-imports when `management` is - * on. Same names as the `@thunderid/react` hooks. + * on. `useManagementRequestConfig` hands the management API functions the module's `http.fetcher`, + * so an app calls them through the server. */ -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', -]; +export const MANAGEMENT_COMPOSABLES: readonly string[] = ['useGrantedPermissions', 'useManagementRequestConfig']; diff --git a/packages/nuxt/src/runtime/types.ts b/packages/nuxt/src/runtime/types.ts index 1512c2b5..7544accc 100644 --- a/packages/nuxt/src/runtime/types.ts +++ b/packages/nuxt/src/runtime/types.ts @@ -43,11 +43,12 @@ export interface ThunderIDNuxtConfig extends Omit { * 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. + * `GroupMembers`, `RoleAssignments`, ... and their `Base*` variants) and composables (`useManagementRequestConfig`, + * `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. + * The route only reaches the management collections that `@thunderid/node` allowlists in + * `MANAGEMENT_COLLECTIONS` (applications, users, groups, roles, agents, user and agent types, and the + * other administration collections), and the management API still decides what the signed-in user may do. */ management?: boolean; /** diff --git a/packages/nuxt/tests/unit/management-collections.test.ts b/packages/nuxt/tests/unit/management-collections.test.ts index c7eb7951..b161d7f8 100644 --- a/packages/nuxt/tests/unit/management-collections.test.ts +++ b/packages/nuxt/tests/unit/management-collections.test.ts @@ -16,6 +16,8 @@ describe('management collections', () => { 'https://id.example.com/groups/g-1/members', 'https://id.example.com/o/roles/r-1/assignments/add', 'https://id.example.com/agent-types', + 'https://id.example.com/applications?limit=10&offset=0', + 'https://id.example.com/applications/app-1', ])('allows %s', (target) => { expect(matchManagementUrl(target, collections)).toBeInstanceOf(URL); }); @@ -45,7 +47,7 @@ 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); + expect(styled).toHaveLength(16); styled.forEach((name) => expect(MANAGEMENT_COMPONENTS).toContain(`Base${name}`)); }); @@ -53,4 +55,11 @@ describe('management auto-imports', () => { expect(new Set(MANAGEMENT_COMPOSABLES).size).toBe(MANAGEMENT_COMPOSABLES.length); expect(MANAGEMENT_COMPOSABLES).toContain('useGrantedPermissions'); }); + + // An auto-import of a name the package does not export only fails when an app's build bundles it. + it('registers only names that @thunderid/vue exports', async () => { + const vue: Record = await import('@thunderid/vue'); + + [...MANAGEMENT_COMPONENTS, ...MANAGEMENT_COMPOSABLES].forEach((name) => expect(vue[name]).toBeDefined()); + }); }); diff --git a/packages/react/src/components/admin/AgentCreate/AgentCreate.tsx b/packages/react/src/components/admin/AgentCreate/AgentCreate.tsx index 4f799310..121f8579 100644 --- a/packages/react/src/components/admin/AgentCreate/AgentCreate.tsx +++ b/packages/react/src/components/admin/AgentCreate/AgentCreate.tsx @@ -1,11 +1,22 @@ // Copyright 2026 The ThunderID Authors // SPDX-License-Identifier: Apache-2.0 -import {toManagementErrorMessage, Agent, AgentType, SelectOption} from '@thunderid/browser'; +import { + Agent, + AgentQueryKeys, + AgentType, + AgentTypeListResponse, + CreateAgentRequest, + SelectOption, + createAgent, + getAgentTypes, + toManagementErrorMessage, +} 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 useManagementEndpoint, {ManagementEndpoint} from '../../../hooks/management/useManagementEndpoint'; +import useResourceMutation, {ResourceMutationResult} from '../../../hooks/management/useResourceMutation'; +import useResourceQuery, {ResourceQueryResult} from '../../../hooks/management/useResourceQuery'; import useTranslation from '../../../hooks/useTranslation'; import useDefaultOuId from '../shared/useDefaultOuId'; @@ -41,8 +52,17 @@ const AgentCreate: FC = ({ }: 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 agentsEndpoint: ManagementEndpoint = useManagementEndpoint('agents'); + const agentTypesEndpoint: ManagementEndpoint = useManagementEndpoint('agentTypes'); + const {data: agentTypes}: ResourceQueryResult = useResourceQuery( + [AgentQueryKeys.AGENT_TYPES, {limit: 100}], + (fetcher) => getAgentTypes({...agentTypesEndpoint, fetcher, limit: 100}), + ); + const {error, isLoading, mutate}: ResourceMutationResult = useResourceMutation( + (payload: CreateAgentRequest, fetcher) => createAgent({...agentsEndpoint, fetcher, payload}), + () => [[AgentQueryKeys.AGENTS]], + {onSuccess: onCreated}, + ); const options: SelectOption[] = useMemo( () => diff --git a/packages/react/src/components/admin/AgentDetails/AgentDetails.tsx b/packages/react/src/components/admin/AgentDetails/AgentDetails.tsx index 95ad0735..e3db14ec 100644 --- a/packages/react/src/components/admin/AgentDetails/AgentDetails.tsx +++ b/packages/react/src/components/admin/AgentDetails/AgentDetails.tsx @@ -1,13 +1,24 @@ // Copyright 2026 The ThunderID Authors // SPDX-License-Identifier: Apache-2.0 -import {toManagementErrorMessage} from '@thunderid/browser'; +import { + Agent, + AgentQueryKeys, + AgentRoleListResponse, + GroupQueryKeys, + MembershipGroupListResponse, + RoleQueryKeys, + deleteAgent, + getAgent, + getAgentGroups, + getAgentRoles, + toManagementErrorMessage, +} from '@thunderid/browser'; import {FC, ReactElement} from 'react'; import BaseAgentDetails, {BaseAgentDetailsProps} from './BaseAgentDetails'; -import useDeleteAgent from '../../../hooks/management/agents/useDeleteAgent'; -import useGetAgent from '../../../hooks/management/agents/useGetAgent'; -import useGetAgentGroups from '../../../hooks/management/agents/useGetAgentGroups'; -import useGetAgentRoles from '../../../hooks/management/agents/useGetAgentRoles'; +import useManagementEndpoint, {ManagementEndpoint} from '../../../hooks/management/useManagementEndpoint'; +import useResourceMutation, {ResourceMutationResult} from '../../../hooks/management/useResourceMutation'; +import useResourceQuery, {ResourceQueryResult} from '../../../hooks/management/useResourceQuery'; import useTranslation from '../../../hooks/useTranslation'; export type AgentDetailsProps = Omit< @@ -43,10 +54,27 @@ const AgentDetails: FC = ({ ...rest }: AgentDetailsProps): ReactElement => { const {t} = useTranslation(preferences?.i18n); - const agent = useGetAgent(agentId); - const groups = useGetAgentGroups(agentId, {limit: 100}); - const roles = useGetAgentRoles(agentId, {limit: 100}); - const deletion = useDeleteAgent({onSuccess: onDeleted}); + const endpoint: ManagementEndpoint = useManagementEndpoint('agents'); + const agent: ResourceQueryResult = useResourceQuery( + [AgentQueryKeys.AGENT, agentId], + (fetcher) => getAgent({...endpoint, agentId, fetcher}), + {enabled: Boolean(agentId)}, + ); + const groups: ResourceQueryResult = useResourceQuery( + [GroupQueryKeys.MEMBERSHIPS, 'agent', agentId, {limit: 100}], + (fetcher) => getAgentGroups({...endpoint, agentId, fetcher, limit: 100}), + {enabled: Boolean(agentId)}, + ); + const roles: ResourceQueryResult = useResourceQuery( + [RoleQueryKeys.ROLE_ASSIGNMENTS, 'agent', agentId, {limit: 100}], + (fetcher) => getAgentRoles({...endpoint, agentId, fetcher, limit: 100}), + {enabled: Boolean(agentId)}, + ); + const deletion: ResourceMutationResult = useResourceMutation( + (id: string, fetcher) => deleteAgent({...endpoint, agentId: id, fetcher}), + () => [[AgentQueryKeys.AGENTS]], + {onSuccess: onDeleted}, + ); return ( = ({ const [offset, setOffset] = useState(0); const [search, setSearch] = useState(null); const attributes: SelectOption[] = searchAttributes ?? [{label: t('admin.agents.list.search.name'), value: 'name'}]; - const {data, error, isLoading} = useGetAgents({ - filter: search ? toEqualityFilter(search.attribute, search.value) : undefined, - limit, - offset, - }); + const endpoint: ManagementEndpoint = useManagementEndpoint('agents'); + const filter: string | undefined = search ? toEqualityFilter(search.attribute, search.value) : undefined; + const {data, error, isLoading}: ResourceQueryResult = useResourceQuery( + [AgentQueryKeys.AGENTS, {filter, limit, offset}], + (fetcher) => getAgents({...endpoint, fetcher, filter, limit, offset}), + ); return ( & { + /** + * Called with the created application. When the server returned a client secret, it is called once + * the user has copied the secret, since the secret cannot be read again. + */ + onCreated?: (application: Application) => void; + /** + * Organization unit the application is created in. Defaults to the signed-in user's organization unit. + */ + ouId?: string; +}; + +/** + * Creates an application with an OAuth client through the management API, and shows its client + * secret once when the server returns one. + * + * The server guards applications with the root `system` permission, for reading and for changes alike; + * it defines no application-specific permission. Gate the view and its actions on it with + * `useGrantedPermissions().can('system')`. + * + * @example + * ```tsx + * navigate(`/applications/${application.id}`)} /> + * ``` + */ +const ApplicationCreate: FC = ({ + onCreated, + ouId, + preferences, + ...rest +}: ApplicationCreateProps): ReactElement => { + const {t} = useTranslation(preferences?.i18n); + const resolvedOuId: string | undefined = useDefaultOuId(ouId); + const endpoint: ManagementEndpoint = useManagementEndpoint('applications'); + const [created, setCreated] = useState<{application: Application; secret: string} | null>(null); + const [formKey, setFormKey] = useState(0); + const {error, isLoading, mutate}: ResourceMutationResult = useResourceMutation( + (payload: CreateApplicationRequest, fetcher) => createApplication({...endpoint, fetcher, payload}), + () => [[ApplicationQueryKeys.APPLICATIONS]], + { + onSuccess: (application: Application): void => { + const secret: string | undefined = getApplicationClientSecret(application); + + if (secret) { + setCreated({application, secret}); + } else { + onCreated?.(application); + } + }, + }, + ); + + const handleSubmit = (submission: ApplicationCreateSubmission): void => { + if (resolvedOuId) { + void mutate(toCreateApplicationRequest(submission, resolvedOuId)); + } + }; + + const handleSecretDismiss = (): void => { + if (created) { + setCreated(null); + setFormKey((key: number) => key + 1); + onCreated?.(created.application); + } + }; + + return ( + + ); +}; + +export default ApplicationCreate; diff --git a/packages/react/src/components/admin/ApplicationCreate/BaseApplicationCreate.tsx b/packages/react/src/components/admin/ApplicationCreate/BaseApplicationCreate.tsx new file mode 100644 index 00000000..0d990179 --- /dev/null +++ b/packages/react/src/components/admin/ApplicationCreate/BaseApplicationCreate.tsx @@ -0,0 +1,219 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import { + APPLICATION_GRANT_TYPES, + ApplicationSubmission, + DEFAULT_APPLICATION_GRANT_TYPES, + isApplicationSubmissionValid, + parseRedirectUris, + Preferences, + SelectOption, +} from '@thunderid/browser'; +import {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 AdminSection from '../shared/AdminSection'; +import useStyles from '../shared/AdminSection.styles'; +import ApplicationFormFields, {ApplicationFormValues, ApplicationTextField} from '../shared/ApplicationFormFields'; +import ClientSecretNotice from '../shared/ClientSecretNotice'; + +/** + * The application {@link BaseApplicationCreate} submits. The organization unit is added by the caller. + */ +export type ApplicationCreateSubmission = ApplicationSubmission; + +/** + * What {@link BaseApplicationCreate} hands its `children` render function, so the form can be built with any UI. + */ +export interface BaseApplicationCreateRenderProps { + /** + * The client secret of the application just created, to show once. `null` while there is none. + */ + clientSecret: string | null; + error: string | null; + /** + * The grant types to choose from, with translated labels. + */ + grantTypeOptions: SelectOption[]; + /** + * Sets the chosen grant types. + */ + handleGrantTypesChange: (grantTypes: string[]) => void; + /** + * Sets one text field of the form. `redirectUris` takes URIs separated by commas. + */ + handleInputChange: (field: ApplicationTextField, value: string) => void; + /** + * Sets whether the client is public. + */ + handlePublicClientChange: (publicClient: boolean) => 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; + /** + * Hides the client secret once it has been copied. Undefined while there is no secret. + */ + onSecretDismiss?: () => void; + values: ApplicationFormValues; +} + +export interface BaseApplicationCreateProps { + /** + * Renders custom UI in place of the default form. Receives the values, their setters, the grant + * types, the submit handler, the submission state and the client secret to show. + */ + children?: (props: BaseApplicationCreateRenderProps) => ReactNode; + className?: string; + /** + * The client secret of the application just created. When set, the form gives way to the secret, + * which can be read only this once, until `onSecretDismiss` clears it. + */ + clientSecret?: string | null; + description?: string; + error?: string | null; + isSubmitting?: boolean; + onCancel?: () => void; + /** + * Called when the user has copied the client secret. + */ + onSecretDismiss?: () => void; + onSubmit: (application: ApplicationCreateSubmission) => void; + preferences?: Preferences; + title?: string; +} + +/** + * A form that collects a new application's name, description, links and OAuth basics: redirect URIs, + * grant types and whether the client is public. Holds no data of its own: handle the submission, and + * pass back the client secret the server returned to show it once. + */ +const BaseApplicationCreate: FC = ({ + children, + className, + clientSecret = null, + description, + error = null, + isSubmitting = false, + onCancel, + onSecretDismiss, + onSubmit, + preferences, + title, +}: BaseApplicationCreateProps): ReactElement => { + const {theme, colorScheme} = useTheme(); + const styles: Record = useStyles(theme, colorScheme); + const {t} = useTranslation(preferences?.i18n); + const [values, setValues] = useState({ + description: '', + grantTypes: [...DEFAULT_APPLICATION_GRANT_TYPES], + logoUrl: '', + name: '', + publicClient: false, + redirectUris: '', + url: '', + }); + + const submission: ApplicationCreateSubmission = { + description: values.description.trim() || undefined, + grantTypes: values.grantTypes, + logoUrl: values.logoUrl.trim() || undefined, + name: values.name.trim(), + publicClient: values.publicClient, + redirectUris: parseRedirectUris(values.redirectUris), + url: values.url.trim() || undefined, + }; + const isValid: boolean = isApplicationSubmissionValid(submission); + + const setInput = (field: ApplicationTextField, value: string): void => + setValues((previous: ApplicationFormValues) => ({...previous, [field]: value})); + const setGrantTypes = (grantTypes: string[]): void => + setValues((previous: ApplicationFormValues) => ({...previous, grantTypes})); + const setPublicClient = (publicClient: boolean): void => + setValues((previous: ApplicationFormValues) => ({...previous, publicClient})); + + const submit = (): void => { + if (isValid && !isSubmitting) { + onSubmit(submission); + } + }; + + const handleSubmit = (event: FormEvent): void => { + event.preventDefault(); + submit(); + }; + + if (children) { + return ( +
+ {children({ + clientSecret, + error, + grantTypeOptions: APPLICATION_GRANT_TYPES.map((grantType: string) => ({ + label: t(`admin.applications.grant_types.${grantType}`), + value: grantType, + })), + handleGrantTypesChange: setGrantTypes, + handleInputChange: setInput, + handlePublicClientChange: setPublicClient, + handleSubmit: submit, + isSubmitting, + isValid, + onCancel, + onSecretDismiss: clientSecret ? onSecretDismiss : undefined, + values, + })} +
+ ); + } + + return ( + + {clientSecret ? ( + + ) : ( +
+ {error && ( + + {error} + + )} + +
+ {onCancel && ( + + )} + +
+ + )} +
+ ); +}; + +export default BaseApplicationCreate; diff --git a/packages/react/src/components/admin/ApplicationDetails/ApplicationDetails.tsx b/packages/react/src/components/admin/ApplicationDetails/ApplicationDetails.tsx new file mode 100644 index 00000000..e194b89e --- /dev/null +++ b/packages/react/src/components/admin/ApplicationDetails/ApplicationDetails.tsx @@ -0,0 +1,157 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import { + Application, + ApplicationQueryKeys, + deleteApplication, + getApplication, + regenerateClientSecret, + RegenerateClientSecretResult, + toManagementErrorMessage, + toUpdateApplicationRequest, + updateApplication, +} from '@thunderid/browser'; +import {FC, ReactElement} from 'react'; +import BaseApplicationDetails, { + ApplicationUpdateSubmission, + BaseApplicationDetailsProps, +} from './BaseApplicationDetails'; +import useManagementEndpoint, {ManagementEndpoint} from '../../../hooks/management/useManagementEndpoint'; +import useResourceMutation, {ResourceMutationResult} from '../../../hooks/management/useResourceMutation'; +import useResourceQuery, {ResourceQueryResult} from '../../../hooks/management/useResourceQuery'; +import useTranslation from '../../../hooks/useTranslation'; + +interface UpdateApplicationVariables { + application: Application; + changes: ApplicationUpdateSubmission; +} + +export type ApplicationDetailsProps = Omit< + BaseApplicationDetailsProps, + | 'application' + | 'clientSecret' + | 'error' + | 'isDeleting' + | 'isLoading' + | 'isRegeneratingSecret' + | 'isUpdating' + | 'onDelete' + | 'onRegenerateSecret' + | 'onSecretDismiss' + | 'onUpdate' +> & { + applicationId: string; + /** + * Whether the application can be deleted from this view. + * @default false + */ + deletable?: boolean; + /** + * Whether the application's general fields, redirect URIs and grant types can be edited from this view. + * @default false + */ + editable?: boolean; + /** + * Called after the application has been deleted. + */ + onDeleted?: () => void; + /** + * Whether the application's client secret can be regenerated from this view. The new secret is shown + * once. + * @default false + */ + secretRegenerable?: boolean; +}; + +/** + * Shows an application and its OAuth configuration, and optionally lets the signed-in user edit it, + * regenerate its client secret or delete it. + * + * The server guards applications with the root `system` permission, for reading and for changes alike; + * it defines no application-specific permission. Gate the view and its actions on it with + * `useGrantedPermissions().can('system')`. + * + * @example + * ```tsx + * const {can} = useGrantedPermissions(); + * const isAdmin = can('system'); + * + * navigate('/applications')} + * /> + * ``` + */ +const ApplicationDetails: FC = ({ + applicationId, + deletable = false, + editable = false, + onDeleted, + preferences, + secretRegenerable = false, + ...rest +}: ApplicationDetailsProps): ReactElement => { + const {t} = useTranslation(preferences?.i18n); + const endpoint: ManagementEndpoint = useManagementEndpoint('applications'); + const application: ResourceQueryResult = useResourceQuery( + [ApplicationQueryKeys.APPLICATION, applicationId], + (fetcher) => getApplication({...endpoint, applicationId, fetcher}), + {enabled: Boolean(applicationId)}, + ); + const deletion: ResourceMutationResult = useResourceMutation( + (id: string, fetcher) => deleteApplication({...endpoint, applicationId: id, fetcher}), + () => [[ApplicationQueryKeys.APPLICATIONS]], + {onSuccess: onDeleted}, + ); + const update: ResourceMutationResult = useResourceMutation( + ({application: current, changes}: UpdateApplicationVariables, fetcher) => + updateApplication({ + ...endpoint, + applicationId: current.id, + fetcher, + payload: toUpdateApplicationRequest(current, changes), + }), + ({application: current}: UpdateApplicationVariables) => [ + [ApplicationQueryKeys.APPLICATION, current.id], + [ApplicationQueryKeys.APPLICATIONS], + ], + ); + const regeneration: ResourceMutationResult = useResourceMutation( + (id: string, fetcher) => regenerateClientSecret({...endpoint, applicationId: id, fetcher}), + (id: string) => [[ApplicationQueryKeys.APPLICATION, id], [ApplicationQueryKeys.APPLICATIONS]], + ); + + return ( + void deletion.mutate(applicationId) : undefined} + isUpdating={update.isLoading} + onUpdate={ + editable && application.data + ? (changes: ApplicationUpdateSubmission): void => + void update.mutate({application: application.data!, changes}) + : undefined + } + isRegeneratingSecret={regeneration.isLoading} + onRegenerateSecret={secretRegenerable ? (): void => void regeneration.mutate(applicationId) : undefined} + clientSecret={regeneration.data?.clientSecret ?? null} + onSecretDismiss={regeneration.reset} + preferences={preferences} + {...rest} + /> + ); +}; + +export default ApplicationDetails; diff --git a/packages/react/src/components/admin/ApplicationDetails/BaseApplicationDetails.tsx b/packages/react/src/components/admin/ApplicationDetails/BaseApplicationDetails.tsx new file mode 100644 index 00000000..4cbc1be3 --- /dev/null +++ b/packages/react/src/components/admin/ApplicationDetails/BaseApplicationDetails.tsx @@ -0,0 +1,373 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import { + Application, + APPLICATION_GRANT_TYPES, + ApplicationSubmission, + getApplicationOAuthConfig, + isApplicationSubmissionValid, + OAuth2Config, + parseRedirectUris, + Preferences, +} from '@thunderid/browser'; +import {FC, FormEvent, ReactElement, ReactNode, useState} from 'react'; +import useTranslation from '../../../hooks/useTranslation'; +import Alert from '../../primitives/Alert/Alert'; +import Button from '../../primitives/Button/Button'; +import CopyableText from '../../primitives/CopyableText/CopyableText'; +import Spinner from '../../primitives/Spinner/Spinner'; +import {ChipList, ConfirmButton, DetailList, DetailListItem, Panel} from '../shared/AdminParts'; +import AdminSection from '../shared/AdminSection'; +import ApplicationFormFields, {ApplicationFormValues, ApplicationTextField} from '../shared/ApplicationFormFields'; +import ClientSecretNotice from '../shared/ClientSecretNotice'; +import useAdminPartStyles from '../shared/useAdminPartStyles'; + +/** + * The edited fields {@link BaseApplicationDetails} hands `onUpdate`. Whether the client is public does + * not change. + */ +export type ApplicationUpdateSubmission = Omit; + +/** + * What {@link BaseApplicationDetails} hands its `children` render function, so the application can be + * shown with any UI. + */ +export interface BaseApplicationDetailsRenderProps { + application?: Application; + /** + * The new client secret, to show once. `null` while there is none. + */ + clientSecret: string | null; + error: string | null; + isDeleting: boolean; + isLoading: boolean; + isRegeneratingSecret: boolean; + isUpdating: boolean; + /** + * The application's OAuth configuration, without its client secret. Undefined when it has no OAuth client. + */ + oauth?: Omit; + /** + * Deletes the application. Undefined when deletion is not allowed, including for read-only applications. + */ + onDelete?: () => void; + /** + * Gives the application a new client secret. Undefined when not allowed, for read-only applications + * and for applications without a confidential OAuth client. + */ + onRegenerateSecret?: () => void; + /** + * Hides the new client secret once it has been copied. Undefined while there is no secret. + */ + onSecretDismiss?: () => void; + /** + * Saves edited fields. Undefined when editing is not allowed, including for read-only applications. + */ + onUpdate?: (application: ApplicationUpdateSubmission) => void; +} + +export interface BaseApplicationDetailsProps { + /** + * Controls rendered next to the application's name. + */ + actions?: ReactNode; + application?: Application; + /** + * Renders custom UI in place of the default view. Receives the application, its OAuth configuration, + * the loading and error state, the actions the app allows and the new client secret to show. + */ + children?: (props: BaseApplicationDetailsRenderProps) => ReactNode; + className?: string; + /** + * A client secret just generated by `onRegenerateSecret`, shown once until `onSecretDismiss` clears it. + */ + clientSecret?: string | null; + error?: string | null; + isDeleting?: boolean; + isLoading?: boolean; + isRegeneratingSecret?: boolean; + isUpdating?: boolean; + /** + * Deletes the application. The delete action is hidden when omitted. + */ + onDelete?: () => void; + /** + * Gives the application a new client secret. The regenerate action is hidden when omitted, and for + * public clients, which have no secret. + */ + onRegenerateSecret?: () => void; + /** + * Called when the user has copied the new client secret. + */ + onSecretDismiss?: () => void; + /** + * Saves the edited general fields, redirect URIs and grant types. Editing is offered only when set. + */ + onUpdate?: (application: ApplicationUpdateSubmission) => void; + preferences?: Preferences; +} + +const toFormValues = (application: Application, oauth?: OAuth2Config): ApplicationFormValues => ({ + description: application.description ?? '', + grantTypes: oauth?.grantTypes ?? [], + logoUrl: application.logoUrl ?? '', + name: application.name, + publicClient: Boolean(oauth?.publicClient), + redirectUris: (oauth?.redirectUris ?? []).join(', '), + url: application.url ?? '', +}); + +const toSubmission = (values: ApplicationFormValues): ApplicationSubmission => ({ + description: values.description.trim() || undefined, + grantTypes: values.grantTypes, + logoUrl: values.logoUrl.trim() || undefined, + name: values.name.trim(), + publicClient: values.publicClient, + redirectUris: parseRedirectUris(values.redirectUris), + url: values.url.trim() || undefined, +}); + +/** + * Shows an application's general fields and OAuth configuration. Holds no data of its own: pass the + * application and handle editing, deletion and client secret regeneration, each offered only when its + * handler is set. + */ +const BaseApplicationDetails: FC = ({ + actions, + application, + children, + className, + clientSecret = null, + error = null, + isDeleting = false, + isLoading = false, + isRegeneratingSecret = false, + isUpdating = false, + onDelete, + onRegenerateSecret, + onSecretDismiss, + onUpdate, + preferences, +}: BaseApplicationDetailsProps): ReactElement => { + const {t} = useTranslation(preferences?.i18n); + const styles: Record = useAdminPartStyles(); + const [draft, setDraft] = useState(null); + + const oauthConfig: OAuth2Config | undefined = getApplicationOAuthConfig(application); + const isWritable: boolean = Boolean(application) && !application?.isReadOnly; + const canRegenerate: boolean = isWritable && Boolean(oauthConfig) && !oauthConfig?.publicClient; + + if (children) { + let oauth: Omit | undefined; + + if (oauthConfig) { + const {clientSecret: storedSecret, ...rest}: OAuth2Config = oauthConfig; + void storedSecret; + oauth = rest; + } + + return ( +
+ {children({ + application, + clientSecret, + error, + isDeleting, + isLoading, + isRegeneratingSecret, + isUpdating, + oauth, + onDelete: isWritable ? onDelete : undefined, + onRegenerateSecret: canRegenerate ? onRegenerateSecret : undefined, + onSecretDismiss: clientSecret ? onSecretDismiss : undefined, + onUpdate: isWritable ? onUpdate : undefined, + })} +
+ ); + } + + if (isLoading && !application) { + return ( + + + {t('admin.common.loading')} + + + ); + } + + if (!application) { + return ( + + + {error ?? t('admin.common.errors.not_found')} + + + ); + } + + const notSet: string = t('admin.applications.details.not_set'); + const overview: DetailListItem[] = [ + { + label: t('admin.applications.list.columns.client_id'), + value: oauthConfig?.clientId ? : notSet, + }, + {label: t('admin.applications.details.type'), value: application.type ?? application.template ?? notSet}, + {label: t('admin.common.columns.organization_unit'), value: application.ouId ?? notSet}, + {label: t('admin.applications.fields.url'), value: application.url ?? notSet}, + {label: t('admin.applications.fields.logo_url'), value: application.logoUrl ?? notSet}, + ]; + + const handleSave = (event: FormEvent): void => { + event.preventDefault(); + + if (!draft) { + return; + } + + const {publicClient, ...submission}: ApplicationSubmission = toSubmission(draft); + void publicClient; + onUpdate?.(submission); + setDraft(null); + }; + + return ( + + {actions} + {onDelete && isWritable && ( + + {t('admin.applications.details.delete.action')} + + )} + + } + > + {error && ( + + {error} + + )} + {draft ? ( + +
+ + setDraft((previous: ApplicationFormValues | null) => previous && {...previous, [field]: value}) + } + onGrantTypesChange={(grantTypes: string[]): void => + setDraft((previous: ApplicationFormValues | null) => previous && {...previous, grantTypes}) + } + /> +
+ + +
+ +
+ ) : ( + <> + setDraft(toFormValues(application, oauthConfig))} + > + {t('admin.applications.details.edit.action')} + + ) : undefined + } + > + + + {oauthConfig && ( + + {t('admin.applications.details.secret.action')} + + ) : undefined + } + > + {clientSecret && ( + + )} + ({ + id: grantType, + label: APPLICATION_GRANT_TYPES.includes(grantType) + ? t(`admin.applications.grant_types.${grantType}`) + : grantType, + }))} + /> + ), + }, + { + label: t('admin.applications.fields.redirect_uris'), + value: ( + ({id: uri, label: uri}))} + /> + ), + }, + { + label: t('admin.applications.details.public_client'), + value: oauthConfig.publicClient + ? t('admin.applications.details.yes') + : t('admin.applications.details.no'), + }, + ]} + /> + + )} + + )} +
+ ); +}; + +export default BaseApplicationDetails; diff --git a/packages/react/src/components/admin/ApplicationList/ApplicationList.tsx b/packages/react/src/components/admin/ApplicationList/ApplicationList.tsx new file mode 100644 index 00000000..f7ab3b5b --- /dev/null +++ b/packages/react/src/components/admin/ApplicationList/ApplicationList.tsx @@ -0,0 +1,61 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import { + ApplicationListResponse, + ApplicationQueryKeys, + getApplications, + toManagementErrorMessage, +} from '@thunderid/browser'; +import {FC, ReactElement, useState} from 'react'; +import BaseApplicationList, {BaseApplicationListProps} from './BaseApplicationList'; +import useManagementEndpoint, {ManagementEndpoint} from '../../../hooks/management/useManagementEndpoint'; +import useResourceQuery, {ResourceQueryResult} from '../../../hooks/management/useResourceQuery'; +import useTranslation from '../../../hooks/useTranslation'; + +export type ApplicationListProps = Omit< + BaseApplicationListProps, + 'applications' | 'error' | 'isLoading' | 'offset' | 'onPageChange' | 'totalResults' +>; + +/** + * Lists the applications the signed-in user is allowed to see, a page at a time. + * + * The server guards applications with the root `system` permission, for reading and for changes alike; + * it defines no application-specific permission. Gate the view and its actions on it with + * `useGrantedPermissions().can('system')`. + * + * @example + * ```tsx + * setSelectedApplicationId(application.id)} /> + * ``` + */ +const ApplicationList: FC = ({ + limit = 20, + preferences, + ...rest +}: ApplicationListProps): ReactElement => { + const {t} = useTranslation(preferences?.i18n); + const [offset, setOffset] = useState(0); + const endpoint: ManagementEndpoint = useManagementEndpoint('applications'); + const {data, error, isLoading}: ResourceQueryResult = useResourceQuery( + [ApplicationQueryKeys.APPLICATIONS, {limit, offset}], + (fetcher) => getApplications({...endpoint, fetcher, limit, offset}), + ); + + return ( + + ); +}; + +export default ApplicationList; diff --git a/packages/react/src/components/admin/ApplicationList/BaseApplicationList.tsx b/packages/react/src/components/admin/ApplicationList/BaseApplicationList.tsx new file mode 100644 index 00000000..f7da744c --- /dev/null +++ b/packages/react/src/components/admin/ApplicationList/BaseApplicationList.tsx @@ -0,0 +1,174 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {BasicApplication, Preferences} 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 BaseApplicationList} hands its `children` render function. `applications` and `rows` are + * the same array. + */ +export type BaseApplicationListRenderProps = AdminListRenderProps & { + applications: BasicApplication[]; +}; + +export interface BaseApplicationListProps { + /** + * Controls rendered next to the title, such as a button that opens `ApplicationCreate`. + */ + actions?: ReactNode; + applications: BasicApplication[]; + /** + * Renders custom UI in place of the default table, for example a third-party data grid. Receives + * the applications, the default columns, the loading and error state, and the pagination and + * selection handlers. + */ + children?: (props: BaseApplicationListRenderProps) => 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 an application is chosen. Rows are selectable only when set. + */ + onSelect?: (application: BasicApplication) => void; + preferences?: Preferences; + selectedApplicationId?: string; + /** + * Heading of the list. Pass an empty string to hide it. + */ + title?: string; + totalResults?: number; +} + +/** + * The translated access status of an application, or its raw value when the status is one this SDK + * does not know. + */ +const getStatusLabel = (application: BasicApplication, t: (key: string) => string): string => { + const value: string | undefined = application.status?.value; + + switch (value?.toUpperCase()) { + case 'ACTIVE': + return t('admin.applications.status.active'); + case 'SUSPENDED': + return t('admin.applications.status.suspended'); + default: + return value ?? ''; + } +}; + +/** + * Lists applications with their client identifier, type and, when the server reports it, access status. Holds no data of its own: pass the page + * of applications and handle selection and pagination. + */ +const BaseApplicationList: FC = ({ + actions, + applications, + children, + className, + description, + error = null, + isLoading = false, + limit = 20, + offset = 0, + onPageChange, + onSelect, + preferences, + selectedApplicationId, + title, + totalResults, +}: BaseApplicationListProps): ReactElement => { + const {t} = useTranslation(preferences?.i18n); + + const columns: DataTableColumn[] = [ + { + header: t('admin.applications.list.columns.name'), + id: 'name', + render: (application: BasicApplication): ReactNode => application.name, + }, + { + header: t('admin.applications.list.columns.client_id'), + id: 'clientId', + render: (application: BasicApplication): ReactNode => + application.clientId ? {application.clientId} : null, + }, + { + header: t('admin.applications.list.columns.type'), + id: 'type', + render: (application: BasicApplication): ReactNode => application.type ?? application.template ?? '', + }, + // A server that does not report the access status gets no empty status column. + ...(applications.some((application: BasicApplication) => application.status?.value) + ? [ + { + header: t('admin.applications.list.columns.status'), + id: 'status', + render: (application: BasicApplication): ReactNode => getStatusLabel(application, t), + }, + ] + : []), + ]; + + if (children) { + return ( +
+ {children({ + applications, + columns, + error, + isLoading, + limit, + offset, + onPageChange, + onSelect, + rows: applications, + selectedRowId: selectedApplicationId, + totalResults: totalResults ?? applications.length, + })} +
+ ); + } + + return ( + + application.id} + isLoading={isLoading} + error={error} + emptyText={t('admin.applications.list.empty')} + onRowClick={onSelect} + selectedRowId={selectedApplicationId} + pagination={ + onPageChange ? {limit, offset, onChange: onPageChange, total: totalResults ?? applications.length} : undefined + } + /> + + ); +}; + +export default BaseApplicationList; diff --git a/packages/react/src/components/admin/GroupCreate/GroupCreate.tsx b/packages/react/src/components/admin/GroupCreate/GroupCreate.tsx index 1fd8e4e3..c6d99e20 100644 --- a/packages/react/src/components/admin/GroupCreate/GroupCreate.tsx +++ b/packages/react/src/components/admin/GroupCreate/GroupCreate.tsx @@ -1,10 +1,11 @@ // Copyright 2026 The ThunderID Authors // SPDX-License-Identifier: Apache-2.0 -import {toManagementErrorMessage, Group} from '@thunderid/browser'; +import {CreateGroupRequest, Group, GroupQueryKeys, createGroup, toManagementErrorMessage} from '@thunderid/browser'; import {FC, ReactElement} from 'react'; import BaseGroupCreate, {BaseGroupCreateProps, GroupCreateSubmission} from './BaseGroupCreate'; -import useCreateGroup from '../../../hooks/management/groups/useCreateGroup'; +import useManagementEndpoint, {ManagementEndpoint} from '../../../hooks/management/useManagementEndpoint'; +import useResourceMutation, {ResourceMutationResult} from '../../../hooks/management/useResourceMutation'; import useTranslation from '../../../hooks/useTranslation'; import useDefaultOuId from '../shared/useDefaultOuId'; @@ -30,7 +31,12 @@ export type GroupCreateProps = Omit = ({onCreated, ouId, preferences, ...rest}: GroupCreateProps): ReactElement => { const {t} = useTranslation(preferences?.i18n); const resolvedOuId: string | undefined = useDefaultOuId(ouId); - const {error, isLoading, mutate} = useCreateGroup({onSuccess: onCreated}); + const endpoint: ManagementEndpoint = useManagementEndpoint('groups'); + const {error, isLoading, mutate}: ResourceMutationResult = useResourceMutation( + (payload: CreateGroupRequest, fetcher) => createGroup({...endpoint, fetcher, payload}), + () => [[GroupQueryKeys.GROUPS], [GroupQueryKeys.MEMBERSHIPS]], + {onSuccess: onCreated}, + ); const handleSubmit = ({description, name}: GroupCreateSubmission): void => { if (resolvedOuId) { diff --git a/packages/react/src/components/admin/GroupList/GroupList.tsx b/packages/react/src/components/admin/GroupList/GroupList.tsx index 1d176ceb..2e86b668 100644 --- a/packages/react/src/components/admin/GroupList/GroupList.tsx +++ b/packages/react/src/components/admin/GroupList/GroupList.tsx @@ -1,10 +1,11 @@ // Copyright 2026 The ThunderID Authors // SPDX-License-Identifier: Apache-2.0 -import {toManagementErrorMessage} from '@thunderid/browser'; +import {GroupListResponse, GroupQueryKeys, getGroups, toManagementErrorMessage} from '@thunderid/browser'; import {FC, ReactElement, useState} from 'react'; import BaseGroupList, {BaseGroupListProps} from './BaseGroupList'; -import useGetGroups from '../../../hooks/management/groups/useGetGroups'; +import useManagementEndpoint, {ManagementEndpoint} from '../../../hooks/management/useManagementEndpoint'; +import useResourceQuery, {ResourceQueryResult} from '../../../hooks/management/useResourceQuery'; import useTranslation from '../../../hooks/useTranslation'; export type GroupListProps = Omit< @@ -23,7 +24,11 @@ export type GroupListProps = Omit< const GroupList: FC = ({limit = 20, preferences, ...rest}: GroupListProps): ReactElement => { const {t} = useTranslation(preferences?.i18n); const [offset, setOffset] = useState(0); - const {data, error, isLoading} = useGetGroups({limit, offset}); + const endpoint: ManagementEndpoint = useManagementEndpoint('groups'); + const {data, error, isLoading}: ResourceQueryResult = useResourceQuery( + [GroupQueryKeys.GROUPS, {limit, offset}], + (fetcher) => getGroups({...endpoint, fetcher, limit, offset}), + ); return ( []; +} + +/** + * Query keys to refetch after a group's membership changes. + */ +const getMembershipInvalidatedKeys = ({groupId}: GroupMembersVariables): ResourceQueryKey[] => [ + [GroupQueryKeys.GROUP, groupId], + [GroupQueryKeys.GROUP_MEMBERS, groupId], + [GroupQueryKeys.MEMBERSHIPS], + [RoleQueryKeys.ROLE_ASSIGNMENTS], +]; + export type GroupMembersProps = Omit< BaseGroupMembersProps, 'candidates' | 'error' | 'isLoading' | 'isMutating' | 'members' | 'onAdd' | 'onRemove' @@ -45,11 +72,34 @@ const GroupMembers: FC = ({ ...rest }: GroupMembersProps): ReactElement => { const {t} = useTranslation(preferences?.i18n); - const members = useGetGroupMembers(groupId, {limit: 100}); - const users = useGetUsers({limit: 100}, {enabled: editable}); - const agents = useGetAgents({limit: 100}, {enabled: editable}); - const add = useAddGroupMembers(); - const remove = useRemoveGroupMembers(); + const groupsEndpoint: ManagementEndpoint = useManagementEndpoint('groups'); + const usersEndpoint: ManagementEndpoint = useManagementEndpoint('users'); + const agentsEndpoint: ManagementEndpoint = useManagementEndpoint('agents'); + const members: ResourceQueryResult = useResourceQuery( + [GroupQueryKeys.GROUP_MEMBERS, groupId, {limit: 100}], + (fetcher) => getGroupMembers({...groupsEndpoint, fetcher, groupId, limit: 100}), + {enabled: Boolean(groupId)}, + ); + const users: ResourceQueryResult = useResourceQuery( + [UserQueryKeys.USERS, {limit: 100}], + (fetcher) => getUsers({...usersEndpoint, fetcher, limit: 100}), + {enabled: editable}, + ); + const agents: ResourceQueryResult = useResourceQuery( + [AgentQueryKeys.AGENTS, {limit: 100}], + (fetcher) => getAgents({...agentsEndpoint, fetcher, limit: 100}), + {enabled: editable}, + ); + const add: ResourceMutationResult = useResourceMutation( + ({groupId: id, members: changes}: GroupMembersVariables, fetcher) => + addGroupMembers({...groupsEndpoint, fetcher, groupId: id, members: changes}), + getMembershipInvalidatedKeys, + ); + const remove: ResourceMutationResult = useResourceMutation( + ({groupId: id, members: changes}: GroupMembersVariables, fetcher) => + removeGroupMembers({...groupsEndpoint, fetcher, groupId: id, members: changes}), + getMembershipInvalidatedKeys, + ); const candidates: Partial> = useMemo( () => ({ diff --git a/packages/react/src/components/admin/RoleAssignments/RoleAssignments.tsx b/packages/react/src/components/admin/RoleAssignments/RoleAssignments.tsx index f00d184d..f28ba054 100644 --- a/packages/react/src/components/admin/RoleAssignments/RoleAssignments.tsx +++ b/packages/react/src/components/admin/RoleAssignments/RoleAssignments.tsx @@ -2,26 +2,42 @@ // SPDX-License-Identifier: Apache-2.0 import { - resolveManagedIdentityName, - toManagementErrorMessage, + AgentListResponse, + AgentQueryKeys, BasicAgent, Group, + GroupListResponse, + GroupQueryKeys, ManagedUser, + ManagedUserListResponse, RoleAssigneeType, RoleAssignment, RoleAssignmentInput, + RoleAssignmentListResponse, + RoleQueryKeys, + UserQueryKeys, + addRoleAssignments, + getAgents, + getGroups, + getRoleAssignments, + getUsers, + removeRoleAssignments, + resolveManagedIdentityName, + toManagementErrorMessage, } 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 useManagementEndpoint, {ManagementEndpoint} from '../../../hooks/management/useManagementEndpoint'; +import useResourceMutation, {ResourceMutationResult} from '../../../hooks/management/useResourceMutation'; +import useResourceQuery, {ResourceQueryResult} from '../../../hooks/management/useResourceQuery'; import useTranslation from '../../../hooks/useTranslation'; import {IdentityCandidate} from '../shared/IdentityPicker'; +interface RoleAssignmentsVariables { + assignments: RoleAssignmentInput[]; + roleId: string; +} + export type RoleAssignmentsProps = Omit< BaseRoleAssignmentsProps, | 'assignments' @@ -65,12 +81,41 @@ const RoleAssignments: FC = ({ }: 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 rolesEndpoint: ManagementEndpoint = useManagementEndpoint('roles'); + const usersEndpoint: ManagementEndpoint = useManagementEndpoint('users'); + const groupsEndpoint: ManagementEndpoint = useManagementEndpoint('groups'); + const agentsEndpoint: ManagementEndpoint = useManagementEndpoint('agents'); + const type: RoleAssigneeType | undefined = typeFilter ?? undefined; + const assignments: ResourceQueryResult = useResourceQuery( + [RoleQueryKeys.ROLE_ASSIGNMENTS, roleId, {limit: 100, type}], + (fetcher) => getRoleAssignments({...rolesEndpoint, fetcher, limit: 100, roleId, type}), + {enabled: Boolean(roleId)}, + ); + const users: ResourceQueryResult = useResourceQuery( + [UserQueryKeys.USERS, {limit: 100}], + (fetcher) => getUsers({...usersEndpoint, fetcher, limit: 100}), + {enabled: editable}, + ); + const groups: ResourceQueryResult = useResourceQuery( + [GroupQueryKeys.GROUPS, {limit: 100}], + (fetcher) => getGroups({...groupsEndpoint, fetcher, limit: 100}), + {enabled: editable}, + ); + const agents: ResourceQueryResult = useResourceQuery( + [AgentQueryKeys.AGENTS, {limit: 100}], + (fetcher) => getAgents({...agentsEndpoint, fetcher, limit: 100}), + {enabled: editable}, + ); + const assign: ResourceMutationResult = useResourceMutation( + ({assignments: changes, roleId: id}: RoleAssignmentsVariables, fetcher) => + addRoleAssignments({...rolesEndpoint, assignments: changes, fetcher, roleId: id}), + () => [[RoleQueryKeys.ROLE_ASSIGNMENTS]], + ); + const unassign: ResourceMutationResult = useResourceMutation( + ({assignments: changes, roleId: id}: RoleAssignmentsVariables, fetcher) => + removeRoleAssignments({...rolesEndpoint, assignments: changes, fetcher, roleId: id}), + () => [[RoleQueryKeys.ROLE_ASSIGNMENTS]], + ); const candidates: Partial> = useMemo( () => ({ diff --git a/packages/react/src/components/admin/RoleCreate/RoleCreate.tsx b/packages/react/src/components/admin/RoleCreate/RoleCreate.tsx index a17a4301..928cef17 100644 --- a/packages/react/src/components/admin/RoleCreate/RoleCreate.tsx +++ b/packages/react/src/components/admin/RoleCreate/RoleCreate.tsx @@ -1,10 +1,11 @@ // Copyright 2026 The ThunderID Authors // SPDX-License-Identifier: Apache-2.0 -import {toManagementErrorMessage, Role} from '@thunderid/browser'; +import {CreateRoleRequest, Role, RoleQueryKeys, createRole, toManagementErrorMessage} from '@thunderid/browser'; import {FC, ReactElement} from 'react'; import BaseRoleCreate, {BaseRoleCreateProps, RoleCreateSubmission} from './BaseRoleCreate'; -import useCreateRole from '../../../hooks/management/roles/useCreateRole'; +import useManagementEndpoint, {ManagementEndpoint} from '../../../hooks/management/useManagementEndpoint'; +import useResourceMutation, {ResourceMutationResult} from '../../../hooks/management/useResourceMutation'; import useTranslation from '../../../hooks/useTranslation'; import useDefaultOuId from '../shared/useDefaultOuId'; @@ -30,7 +31,12 @@ export type RoleCreateProps = Omit = ({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 endpoint: ManagementEndpoint = useManagementEndpoint('roles'); + const {error, isLoading, mutate}: ResourceMutationResult = useResourceMutation( + (payload: CreateRoleRequest, fetcher) => createRole({...endpoint, fetcher, payload}), + () => [[RoleQueryKeys.ROLES], [RoleQueryKeys.ROLE_ASSIGNMENTS]], + {onSuccess: onCreated}, + ); const handleSubmit = ({description, name}: RoleCreateSubmission): void => { if (resolvedOuId) { diff --git a/packages/react/src/components/admin/RoleList/RoleList.tsx b/packages/react/src/components/admin/RoleList/RoleList.tsx index b14fe34c..b3062b6e 100644 --- a/packages/react/src/components/admin/RoleList/RoleList.tsx +++ b/packages/react/src/components/admin/RoleList/RoleList.tsx @@ -1,10 +1,11 @@ // Copyright 2026 The ThunderID Authors // SPDX-License-Identifier: Apache-2.0 -import {toManagementErrorMessage} from '@thunderid/browser'; +import {RoleListResponse, RoleQueryKeys, getRoles, toManagementErrorMessage} from '@thunderid/browser'; import {FC, ReactElement, useState} from 'react'; import BaseRoleList, {BaseRoleListProps} from './BaseRoleList'; -import useGetRoles from '../../../hooks/management/roles/useGetRoles'; +import useManagementEndpoint, {ManagementEndpoint} from '../../../hooks/management/useManagementEndpoint'; +import useResourceQuery, {ResourceQueryResult} from '../../../hooks/management/useResourceQuery'; import useTranslation from '../../../hooks/useTranslation'; export type RoleListProps = Omit< @@ -23,7 +24,11 @@ export type RoleListProps = Omit< 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}); + const endpoint: ManagementEndpoint = useManagementEndpoint('roles'); + const {data, error, isLoading}: ResourceQueryResult = useResourceQuery( + [RoleQueryKeys.ROLES, {limit, offset}], + (fetcher) => getRoles({...endpoint, fetcher, limit, offset}), + ); return ( & { @@ -25,7 +26,12 @@ const RolePermissions: FC = ({ ...rest }: RolePermissionsProps): ReactElement => { const {t} = useTranslation(preferences?.i18n); - const role = useGetRole(roleId); + const endpoint: ManagementEndpoint = useManagementEndpoint('roles'); + const role: ResourceQueryResult = useResourceQuery( + [RoleQueryKeys.ROLE, roleId], + (fetcher) => getRole({...endpoint, fetcher, roleId}), + {enabled: Boolean(roleId)}, + ); return ( = ({ }: UserCreateProps): ReactElement => { const {t} = useTranslation(preferences?.i18n); const resolvedOuId: string | undefined = useDefaultOuId(ouId); - const {data: userTypes} = useGetUserTypes({limit: 100}, {enabled: mode === 'api'}); - const {error, isLoading, mutate} = useCreateUser({onSuccess: onCreated}); + const usersEndpoint: ManagementEndpoint = useManagementEndpoint('users'); + const userTypesEndpoint: ManagementEndpoint = useManagementEndpoint('userTypes'); + const {data: userTypes}: ResourceQueryResult = useResourceQuery( + [UserQueryKeys.USER_TYPES, {limit: 100}], + (fetcher) => getUserTypes({...userTypesEndpoint, fetcher, limit: 100}), + {enabled: mode === 'api'}, + ); + const {error, isLoading, mutate}: ResourceMutationResult = useResourceMutation( + (payload: CreateManagedUserRequest, fetcher) => createUser({...usersEndpoint, fetcher, payload}), + () => [[UserQueryKeys.USERS]], + {onSuccess: onCreated}, + ); const options: SelectOption[] = useMemo( () => diff --git a/packages/react/src/components/admin/UserDetails/UserDetails.tsx b/packages/react/src/components/admin/UserDetails/UserDetails.tsx index b8611c84..ad21d671 100644 --- a/packages/react/src/components/admin/UserDetails/UserDetails.tsx +++ b/packages/react/src/components/admin/UserDetails/UserDetails.tsx @@ -1,15 +1,30 @@ // Copyright 2026 The ThunderID Authors // SPDX-License-Identifier: Apache-2.0 -import {toManagementErrorMessage} from '@thunderid/browser'; +import { + GroupQueryKeys, + ManagedUser, + MembershipGroupListResponse, + UpdateManagedUserRequest, + UserQueryKeys, + deleteUser, + getUser, + getUserGroups, + toManagementErrorMessage, + updateUser, +} from '@thunderid/browser'; import {FC, ReactElement} from 'react'; import BaseUserDetails, {BaseUserDetailsProps} from './BaseUserDetails'; -import useDeleteUser from '../../../hooks/management/users/useDeleteUser'; -import useGetUser from '../../../hooks/management/users/useGetUser'; -import useGetUserGroups from '../../../hooks/management/users/useGetUserGroups'; -import useUpdateUser from '../../../hooks/management/users/useUpdateUser'; +import useManagementEndpoint, {ManagementEndpoint} from '../../../hooks/management/useManagementEndpoint'; +import useResourceMutation, {ResourceMutationResult} from '../../../hooks/management/useResourceMutation'; +import useResourceQuery, {ResourceQueryResult} from '../../../hooks/management/useResourceQuery'; import useTranslation from '../../../hooks/useTranslation'; +interface UpdateUserVariables { + data: UpdateManagedUserRequest; + userId: string; +} + export type UserDetailsProps = Omit< BaseUserDetailsProps, 'error' | 'groups' | 'isDeleting' | 'isLoading' | 'isUpdating' | 'onDelete' | 'onUpdate' | 'user' @@ -48,10 +63,26 @@ const UserDetails: FC = ({ ...rest }: UserDetailsProps): ReactElement => { const {t} = useTranslation(preferences?.i18n); - const user = useGetUser(userId); - const groups = useGetUserGroups(userId, {limit: 100}); - const deletion = useDeleteUser({onSuccess: onDeleted}); - const update = useUpdateUser(); + const endpoint: ManagementEndpoint = useManagementEndpoint('users'); + const user: ResourceQueryResult = useResourceQuery( + [UserQueryKeys.USER, userId], + (fetcher) => getUser({...endpoint, fetcher, userId}), + {enabled: Boolean(userId)}, + ); + const groups: ResourceQueryResult = useResourceQuery( + [GroupQueryKeys.MEMBERSHIPS, 'user', userId, {limit: 100}], + (fetcher) => getUserGroups({...endpoint, fetcher, limit: 100, userId}), + {enabled: Boolean(userId)}, + ); + const deletion: ResourceMutationResult = useResourceMutation( + (id: string, fetcher) => deleteUser({...endpoint, fetcher, userId: id}), + () => [[UserQueryKeys.USERS]], + {onSuccess: onDeleted}, + ); + const update: ResourceMutationResult = useResourceMutation( + ({data, userId: id}: UpdateUserVariables, fetcher) => updateUser({...endpoint, fetcher, payload: data, userId: id}), + ({userId: id}: UpdateUserVariables) => [[UserQueryKeys.USER, id], [UserQueryKeys.USERS]], + ); return ( = ({ {label: t('admin.users.list.search.username'), value: 'username'}, {label: t('admin.users.list.search.email'), value: 'email'}, ]; - const {data, error, isLoading} = useGetUsers({ - filter: filter ?? (search ? toEqualityFilter(search.attribute, search.value) : undefined), - limit, - offset, - }); + const endpoint: ManagementEndpoint = useManagementEndpoint('users'); + const queryFilter: string | undefined = + filter ?? (search ? toEqualityFilter(search.attribute, search.value) : undefined); + const {data, error, isLoading}: ResourceQueryResult = useResourceQuery( + [UserQueryKeys.USERS, {filter: queryFilter, limit, offset}], + (fetcher) => getUsers({...endpoint, fetcher, filter: queryFilter, limit, offset}), + ); return ( const renderWithProviders = ( ui: ReactElement, - {fetcher, ouId}: {fetcher?: ApiFetcher; ouId?: string} = {}, + { + endpoints, + fetcher, + ouId, + }: {endpoints?: ThunderIDContextProps['endpoints']; fetcher?: ApiFetcher; ouId?: string} = {}, ): ReturnType => { const thunderID = { baseUrl, - endpoints: undefined, + endpoints, http: {fetcher}, instanceId: 0, invalidator: createResourceInvalidator(), @@ -400,7 +414,7 @@ describe('RolePermissions', () => { renderWithProviders(, {fetcher}); expect((await screen.findByRole('list', {name: 'rs-1'})).textContent).toContain('system:user:view'); - expect(fetcher.mock.calls[0][0]).toBe(`${baseUrl}/roles/r-1`); + expect(fetcher.mock.calls[0][0]).toBe(`${baseUrl}/roles/r-1?include=display`); }); }); @@ -657,3 +671,619 @@ describe('search and filters', () => { expect(onSearch).toHaveBeenCalledWith(null); }); }); + +describe('connected data loading', () => { + const emptyPage: Record = { + agents: [], + assignments: [], + count: 0, + groups: [], + members: [], + roles: [], + startIndex: 1, + totalResults: 0, + users: [], + }; + + // Answers each management request with a body shaped for the resource it asks for. + const createRoutingFetcher = (): FetcherMock => + vi.fn().mockImplementation((url: string, init?: RequestInit) => { + const {pathname} = new URL(url); + + if (init?.method === 'DELETE') { + return Promise.resolve(respond(null, 204)); + } + if (pathname.endsWith('/members')) { + return Promise.resolve( + respond({ + ...emptyPage, + count: 1, + members: [{display: 'Aiko Tanaka', id: 'u-1', type: 'user'}], + totalResults: 1, + }), + ); + } + if (pathname.endsWith('/assignments')) { + return Promise.resolve( + respond({...emptyPage, assignments: [{display: 'Deploy bot', id: 'ag-1', type: 'agent'}], count: 1}), + ); + } + if (pathname === '/users/u-1') { + return Promise.resolve(respond(users[0])); + } + if (pathname === '/agents/ag-1') { + return Promise.resolve(respond({id: 'ag-1', name: 'Deploy bot', ouId: 'ou-1', type: 'default'})); + } + if (init?.method === 'POST' && pathname === '/groups') { + return Promise.resolve(respond({id: 'g-9', name: 'Support', ouId: 'ou-1'}, 201)); + } + + return Promise.resolve(respond(emptyPage)); + }); + + const requestedPaths = (fetcher: FetcherMock): string[] => + fetcher.mock.calls.map(([url]: [string, RequestInit]) => { + const {pathname, search} = new URL(url); + + return `${pathname}${search}`; + }); + + it('loads groups and roles from their collections', async () => { + const fetcher: FetcherMock = createRoutingFetcher(); + + renderWithProviders( + <> + + + , + {fetcher}, + ); + + await waitFor(() => + expect(requestedPaths(fetcher)).toEqual( + expect.arrayContaining([ + '/groups?include=display&limit=10&offset=0', + '/roles?include=display&limit=10&offset=0', + ]), + ), + ); + }); + + it('targets the groups endpoint override when the provider sets one', async () => { + const fetcher: FetcherMock = createRoutingFetcher(); + + renderWithProviders(, { + endpoints: {groups: 'https://rs.example.com/groups'}, + fetcher, + }); + + await screen.findByText('Aiko Tanaka'); + expect(fetcher.mock.calls[0][0]).toBe('https://rs.example.com/groups/g-1/members?include=display&limit=100'); + }); + + it('waits for an identifier before fetching a resource', () => { + const fetcher: FetcherMock = createRoutingFetcher(); + + renderWithProviders( + <> + + + , + {fetcher}, + ); + + expect(fetcher).not.toHaveBeenCalled(); + }); + + it('refetches members, memberships and role assignments after removing a group member', async () => { + const fetcher: FetcherMock = createRoutingFetcher(); + + renderWithProviders( + <> + + + + , + {fetcher}, + ); + + const remove: HTMLElement = await screen.findByRole('button', {name: 'Remove Aiko Tanaka from the group'}); + await screen.findByRole('heading', {name: 'Aiko Tanaka'}); + await screen.findByText('Deploy bot'); + fetcher.mockClear(); + + fireEvent.click(remove); + + await waitFor(() => + expect(requestedPaths(fetcher)).toEqual( + expect.arrayContaining([ + '/groups/g-1/members/remove', + '/groups/g-1/members?include=display&limit=100', + '/users/u-1/groups?limit=100', + '/roles/r-1/assignments?include=display&limit=100', + ]), + ), + ); + // The user itself is not part of the membership change. + expect(requestedPaths(fetcher)).not.toContain('/users/u-1?include=display'); + }); + + it('refetches role assignments and agent roles after unassigning a role', async () => { + const fetcher: FetcherMock = createRoutingFetcher(); + + renderWithProviders( + <> + + + , + {fetcher}, + ); + + const remove: HTMLElement = await screen.findByRole('button', {name: 'Remove the role from Deploy bot'}); + await waitFor(() => expect(requestedPaths(fetcher)).toContain('/agents/ag-1/roles?limit=100')); + fetcher.mockClear(); + + fireEvent.click(remove); + + await waitFor(() => + expect(requestedPaths(fetcher)).toEqual( + expect.arrayContaining([ + '/roles/r-1/assignments/remove', + '/roles/r-1/assignments?include=display&limit=100', + '/agents/ag-1/roles?limit=100', + ]), + ), + ); + const removal: [string, RequestInit] = fetcher.mock.calls.find(([url]: [string, RequestInit]) => + url.endsWith('/assignments/remove'), + )!; + expect(JSON.parse(removal[1].body as string)).toEqual({assignments: [{id: 'ag-1', type: 'agent'}]}); + }); + + it('refreshes user lists after a user is deleted', async () => { + const fetcher: FetcherMock = createRoutingFetcher(); + const onDeleted: Mock = vi.fn(); + + renderWithProviders( + <> + + + , + {fetcher}, + ); + + fireEvent.click(await screen.findByRole('button', {name: 'Delete user'})); + await waitFor(() => expect(requestedPaths(fetcher)).toContain('/users?include=display&limit=20&offset=0')); + fetcher.mockClear(); + fireEvent.click(screen.getByRole('button', {name: 'Confirm'})); + + await waitFor(() => expect(onDeleted).toHaveBeenCalledOnce()); + expect(fetcher.mock.calls[0]).toEqual([`${baseUrl}/users/u-1`, expect.objectContaining({method: 'DELETE'})]); + await waitFor(() => expect(requestedPaths(fetcher)).toContain('/users?include=display&limit=20&offset=0')); + }); + + it('refreshes group lists after a group is created', async () => { + const fetcher: FetcherMock = createRoutingFetcher(); + const onCreated: Mock = vi.fn(); + + renderWithProviders( + <> + + + , + {fetcher, ouId: 'ou-1'}, + ); + + await waitFor(() => expect(requestedPaths(fetcher)).toContain('/groups?include=display&limit=20&offset=0')); + fetcher.mockClear(); + fireEvent.change(document.querySelector('input[name="name"]')!, {target: {value: 'Support'}}); + fireEvent.click(screen.getByRole('button', {name: 'Create group'})); + + await waitFor(() => + expect(onCreated).toHaveBeenCalledWith(expect.objectContaining({id: 'g-9'}), expect.anything()), + ); + expect(JSON.parse(fetcher.mock.calls[0][1].body as string)).toEqual({name: 'Support', ouId: 'ou-1'}); + await waitFor(() => expect(requestedPaths(fetcher)).toContain('/groups?include=display&limit=20&offset=0')); + }); +}); + +describe('applications', () => { + const application: Application = { + description: 'Customer portal', + id: 'app-1', + inboundAuthConfig: [ + { + config: { + clientId: 'portal-client', + clientSecret: 'stored-secret', + grantTypes: ['authorization_code', 'refresh_token'], + redirectUris: ['https://portal.example/callback'], + responseTypes: ['code'], + }, + type: 'oauth2', + }, + ], + name: 'Portal', + ouId: 'ou-1', + template: 'browser', + url: 'https://portal.example', + }; + + const createApplicationFetcher = (created: Application = application): FetcherMock => + vi.fn().mockImplementation((url: string, init?: RequestInit) => { + const {pathname} = new URL(url); + + if (init?.method === 'DELETE') return Promise.resolve(respond(null, 204)); + if (init?.method === 'POST') return Promise.resolve(respond(created, 201)); + if (init?.method === 'PUT') return Promise.resolve(respond(JSON.parse(init.body as string))); + if (pathname === '/applications/app-1') return Promise.resolve(respond(application)); + + return Promise.resolve( + respond({ + applications: [{clientId: 'portal-client', id: 'app-1', name: 'Portal', template: 'browser'}], + count: 1, + totalResults: 1, + }), + ); + }); + + const paths = (fetcher: FetcherMock): string[] => + fetcher.mock.calls.map(([url, init]: [string, RequestInit]) => { + const {pathname, search} = new URL(url); + + return `${init?.method ?? 'GET'} ${pathname}${search}`; + }); + + it('lists applications with their client ID and type', () => { + renderWithProviders( + , + ); + + const table: HTMLElement = screen.getByRole('table', {name: 'Applications'}); + expect(within(table).getByText('Portal')).toBeTruthy(); + expect(within(table).getByText('portal-client')).toBeTruthy(); + expect(within(table).getByText('browser')).toBeTruthy(); + }); + + it('shows a translated status column only when the server reports a status', () => { + const {unmount} = renderWithProviders( + , + ); + + const table: HTMLElement = screen.getByRole('table', {name: 'Applications'}); + expect(within(table).getByRole('columnheader', {name: 'Status'})).toBeTruthy(); + expect(within(table).getByText('Active')).toBeTruthy(); + expect(within(table).getByText('Suspended')).toBeTruthy(); + unmount(); + + renderWithProviders(); + expect(screen.queryByRole('columnheader', {name: 'Status'})).toBeNull(); + }); + + it('hands a custom list the applications and default columns', () => { + renderWithProviders( + + {({applications: rows, columns, totalResults}): ReactNode => ( +

{`${rows.map((row) => row.name).join()} ${totalResults} ${columns.map((column) => column.id).join()}`}

+ )} +
, + ); + + expect(screen.queryByRole('table')).toBeNull(); + expect(screen.getByText('Portal 9 name,clientId,type')).toBeTruthy(); + }); + + it('fetches a page of applications', async () => { + const fetcher: FetcherMock = createApplicationFetcher(); + + renderWithProviders(, {fetcher}); + + await screen.findByText('portal-client'); + expect(paths(fetcher)).toEqual(['GET /applications?limit=5&offset=0']); + }); + + it('needs a redirect URI for the authorization code grant and submits the OAuth basics', () => { + const onSubmit: Mock = vi.fn(); + + renderWithProviders(); + const submit: HTMLButtonElement = screen.getByRole('button', {name: 'Create application'}); + + fireEvent.change(document.querySelector('input[name="name"]')!, {target: {value: ' Portal '}}); + expect(submit.disabled).toBe(true); + fireEvent.change(document.querySelector('input[name="redirectUris"]')!, { + target: {value: 'https://a.example/cb, https://b.example/cb'}, + }); + expect(submit.disabled).toBe(false); + fireEvent.click(screen.getByLabelText('Public client (no client secret)')); + fireEvent.click(submit); + + expect(onSubmit).toHaveBeenCalledWith({ + description: undefined, + grantTypes: ['authorization_code', 'refresh_token'], + logoUrl: undefined, + name: 'Portal', + publicClient: true, + redirectUris: ['https://a.example/cb', 'https://b.example/cb'], + url: undefined, + }); + }); + + it('refuses the client credentials grant for a public client', () => { + renderWithProviders(); + + fireEvent.change(document.querySelector('input[name="name"]')!, {target: {value: 'Worker'}}); + fireEvent.click(screen.getByLabelText('Authorization code')); + fireEvent.click(screen.getByLabelText('Client credentials')); + expect(screen.getByRole('button', {name: 'Create application'}).disabled).toBe(false); + fireEvent.click(screen.getByLabelText('Public client (no client secret)')); + + expect(screen.getByRole('button', {name: 'Create application'}).disabled).toBe(true); + }); + + it('lets a custom create form set values and submit them', () => { + const onSubmit: Mock = vi.fn(); + let form: BaseApplicationCreateRenderProps | undefined; + + renderWithProviders( + + {(props: BaseApplicationCreateRenderProps): ReactNode => { + form = props; + + return

{props.grantTypeOptions.map((option) => option.label).join(',')}

; + }} +
, + ); + + expect(screen.getByText('Authorization code,Refresh token,Client credentials')).toBeTruthy(); + act(() => { + form!.handleInputChange('name', 'Daemon'); + form!.handleGrantTypesChange(['client_credentials']); + }); + expect(form!.isValid).toBe(true); + act(() => form!.handleSubmit()); + + expect(onSubmit).toHaveBeenCalledWith( + expect.objectContaining({grantTypes: ['client_credentials'], name: 'Daemon'}), + ); + }); + + it('creates the application and shows the returned client secret once before reporting it', async () => { + const fetcher: FetcherMock = createApplicationFetcher({ + ...application, + id: 'app-9', + inboundAuthConfig: [{config: {clientSecret: 's3cr3t', grantTypes: [], responseTypes: []}, type: 'oauth2'}], + }); + const onCreated: Mock = vi.fn(); + + renderWithProviders( + <> + + + , + {fetcher, ouId: 'ou-1'}, + ); + + await screen.findByText('portal-client'); + fetcher.mockClear(); + fireEvent.change(document.querySelector('input[name="name"]')!, {target: {value: 'Portal'}}); + fireEvent.change(document.querySelector('input[name="redirectUris"]')!, {target: {value: 'https://p.example/cb'}}); + fireEvent.click(screen.getByRole('button', {name: 'Create application'})); + + expect(await screen.findByDisplayValue('s3cr3t')).toBeTruthy(); + expect(onCreated).not.toHaveBeenCalled(); + const [url, init] = fetcher.mock.calls[0]; + expect(url).toBe(`${baseUrl}/applications`); + expect(init.method).toBe('POST'); + expect(JSON.parse(init.body as string)).toEqual({ + inboundAuthConfig: [ + { + config: { + grantTypes: ['authorization_code', 'refresh_token'], + publicClient: false, + redirectUris: ['https://p.example/cb'], + responseTypes: ['code'], + tokenEndpointAuthMethod: 'client_secret_basic', + }, + type: 'oauth2', + }, + ], + name: 'Portal', + ouId: 'ou-1', + }); + // The list refetches after the create. + await waitFor(() => expect(paths(fetcher)).toContain('GET /applications?limit=20&offset=0')); + + fireEvent.click(screen.getByRole('button', {name: 'Done'})); + + expect(onCreated).toHaveBeenCalledWith(expect.objectContaining({id: 'app-9'})); + expect(screen.queryByDisplayValue('s3cr3t')).toBeNull(); + expect(document.querySelector('input[name="name"]')!.value).toBe(''); + }); + + it('shows the details and OAuth configuration without the stored secret, and offers no actions by default', () => { + renderWithProviders(); + + expect(screen.getByRole('heading', {name: 'Portal'})).toBeTruthy(); + expect(screen.getByRole('list', {name: 'Redirect URIs'}).textContent).toContain('https://portal.example/callback'); + expect(screen.getByRole('list', {name: 'Grant types'}).textContent).toContain('Authorization code'); + expect(document.body.innerHTML).not.toContain('stored-secret'); + expect(screen.queryByRole('button', {name: 'Edit'})).toBeNull(); + expect(screen.queryByRole('button', {name: 'Delete application'})).toBeNull(); + expect(screen.queryByRole('button', {name: 'Regenerate client secret'})).toBeNull(); + }); + + it('asks for confirmation before deleting or regenerating the secret', () => { + const onDelete: Mock = vi.fn(); + const onRegenerateSecret: Mock = vi.fn(); + + renderWithProviders( + , + ); + + fireEvent.click(screen.getByRole('button', {name: 'Regenerate client secret'})); + expect(onRegenerateSecret).not.toHaveBeenCalled(); + const regenerate: HTMLElement = screen.getByRole('group', {name: /Regenerate the client secret of Portal/}); + fireEvent.click(within(regenerate).getByRole('button', {name: 'Confirm'})); + expect(onRegenerateSecret).toHaveBeenCalledOnce(); + + fireEvent.click(screen.getByRole('button', {name: 'Delete application'})); + expect(onDelete).not.toHaveBeenCalled(); + fireEvent.click(within(screen.getByRole('group', {name: 'Delete Portal?'})).getByRole('button', {name: 'Confirm'})); + expect(onDelete).toHaveBeenCalledOnce(); + }); + + it('offers no secret regeneration for public clients and no actions for read-only applications', () => { + const publicApplication: Application = { + ...application, + inboundAuthConfig: [{config: {grantTypes: [], publicClient: true, responseTypes: []}, type: 'oauth2'}], + }; + const {unmount} = renderWithProviders( + , + ); + + expect(screen.queryByRole('button', {name: 'Regenerate client secret'})).toBeNull(); + unmount(); + + renderWithProviders( + , + ); + expect(screen.queryByRole('button', {name: 'Edit'})).toBeNull(); + expect(screen.queryByRole('button', {name: 'Delete application'})).toBeNull(); + }); + + it('edits the general fields, redirect URIs and grant types', () => { + const onUpdate: Mock = vi.fn(); + + renderWithProviders(); + fireEvent.click(screen.getByRole('button', {name: 'Edit'})); + fireEvent.change(document.querySelector('input[name="name"]')!, {target: {value: 'Portal 2'}}); + fireEvent.change(document.querySelector('input[name="redirectUris"]')!, { + target: {value: 'https://portal.example/callback, https://portal.example/alt'}, + }); + fireEvent.click(screen.getByLabelText('Refresh token')); + fireEvent.click(screen.getByRole('button', {name: 'Save changes'})); + + expect(onUpdate).toHaveBeenCalledWith({ + description: 'Customer portal', + grantTypes: ['authorization_code'], + logoUrl: undefined, + name: 'Portal 2', + redirectUris: ['https://portal.example/callback', 'https://portal.example/alt'], + url: 'https://portal.example', + }); + }); + + it('shows a regenerated secret once and hands custom details only the allowed actions', () => { + const onSecretDismiss: Mock = vi.fn(); + const {unmount} = renderWithProviders( + , + ); + + expect(screen.getByDisplayValue('fresh')).toBeTruthy(); + fireEvent.click(screen.getByRole('button', {name: 'Done'})); + expect(onSecretDismiss).toHaveBeenCalledOnce(); + unmount(); + + let details: BaseApplicationDetailsRenderProps | undefined; + renderWithProviders( + + {(props: BaseApplicationDetailsRenderProps): ReactNode => { + details = props; + + return

{props.oauth?.clientId}

; + }} +
, + ); + + expect(screen.getByText('portal-client')).toBeTruthy(); + expect(details!.oauth).not.toHaveProperty('clientSecret'); + expect(details!.onDelete).toBeTypeOf('function'); + expect(details!.onUpdate).toBeUndefined(); + expect(details!.onRegenerateSecret).toBeUndefined(); + }); + + it('loads the application and saves an edit as a full update', async () => { + const fetcher: FetcherMock = createApplicationFetcher(); + + renderWithProviders(, {fetcher}); + + fireEvent.click(await screen.findByRole('button', {name: 'Edit'})); + expect(paths(fetcher)).toEqual(['GET /applications/app-1']); + fireEvent.change(document.querySelector('input[name="name"]')!, {target: {value: 'Portal 2'}}); + fireEvent.click(screen.getByRole('button', {name: 'Save changes'})); + + await waitFor(() => expect(paths(fetcher)).toContain('PUT /applications/app-1')); + const [, init] = fetcher.mock.calls.find(([, request]: [string, RequestInit]) => request?.method === 'PUT')!; + const {id, ...rest} = application; + void id; + expect(JSON.parse(init.body as string)).toEqual({...rest, name: 'Portal 2'}); + // The application refetches after the update. + await waitFor(() => expect(paths(fetcher).filter((path) => path === 'GET /applications/app-1')).toHaveLength(2)); + }); + + it('regenerates the client secret and shows the new one', async () => { + const fetcher: FetcherMock = createApplicationFetcher(); + + renderWithProviders(, {fetcher}); + + fireEvent.click(await screen.findByRole('button', {name: 'Regenerate client secret'})); + fireEvent.click(screen.getByRole('button', {name: 'Confirm'})); + + const put: [string, RequestInit] = await waitFor(() => { + const call = fetcher.mock.calls.find(([, request]: [string, RequestInit]) => request?.method === 'PUT'); + expect(call).toBeTruthy(); + + return call!; + }); + expect(put[0]).toBe(`${baseUrl}/applications/app-1`); + const saved: Application = JSON.parse(put[1].body as string) as Application; + const secret: string = saved.inboundAuthConfig![0].config.clientSecret!; + expect(secret).not.toBe('stored-secret'); + expect(await screen.findByDisplayValue(secret)).toBeTruthy(); + + fireEvent.click(screen.getByRole('button', {name: 'Done'})); + expect(screen.queryByDisplayValue(secret)).toBeNull(); + }); + + it('deletes the application and refreshes application lists', async () => { + const fetcher: FetcherMock = createApplicationFetcher(); + const onDeleted: Mock = vi.fn(); + + renderWithProviders( + <> + + + , + {fetcher}, + ); + + fireEvent.click(await screen.findByRole('button', {name: 'Delete application'})); + await within(screen.getByRole('table', {name: 'Applications'})).findByText('portal-client'); + fetcher.mockClear(); + fireEvent.click(screen.getByRole('button', {name: 'Confirm'})); + + await waitFor(() => expect(onDeleted).toHaveBeenCalledOnce()); + expect(fetcher.mock.calls[0]).toEqual([ + `${baseUrl}/applications/app-1`, + expect.objectContaining({method: 'DELETE'}), + ]); + await waitFor(() => expect(paths(fetcher)).toContain('GET /applications?limit=20&offset=0')); + }); + + it('shows a translated message instead of the server text when the server refuses', async () => { + const fetcher: FetcherMock = vi + .fn() + .mockResolvedValue(respond({code: 'APP-1', message: 'raw server detail'}, 403)); + + renderWithProviders(, {fetcher}); + + expect((await screen.findByRole('alert')).textContent).not.toContain('raw server detail'); + }); +}); diff --git a/packages/react/src/components/admin/shared/ApplicationFormFields.tsx b/packages/react/src/components/admin/shared/ApplicationFormFields.tsx new file mode 100644 index 00000000..e2d12c1c --- /dev/null +++ b/packages/react/src/components/admin/shared/ApplicationFormFields.tsx @@ -0,0 +1,104 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {APPLICATION_GRANT_TYPES, Preferences} from '@thunderid/browser'; +import {ChangeEvent, FC, ReactElement} from 'react'; +import useAdminPartStyles from './useAdminPartStyles'; +import useTranslation from '../../../hooks/useTranslation'; +import Checkbox from '../../primitives/Checkbox/Checkbox'; +import TextField from '../../primitives/TextField/TextField'; + +/** + * The text fields of the application forms. + */ +export type ApplicationTextField = 'description' | 'logoUrl' | 'name' | 'redirectUris' | 'url'; + +/** + * The values of the application forms. `redirectUris` is the text of the field, URIs separated by + * commas. + */ +export interface ApplicationFormValues { + description: string; + grantTypes: string[]; + logoUrl: string; + name: string; + publicClient: boolean; + redirectUris: string; + url: string; +} + +export interface ApplicationFormFieldsProps { + onGrantTypesChange: (grantTypes: string[]) => void; + onInputChange: (field: ApplicationTextField, value: string) => void; + /** + * Shows the public client choice. Omit it where the client type cannot change. + */ + onPublicClientChange?: (publicClient: boolean) => void; + preferences?: Preferences; + values: ApplicationFormValues; +} + +/** + * The general fields and OAuth basics of an application, shared by the create form and the edit form. + */ +const ApplicationFormFields: FC = ({ + onGrantTypesChange, + onInputChange, + onPublicClientChange, + preferences, + values, +}: ApplicationFormFieldsProps): ReactElement => { + const styles: Record = useAdminPartStyles(); + const {t} = useTranslation(preferences?.i18n); + + const text = (field: ApplicationTextField, label: string, extra: {helperText?: string; required?: boolean} = {}) => ( + ): void => onInputChange(field, event.target.value)} + {...extra} + /> + ); + + return ( + <> + {text('name', t('admin.applications.fields.name'), {required: true})} + {text('description', t('admin.applications.fields.description'))} + {text('url', t('admin.applications.fields.url'))} + {text('logoUrl', t('admin.applications.fields.logo_url'))} + {text('redirectUris', t('admin.applications.fields.redirect_uris'), { + helperText: t('admin.applications.fields.redirect_uris_hint'), + })} +
+ {t('admin.applications.fields.grant_types')} + {APPLICATION_GRANT_TYPES.map((grantType: string) => ( + ): void => + onGrantTypesChange( + event.target.checked + ? [...values.grantTypes, grantType] + : values.grantTypes.filter((value: string) => value !== grantType), + ) + } + /> + ))} +
+ {onPublicClientChange && ( + ): void => onPublicClientChange(event.target.checked)} + /> + )} + + ); +}; + +export default ApplicationFormFields; diff --git a/packages/react/src/components/admin/shared/ClientSecretNotice.tsx b/packages/react/src/components/admin/shared/ClientSecretNotice.tsx new file mode 100644 index 00000000..70d7112e --- /dev/null +++ b/packages/react/src/components/admin/shared/ClientSecretNotice.tsx @@ -0,0 +1,47 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {Preferences} from '@thunderid/browser'; +import {FC, ReactElement} from 'react'; +import useAdminPartStyles from './useAdminPartStyles'; +import useTranslation from '../../../hooks/useTranslation'; +import Alert from '../../primitives/Alert/Alert'; +import Button from '../../primitives/Button/Button'; +import CopyableText from '../../primitives/CopyableText/CopyableText'; + +export interface ClientSecretNoticeProps { + /** + * Called once the secret has been copied, after which it cannot be read again. + */ + onDismiss?: () => void; + preferences?: Preferences; + secret: string; +} + +/** + * Shows a client secret the one time it can be read, masked until revealed, with a copy button. + */ +const ClientSecretNotice: FC = ({ + onDismiss, + preferences, + secret, +}: ClientSecretNoticeProps): ReactElement => { + const styles: Record = useAdminPartStyles(); + const {t} = useTranslation(preferences?.i18n); + + return ( +
+ + {t('admin.applications.secret.notice')} + + + {onDismiss && ( +
+ +
+ )} +
+ ); +}; + +export default ClientSecretNotice; diff --git a/packages/react/src/hooks/management/__tests__/managementHooks.test.tsx b/packages/react/src/hooks/management/__tests__/managementHooks.test.tsx index db5bc800..c312feb6 100644 --- a/packages/react/src/hooks/management/__tests__/managementHooks.test.tsx +++ b/packages/react/src/hooks/management/__tests__/managementHooks.test.tsx @@ -2,16 +2,31 @@ // SPDX-License-Identifier: Apache-2.0 import {act, cleanup, renderHook, waitFor} from '@testing-library/react'; -import {createResourceInvalidator, ApiFetcher} from '@thunderid/browser'; +import { + Application, + ApplicationListResponse, + ApplicationQueryKeys, + ApiFetcher, + CreateApplicationRequest, + ManagedUserListResponse, + UpdateApplicationRequest, + UserQueryKeys, + createApplication, + createResourceInvalidator, + deleteAgent, + getApplication, + getApplications, + getOrganizationUnitUsers, + getUsers, + updateApplication, +} from '@thunderid/browser'; import {ReactElement, ReactNode} from 'react'; import {afterEach, describe, expect, it, vi, type Mock} from 'vitest'; import ThunderIDContext, {ThunderIDContextProps} from '../../../contexts/ThunderID/ThunderIDContext'; -import useDeleteAgent from '../agents/useDeleteAgent'; -import useCreateApplication from '../applications/useCreateApplication'; -import useGetApplication from '../applications/useGetApplication'; -import useGetApplications from '../applications/useGetApplications'; -import useUpdateApplication from '../applications/useUpdateApplication'; -import useGetUsers from '../users/useGetUsers'; +import useManagementEndpoint, {ManagementEndpoint} from '../useManagementEndpoint'; +import useManagementRequestConfig from '../useManagementRequestConfig'; +import useResourceMutation, {ResourceMutationOptions, ResourceMutationResult} from '../useResourceMutation'; +import useResourceQuery, {ResourceQueryOptions, ResourceQueryResult} from '../useResourceQuery'; type FetcherMock = Mock; @@ -49,16 +64,85 @@ const createWrapper = ({ return Wrapper; }; +interface UpdateApplicationVariables { + applicationId: string; + data: UpdateApplicationRequest; +} + +// The admin components call the management API functions through the generic query layer this way. +const useApplications = (limit?: number): ResourceQueryResult => { + const endpoint: ManagementEndpoint = useManagementEndpoint('applications'); + + return useResourceQuery([ApplicationQueryKeys.APPLICATIONS, {limit}], (fetcher: ApiFetcher) => + getApplications({...endpoint, fetcher, limit}), + ); +}; + +const useApplication = (applicationId: string | undefined): ResourceQueryResult => { + const endpoint: ManagementEndpoint = useManagementEndpoint('applications'); + + return useResourceQuery( + [ApplicationQueryKeys.APPLICATION, applicationId], + (fetcher: ApiFetcher) => getApplication({...endpoint, applicationId: applicationId!, fetcher}), + {enabled: Boolean(applicationId)}, + ); +}; + +const useUsers = (options?: ResourceQueryOptions): ResourceQueryResult => { + const endpoint: ManagementEndpoint = useManagementEndpoint('users'); + + return useResourceQuery( + [UserQueryKeys.USERS, {}], + (fetcher: ApiFetcher) => getUsers({...endpoint, fetcher}), + options, + ); +}; + +const useUpdateApplication = ( + options?: ResourceMutationOptions, +): ResourceMutationResult => { + const endpoint: ManagementEndpoint = useManagementEndpoint('applications'); + + return useResourceMutation( + ({applicationId, data}: UpdateApplicationVariables, fetcher: ApiFetcher) => + updateApplication({...endpoint, applicationId, fetcher, payload: data}), + ({applicationId}: UpdateApplicationVariables) => [ + [ApplicationQueryKeys.APPLICATION, applicationId], + [ApplicationQueryKeys.APPLICATIONS], + ], + options, + ); +}; + +const useDeleteAgent = (options?: ResourceMutationOptions): ResourceMutationResult => { + const endpoint: ManagementEndpoint = useManagementEndpoint('agents'); + + return useResourceMutation( + (agentId: string, fetcher: ApiFetcher) => deleteAgent({...endpoint, agentId, fetcher}), + () => [], + options, + ); +}; + +const useCreateApplication = (): ResourceMutationResult => { + const endpoint: ManagementEndpoint = useManagementEndpoint('applications'); + + return useResourceMutation( + (payload: CreateApplicationRequest, fetcher: ApiFetcher) => createApplication({...endpoint, fetcher, payload}), + () => [[ApplicationQueryKeys.APPLICATIONS]], + ); +}; + afterEach(() => { cleanup(); }); -describe('management query hooks', () => { +describe('useResourceQuery with the management API functions', () => { it('loads data through the provider fetcher', async () => { const page = {applications: [{id: 'app-1', name: 'App'}], count: 1, totalResults: 1}; const fetcher: FetcherMock = createFetcher(page); - const {result} = renderHook(() => useGetApplications({limit: 10}), {wrapper: createWrapper({fetcher})}); + const {result} = renderHook(() => useApplications(10), {wrapper: createWrapper({fetcher})}); expect(result.current.isLoading).toBe(true); await waitFor(() => expect(result.current.isLoading).toBe(false)); @@ -67,11 +151,11 @@ describe('management query hooks', () => { expect(fetcher.mock.calls[0][0]).toBe(`${baseUrl}/applications?limit=10`); }); - it('prefers a per-hook fetcher over the provider fetcher', async () => { + it('prefers a per-query fetcher over the provider fetcher', async () => { const providerFetcher: FetcherMock = createFetcher(); const hookFetcher: FetcherMock = createFetcher({count: 0, startIndex: 1, totalResults: 0, users: []}); - const {result} = renderHook(() => useGetUsers({}, {fetcher: hookFetcher}), { + const {result} = renderHook(() => useUsers({fetcher: hookFetcher}), { wrapper: createWrapper({fetcher: providerFetcher}), }); @@ -83,7 +167,7 @@ describe('management query hooks', () => { it('targets the endpoints override when the provider sets one', async () => { const fetcher: FetcherMock = createFetcher({id: 'app-1'}); - const {result} = renderHook(() => useGetApplication('app-1'), { + const {result} = renderHook(() => useApplication('app-1'), { wrapper: createWrapper({endpoints: {applications: 'https://rs.example.com/applications'}, fetcher}), }); @@ -94,7 +178,7 @@ describe('management query hooks', () => { it('exposes a failed request as an error with a distinct code', async () => { const fetcher: FetcherMock = createFetcher({}, 404); - const {result} = renderHook(() => useGetApplication('missing'), {wrapper: createWrapper({fetcher})}); + const {result} = renderHook(() => useApplication('missing'), {wrapper: createWrapper({fetcher})}); await waitFor(() => expect(result.current.error).not.toBeNull()); expect(result.current.error).toMatchObject({code: 'getApplication-NotFoundError-001'}); @@ -104,14 +188,14 @@ describe('management query hooks', () => { it('does not fetch a single resource until its identifier is known', () => { const fetcher: FetcherMock = createFetcher(); - const {result} = renderHook(() => useGetApplication(undefined), {wrapper: createWrapper({fetcher})}); + const {result} = renderHook(() => useApplication(undefined), {wrapper: createWrapper({fetcher})}); expect(result.current.isLoading).toBe(false); expect(fetcher).not.toHaveBeenCalled(); }); }); -describe('management mutation hooks', () => { +describe('useResourceMutation with the management API functions', () => { it('refetches matching queries after a successful mutation and calls onSuccess', async () => { const fetcher: FetcherMock = createFetcher({applications: [], count: 0, id: 'app-1', totalResults: 0}); const onSuccess: Mock = vi.fn(); @@ -119,9 +203,9 @@ describe('management mutation hooks', () => { const {result} = renderHook( () => ({ - detail: useGetApplication('app-1'), - list: useGetApplications(), - otherDetail: useGetApplication('app-2'), + detail: useApplication('app-1'), + list: useApplications(), + otherDetail: useApplication('app-2'), update: useUpdateApplication({onSuccess}), }), {wrapper}, @@ -178,3 +262,56 @@ describe('management mutation hooks', () => { expect(fetcher.mock.calls[0][1]).toMatchObject({body: '{"name":"App"}', method: 'POST'}); }); }); + +describe('useManagementRequestConfig', () => { + afterEach(() => { + cleanup(); + }); + + it('hands the API functions the provider base URL and fetcher', async () => { + const fetcher: FetcherMock = createFetcher({applications: [], count: 0, totalResults: 0}); + const {result} = renderHook(() => useManagementRequestConfig('applications'), {wrapper: createWrapper({fetcher})}); + + expect(result.current).toEqual({baseUrl, fetcher, url: undefined}); + + await getApplications(result.current); + + expect(fetcher).toHaveBeenCalledWith(`${baseUrl}/applications`, expect.objectContaining({method: 'GET'})); + }); + + it('targets the endpoints override for its collection', () => { + const {result} = renderHook(() => useManagementRequestConfig('applications'), { + wrapper: createWrapper({ + endpoints: {applications: 'https://mgmt.example.com/api/applications'}, + fetcher: createFetcher(), + }), + }); + + expect(result.current.url).toBe('https://mgmt.example.com/api/applications'); + }); + + it('addresses the newer management collections through their endpoints overrides', async () => { + const fetcher: FetcherMock = createFetcher({organizationUnits: []}); + const {result} = renderHook(() => useManagementRequestConfig('organizationUnits'), { + wrapper: createWrapper({endpoints: {organizationUnits: 'https://mgmt.example.com/ous'}, fetcher}), + }); + + await getOrganizationUnitUsers({...result.current, path: 'engineering/platform'}); + + expect(fetcher).toHaveBeenCalledWith( + 'https://mgmt.example.com/ous/tree/engineering/platform/users?include=display', + expect.objectContaining({method: 'GET'}), + ); + }); + + it('keeps the same config across renders so it can sit in a dependency list', () => { + const {result, rerender} = renderHook(() => useManagementRequestConfig('applications'), { + wrapper: createWrapper({fetcher: createFetcher()}), + }); + const first = result.current; + + rerender(); + + expect(result.current).toBe(first); + }); +}); diff --git a/packages/react/src/hooks/management/__tests__/rolesAndGroupsHooks.test.tsx b/packages/react/src/hooks/management/__tests__/rolesAndGroupsHooks.test.tsx deleted file mode 100644 index 871a450a..00000000 --- a/packages/react/src/hooks/management/__tests__/rolesAndGroupsHooks.test.tsx +++ /dev/null @@ -1,167 +0,0 @@ -// Copyright 2026 The ThunderID Authors -// SPDX-License-Identifier: Apache-2.0 - -import {act, cleanup, renderHook, waitFor} from '@testing-library/react'; -import {createResourceInvalidator, ApiFetcher} from '@thunderid/browser'; -import {ReactElement, ReactNode} from 'react'; -import {afterEach, describe, expect, it, vi, type Mock} from 'vitest'; -import ThunderIDContext, {ThunderIDContextProps} from '../../../contexts/ThunderID/ThunderIDContext'; -import useGetAgentRoles from '../agents/useGetAgentRoles'; -import useAddGroupMembers from '../groups/useAddGroupMembers'; -import useGetGroupMembers from '../groups/useGetGroupMembers'; -import useGetGroups from '../groups/useGetGroups'; -import useAddRoleAssignments from '../roles/useAddRoleAssignments'; -import useGetRoleAssignments from '../roles/useGetRoleAssignments'; -import useGetRoles from '../roles/useGetRoles'; -import useGetUserGroups from '../users/useGetUserGroups'; - -type FetcherMock = Mock; - -const baseUrl = 'https://localhost:8090'; - -const createFetcher = (body: unknown = {}): FetcherMock => - vi.fn().mockImplementation(() => - Promise.resolve({ - json: () => Promise.resolve(body), - ok: true, - status: 200, - statusText: 'OK', - text: () => Promise.resolve(JSON.stringify(body)), - } as Response), - ); - -const createWrapper = ({ - fetcher, - ...overrides -}: Partial & {fetcher?: ApiFetcher} = {}): ((props: {children: ReactNode}) => ReactElement) => { - const value = { - baseUrl, - endpoints: undefined, - http: {fetcher}, - instanceId: 0, - invalidator: createResourceInvalidator(), - ...overrides, - } as unknown as ThunderIDContextProps; - - function Wrapper({children}: {children: ReactNode}): ReactElement { - return {children}; - } - - return Wrapper; -}; - -const requestedUrls = (fetcher: FetcherMock): string[] => fetcher.mock.calls.map(([url]: [string, RequestInit]) => url); - -afterEach(() => { - cleanup(); -}); - -describe('role and group query hooks', () => { - it('loads roles and groups from their collections', async () => { - const fetcher: FetcherMock = createFetcher({count: 0, groups: [], roles: [], startIndex: 1, totalResults: 0}); - - const {result} = renderHook(() => ({groups: useGetGroups({limit: 10}), roles: useGetRoles({limit: 10})}), { - wrapper: createWrapper({fetcher}), - }); - - await waitFor(() => expect(result.current.roles.isLoading).toBe(false)); - await waitFor(() => expect(result.current.groups.isLoading).toBe(false)); - expect(requestedUrls(fetcher)).toEqual( - expect.arrayContaining([`${baseUrl}/roles?limit=10`, `${baseUrl}/groups?include=display&limit=10`]), - ); - }); - - it('targets the groups endpoints override when the provider sets one', async () => { - const fetcher: FetcherMock = createFetcher({count: 0, members: [], startIndex: 1, totalResults: 0}); - - const {result} = renderHook(() => useGetGroupMembers('g-1'), { - wrapper: createWrapper({endpoints: {groups: 'https://rs.example.com/groups'}, fetcher}), - }); - - await waitFor(() => expect(result.current.isLoading).toBe(false)); - expect(fetcher.mock.calls[0][0]).toBe('https://rs.example.com/groups/g-1/members?include=display'); - }); - - it('waits for an identifier before fetching sub-resources', () => { - const fetcher: FetcherMock = createFetcher(); - - const {result} = renderHook( - () => ({assignments: useGetRoleAssignments(undefined), memberships: useGetUserGroups(undefined)}), - {wrapper: createWrapper({fetcher})}, - ); - - expect(result.current.assignments.isLoading).toBe(false); - expect(result.current.memberships.isLoading).toBe(false); - expect(fetcher).not.toHaveBeenCalled(); - }); -}); - -describe('role and group mutation hooks', () => { - it('refetches assignment lists and agent roles after assigning a role', async () => { - const fetcher: FetcherMock = createFetcher({assignments: [], count: 0, roles: [], startIndex: 1, totalResults: 0}); - - const {result} = renderHook( - () => ({ - add: useAddRoleAssignments(), - agentRoles: useGetAgentRoles('ag-1'), - assignments: useGetRoleAssignments('r-1'), - }), - {wrapper: createWrapper({fetcher})}, - ); - - await waitFor(() => expect(result.current.assignments.isLoading).toBe(false)); - await waitFor(() => expect(result.current.agentRoles.isLoading).toBe(false)); - fetcher.mockClear(); - - await act(async () => { - await result.current.add.mutate({assignments: [{id: 'ag-1', type: 'agent'}], roleId: 'r-1'}); - }); - - const urls: string[] = requestedUrls(fetcher); - expect(urls).toContain(`${baseUrl}/roles/r-1/assignments/add`); - await waitFor(() => expect(requestedUrls(fetcher)).toContain(`${baseUrl}/roles/r-1/assignments?include=display`)); - await waitFor(() => expect(requestedUrls(fetcher)).toContain(`${baseUrl}/agents/ag-1/roles`)); - }); - - it('refetches members, memberships and inherited roles after adding group members', async () => { - const fetcher: FetcherMock = createFetcher({ - assignments: [], - count: 0, - groups: [], - id: 'g-1', - members: [], - startIndex: 1, - totalResults: 0, - }); - - const {result} = renderHook( - () => ({ - add: useAddGroupMembers(), - assignments: useGetRoleAssignments('r-1'), - members: useGetGroupMembers('g-1'), - memberships: useGetUserGroups('u-1'), - }), - {wrapper: createWrapper({fetcher})}, - ); - - await waitFor(() => expect(result.current.members.isLoading).toBe(false)); - await waitFor(() => expect(result.current.memberships.isLoading).toBe(false)); - await waitFor(() => expect(result.current.assignments.isLoading).toBe(false)); - fetcher.mockClear(); - - await act(async () => { - await result.current.add.mutate({groupId: 'g-1', members: [{id: 'u-1', type: 'user'}]}); - }); - - await waitFor(() => - expect(requestedUrls(fetcher)).toEqual( - expect.arrayContaining([ - `${baseUrl}/groups/g-1/members/add`, - `${baseUrl}/groups/g-1/members?include=display`, - `${baseUrl}/users/u-1/groups`, - `${baseUrl}/roles/r-1/assignments?include=display`, - ]), - ), - ); - }); -}); diff --git a/packages/react/src/hooks/management/agents/useCreateAgent.ts b/packages/react/src/hooks/management/agents/useCreateAgent.ts deleted file mode 100644 index 535887ea..00000000 --- a/packages/react/src/hooks/management/agents/useCreateAgent.ts +++ /dev/null @@ -1,26 +0,0 @@ -// Copyright 2026 The ThunderID Authors -// SPDX-License-Identifier: Apache-2.0 - -import {Agent, AgentQueryKeys, CreateAgentRequest, createAgent} from '@thunderid/browser'; -import useManagementEndpoint, {ManagementEndpoint} from '../useManagementEndpoint'; -import useResourceMutation, {ResourceMutationOptions, ResourceMutationResult} from '../useResourceMutation'; - -/** - * Creates an agent, then refetches any mounted agent lists. - * - * @param options - Mutation options, including `onSuccess`, `onError`, and a per-hook `fetcher`. - * @returns The mutation state. Call `mutate(payload)` to create. - */ -const useCreateAgent = ( - options?: ResourceMutationOptions, -): ResourceMutationResult => { - const endpoint: ManagementEndpoint = useManagementEndpoint('agents'); - - return useResourceMutation( - (payload: CreateAgentRequest, fetcher) => createAgent({...endpoint, fetcher, payload}), - () => [[AgentQueryKeys.AGENTS]], - options, - ); -}; - -export default useCreateAgent; diff --git a/packages/react/src/hooks/management/agents/useDeleteAgent.ts b/packages/react/src/hooks/management/agents/useDeleteAgent.ts deleted file mode 100644 index e0f8eee4..00000000 --- a/packages/react/src/hooks/management/agents/useDeleteAgent.ts +++ /dev/null @@ -1,24 +0,0 @@ -// Copyright 2026 The ThunderID Authors -// SPDX-License-Identifier: Apache-2.0 - -import {AgentQueryKeys, deleteAgent} from '@thunderid/browser'; -import useManagementEndpoint, {ManagementEndpoint} from '../useManagementEndpoint'; -import useResourceMutation, {ResourceMutationOptions, ResourceMutationResult} from '../useResourceMutation'; - -/** - * Deletes an agent, then refetches any mounted agent lists. - * - * @param options - Mutation options, including `onSuccess`, `onError`, and a per-hook `fetcher`. - * @returns The mutation state. Call `mutate(agentId)` to delete. - */ -const useDeleteAgent = (options?: ResourceMutationOptions): ResourceMutationResult => { - const endpoint: ManagementEndpoint = useManagementEndpoint('agents'); - - return useResourceMutation( - (agentId: string, fetcher) => deleteAgent({...endpoint, agentId, fetcher}), - () => [[AgentQueryKeys.AGENTS]], - options, - ); -}; - -export default useDeleteAgent; diff --git a/packages/react/src/hooks/management/agents/useGetAgent.ts b/packages/react/src/hooks/management/agents/useGetAgent.ts deleted file mode 100644 index 74395c36..00000000 --- a/packages/react/src/hooks/management/agents/useGetAgent.ts +++ /dev/null @@ -1,25 +0,0 @@ -// Copyright 2026 The ThunderID Authors -// SPDX-License-Identifier: Apache-2.0 - -import {Agent, AgentQueryKeys, getAgent} from '@thunderid/browser'; -import useManagementEndpoint, {ManagementEndpoint} from '../useManagementEndpoint'; -import useResourceQuery, {ResourceQueryOptions, ResourceQueryResult} from '../useResourceQuery'; - -/** - * Fetches a single agent. Does nothing until `agentId` is set. - * - * @param agentId - Identifier of the agent. - * @param options - Query options, including a per-hook `fetcher`. - * @returns The query state. - */ -const useGetAgent = (agentId: string | undefined, options?: ResourceQueryOptions): ResourceQueryResult => { - const endpoint: ManagementEndpoint = useManagementEndpoint('agents'); - - return useResourceQuery( - [AgentQueryKeys.AGENT, agentId], - (fetcher) => getAgent({...endpoint, agentId: agentId!, fetcher}), - {...options, enabled: Boolean(agentId) && options?.enabled !== false}, - ); -}; - -export default useGetAgent; diff --git a/packages/react/src/hooks/management/agents/useGetAgentGroups.ts b/packages/react/src/hooks/management/agents/useGetAgentGroups.ts deleted file mode 100644 index f0b02d4e..00000000 --- a/packages/react/src/hooks/management/agents/useGetAgentGroups.ts +++ /dev/null @@ -1,30 +0,0 @@ -// Copyright 2026 The ThunderID Authors -// SPDX-License-Identifier: Apache-2.0 - -import {GroupQueryKeys, MembershipGroupListResponse, getAgentGroups} from '@thunderid/browser'; -import useManagementEndpoint, {ManagementEndpoint} from '../useManagementEndpoint'; -import useResourceQuery, {ResourceQueryOptions, ResourceQueryResult} from '../useResourceQuery'; - -/** - * Fetches a page of the groups an agent belongs to. - * - * @param agentId - Identifier of the agent. Fetching waits until it is defined. - * @param params - Pagination parameters. - * @param options - Query options, including a per-hook `fetcher`. - * @returns The query state. - */ -const useGetAgentGroups = ( - agentId: string | undefined, - {limit, offset}: {limit?: number; offset?: number} = {}, - options?: ResourceQueryOptions, -): ResourceQueryResult => { - const endpoint: ManagementEndpoint = useManagementEndpoint('agents'); - - return useResourceQuery( - [GroupQueryKeys.MEMBERSHIPS, 'agent', agentId, {limit, offset}], - (fetcher) => getAgentGroups({...endpoint, fetcher, limit, offset, agentId: agentId!}), - {...options, enabled: Boolean(agentId) && options?.enabled !== false}, - ); -}; - -export default useGetAgentGroups; diff --git a/packages/react/src/hooks/management/agents/useGetAgentRoles.ts b/packages/react/src/hooks/management/agents/useGetAgentRoles.ts deleted file mode 100644 index f0bb2a53..00000000 --- a/packages/react/src/hooks/management/agents/useGetAgentRoles.ts +++ /dev/null @@ -1,31 +0,0 @@ -// Copyright 2026 The ThunderID Authors -// SPDX-License-Identifier: Apache-2.0 - -import {AgentRoleListResponse, RoleQueryKeys, getAgentRoles} from '@thunderid/browser'; -import useManagementEndpoint, {ManagementEndpoint} from '../useManagementEndpoint'; -import useResourceQuery, {ResourceQueryOptions, ResourceQueryResult} from '../useResourceQuery'; - -/** - * Fetches a page of the role names assigned to an agent, directly or through its groups. Refetches - * whenever a role assignment or a group membership changes. - * - * @param agentId - Identifier of the agent. Fetching waits until it is defined. - * @param params - Pagination parameters. - * @param options - Query options, including a per-hook `fetcher`. - * @returns The query state. - */ -const useGetAgentRoles = ( - agentId: string | undefined, - {limit, offset}: {limit?: number; offset?: number} = {}, - options?: ResourceQueryOptions, -): ResourceQueryResult => { - const endpoint: ManagementEndpoint = useManagementEndpoint('agents'); - - return useResourceQuery( - [RoleQueryKeys.ROLE_ASSIGNMENTS, 'agent', agentId, {limit, offset}], - (fetcher) => getAgentRoles({...endpoint, agentId: agentId!, fetcher, limit, offset}), - {...options, enabled: Boolean(agentId) && options?.enabled !== false}, - ); -}; - -export default useGetAgentRoles; diff --git a/packages/react/src/hooks/management/agents/useGetAgentTypes.ts b/packages/react/src/hooks/management/agents/useGetAgentTypes.ts deleted file mode 100644 index f6a64be5..00000000 --- a/packages/react/src/hooks/management/agents/useGetAgentTypes.ts +++ /dev/null @@ -1,28 +0,0 @@ -// Copyright 2026 The ThunderID Authors -// SPDX-License-Identifier: Apache-2.0 - -import {AgentQueryKeys, AgentTypeListResponse, getAgentTypes} from '@thunderid/browser'; -import useManagementEndpoint, {ManagementEndpoint} from '../useManagementEndpoint'; -import useResourceQuery, {ResourceQueryOptions, ResourceQueryResult} from '../useResourceQuery'; - -/** - * Fetches a page of agent types. - * - * @param params - Pagination parameters. - * @param options - Query options, including a per-hook `fetcher`. - * @returns The query state. - */ -const useGetAgentTypes = ( - {limit, offset}: {limit?: number; offset?: number} = {}, - options?: ResourceQueryOptions, -): ResourceQueryResult => { - const endpoint: ManagementEndpoint = useManagementEndpoint('agentTypes'); - - return useResourceQuery( - [AgentQueryKeys.AGENT_TYPES, {limit, offset}], - (fetcher) => getAgentTypes({...endpoint, fetcher, limit, offset}), - options, - ); -}; - -export default useGetAgentTypes; diff --git a/packages/react/src/hooks/management/agents/useGetAgents.ts b/packages/react/src/hooks/management/agents/useGetAgents.ts deleted file mode 100644 index 0f0ebb80..00000000 --- a/packages/react/src/hooks/management/agents/useGetAgents.ts +++ /dev/null @@ -1,40 +0,0 @@ -// Copyright 2026 The ThunderID Authors -// SPDX-License-Identifier: Apache-2.0 - -import {AgentListResponse, AgentQueryKeys, getAgents} from '@thunderid/browser'; -import useManagementEndpoint, {ManagementEndpoint} from '../useManagementEndpoint'; -import useResourceQuery, {ResourceQueryOptions, ResourceQueryResult} from '../useResourceQuery'; - -/** - * Parameters for {@link useGetAgents}. - */ -export interface UseGetAgentsParams { - /** - * Server-side filter on an agent attribute, for example `name eq "Calendar Agent"`. - */ - filter?: string; - limit?: number; - offset?: number; -} - -/** - * Fetches a page of agents. - * - * @param params - Pagination and filter parameters. - * @param options - Query options, including a per-hook `fetcher`. - * @returns The query state. - */ -const useGetAgents = ( - {filter, limit, offset}: UseGetAgentsParams = {}, - options?: ResourceQueryOptions, -): ResourceQueryResult => { - const endpoint: ManagementEndpoint = useManagementEndpoint('agents'); - - return useResourceQuery( - [AgentQueryKeys.AGENTS, {filter, limit, offset}], - (fetcher) => getAgents({...endpoint, fetcher, filter, limit, offset}), - options, - ); -}; - -export default useGetAgents; diff --git a/packages/react/src/hooks/management/agents/useUpdateAgent.ts b/packages/react/src/hooks/management/agents/useUpdateAgent.ts deleted file mode 100644 index 97ef3602..00000000 --- a/packages/react/src/hooks/management/agents/useUpdateAgent.ts +++ /dev/null @@ -1,34 +0,0 @@ -// Copyright 2026 The ThunderID Authors -// SPDX-License-Identifier: Apache-2.0 - -import {Agent, AgentQueryKeys, UpdateAgentRequest, updateAgent} from '@thunderid/browser'; -import useManagementEndpoint, {ManagementEndpoint} from '../useManagementEndpoint'; -import useResourceMutation, {ResourceMutationOptions, ResourceMutationResult} from '../useResourceMutation'; - -/** - * Variables accepted by {@link useUpdateAgent}'s `mutate`. - */ -export interface UpdateAgentVariables { - agentId: string; - data: UpdateAgentRequest; -} - -/** - * Updates an agent, then refetches it and any mounted agent lists. - * - * @param options - Mutation options, including `onSuccess`, `onError`, and a per-hook `fetcher`. - * @returns The mutation state. Call `mutate({agentId, data})` to update. - */ -const useUpdateAgent = ( - options?: ResourceMutationOptions, -): ResourceMutationResult => { - const endpoint: ManagementEndpoint = useManagementEndpoint('agents'); - - return useResourceMutation( - ({agentId, data}: UpdateAgentVariables, fetcher) => updateAgent({...endpoint, agentId, fetcher, payload: data}), - ({agentId}: UpdateAgentVariables) => [[AgentQueryKeys.AGENT, agentId], [AgentQueryKeys.AGENTS]], - options, - ); -}; - -export default useUpdateAgent; diff --git a/packages/react/src/hooks/management/applications/useCreateApplication.ts b/packages/react/src/hooks/management/applications/useCreateApplication.ts deleted file mode 100644 index 8e80cc19..00000000 --- a/packages/react/src/hooks/management/applications/useCreateApplication.ts +++ /dev/null @@ -1,31 +0,0 @@ -// Copyright 2025-2026 The ThunderID Authors -// SPDX-License-Identifier: Apache-2.0 - -import {Application, ApplicationQueryKeys, CreateApplicationRequest, createApplication} from '@thunderid/browser'; -import useManagementEndpoint, {ManagementEndpoint} from '../useManagementEndpoint'; -import useResourceMutation, {ResourceMutationOptions, ResourceMutationResult} from '../useResourceMutation'; - -/** - * Creates an application, then refetches any mounted application lists. - * - * @param options - Mutation options, including `onSuccess`, `onError`, and a per-hook `fetcher`. - * @returns The mutation state. Call `mutate(payload)` to create. - * - * @example - * ```tsx - * const {mutate, isLoading, error} = useCreateApplication({onSuccess: app => navigate(app.id)}); - * ``` - */ -const useCreateApplication = ( - options?: ResourceMutationOptions, -): ResourceMutationResult => { - const endpoint: ManagementEndpoint = useManagementEndpoint('applications'); - - return useResourceMutation( - (payload: CreateApplicationRequest, fetcher) => createApplication({...endpoint, fetcher, payload}), - () => [[ApplicationQueryKeys.APPLICATIONS]], - options, - ); -}; - -export default useCreateApplication; diff --git a/packages/react/src/hooks/management/applications/useDeleteApplication.ts b/packages/react/src/hooks/management/applications/useDeleteApplication.ts deleted file mode 100644 index 57d399af..00000000 --- a/packages/react/src/hooks/management/applications/useDeleteApplication.ts +++ /dev/null @@ -1,26 +0,0 @@ -// Copyright 2025-2026 The ThunderID Authors -// SPDX-License-Identifier: Apache-2.0 - -import {ApplicationQueryKeys, deleteApplication} from '@thunderid/browser'; -import useManagementEndpoint, {ManagementEndpoint} from '../useManagementEndpoint'; -import useResourceMutation, {ResourceMutationOptions, ResourceMutationResult} from '../useResourceMutation'; - -/** - * Deletes an application, then refetches any mounted application lists. - * - * @param options - Mutation options, including `onSuccess`, `onError`, and a per-hook `fetcher`. - * @returns The mutation state. Call `mutate(applicationId)` to delete. - */ -const useDeleteApplication = ( - options?: ResourceMutationOptions, -): ResourceMutationResult => { - const endpoint: ManagementEndpoint = useManagementEndpoint('applications'); - - return useResourceMutation( - (applicationId: string, fetcher) => deleteApplication({...endpoint, applicationId, fetcher}), - () => [[ApplicationQueryKeys.APPLICATIONS]], - options, - ); -}; - -export default useDeleteApplication; diff --git a/packages/react/src/hooks/management/applications/useGetApplication.ts b/packages/react/src/hooks/management/applications/useGetApplication.ts deleted file mode 100644 index 062332ae..00000000 --- a/packages/react/src/hooks/management/applications/useGetApplication.ts +++ /dev/null @@ -1,33 +0,0 @@ -// Copyright 2025-2026 The ThunderID Authors -// SPDX-License-Identifier: Apache-2.0 - -import {Application, ApplicationQueryKeys, getApplication} from '@thunderid/browser'; -import useManagementEndpoint, {ManagementEndpoint} from '../useManagementEndpoint'; -import useResourceQuery, {ResourceQueryOptions, ResourceQueryResult} from '../useResourceQuery'; - -/** - * Fetches a single application. Does nothing until `applicationId` is set. - * - * @param applicationId - Identifier of the application. - * @param options - Query options, including a per-hook `fetcher`. - * @returns The query state. - * - * @example - * ```tsx - * const {data: application} = useGetApplication(applicationId); - * ``` - */ -const useGetApplication = ( - applicationId: string | undefined, - options?: ResourceQueryOptions, -): ResourceQueryResult => { - const endpoint: ManagementEndpoint = useManagementEndpoint('applications'); - - return useResourceQuery( - [ApplicationQueryKeys.APPLICATION, applicationId], - (fetcher) => getApplication({...endpoint, applicationId: applicationId!, fetcher}), - {...options, enabled: Boolean(applicationId) && options?.enabled !== false}, - ); -}; - -export default useGetApplication; diff --git a/packages/react/src/hooks/management/applications/useGetApplications.ts b/packages/react/src/hooks/management/applications/useGetApplications.ts deleted file mode 100644 index c9f82ed6..00000000 --- a/packages/react/src/hooks/management/applications/useGetApplications.ts +++ /dev/null @@ -1,41 +0,0 @@ -// Copyright 2025-2026 The ThunderID Authors -// SPDX-License-Identifier: Apache-2.0 - -import {ApplicationListResponse, ApplicationQueryKeys, getApplications} from '@thunderid/browser'; -import useManagementEndpoint, {ManagementEndpoint} from '../useManagementEndpoint'; -import useResourceQuery, {ResourceQueryOptions, ResourceQueryResult} from '../useResourceQuery'; - -/** - * Parameters for {@link useGetApplications}. - */ -export interface UseGetApplicationsParams { - limit?: number; - offset?: number; -} - -/** - * Fetches a page of applications. - * - * @param params - Pagination parameters. - * @param options - Query options, including a per-hook `fetcher`. - * @returns The query state. - * - * @example - * ```tsx - * const {data, isLoading, error} = useGetApplications({limit: 10}); - * ``` - */ -const useGetApplications = ( - {limit, offset}: UseGetApplicationsParams = {}, - options?: ResourceQueryOptions, -): ResourceQueryResult => { - const endpoint: ManagementEndpoint = useManagementEndpoint('applications'); - - return useResourceQuery( - [ApplicationQueryKeys.APPLICATIONS, {limit, offset}], - (fetcher) => getApplications({...endpoint, fetcher, limit, offset}), - options, - ); -}; - -export default useGetApplications; diff --git a/packages/react/src/hooks/management/applications/useUpdateApplication.ts b/packages/react/src/hooks/management/applications/useUpdateApplication.ts deleted file mode 100644 index 4302bd9c..00000000 --- a/packages/react/src/hooks/management/applications/useUpdateApplication.ts +++ /dev/null @@ -1,38 +0,0 @@ -// Copyright 2025-2026 The ThunderID Authors -// SPDX-License-Identifier: Apache-2.0 - -import {Application, ApplicationQueryKeys, UpdateApplicationRequest, updateApplication} from '@thunderid/browser'; -import useManagementEndpoint, {ManagementEndpoint} from '../useManagementEndpoint'; -import useResourceMutation, {ResourceMutationOptions, ResourceMutationResult} from '../useResourceMutation'; - -/** - * Variables accepted by {@link useUpdateApplication}'s `mutate`. - */ -export interface UpdateApplicationVariables { - applicationId: string; - data: UpdateApplicationRequest; -} - -/** - * Updates an application, then refetches it and any mounted application lists. - * - * @param options - Mutation options, including `onSuccess`, `onError`, and a per-hook `fetcher`. - * @returns The mutation state. Call `mutate({applicationId, data})` to update. - */ -const useUpdateApplication = ( - options?: ResourceMutationOptions, -): ResourceMutationResult => { - const endpoint: ManagementEndpoint = useManagementEndpoint('applications'); - - return useResourceMutation( - ({applicationId, data}: UpdateApplicationVariables, fetcher) => - updateApplication({...endpoint, applicationId, fetcher, payload: data}), - ({applicationId}: UpdateApplicationVariables) => [ - [ApplicationQueryKeys.APPLICATION, applicationId], - [ApplicationQueryKeys.APPLICATIONS], - ], - options, - ); -}; - -export default useUpdateApplication; diff --git a/packages/react/src/hooks/management/groups/useAddGroupMembers.ts b/packages/react/src/hooks/management/groups/useAddGroupMembers.ts deleted file mode 100644 index 408acb5a..00000000 --- a/packages/react/src/hooks/management/groups/useAddGroupMembers.ts +++ /dev/null @@ -1,45 +0,0 @@ -// Copyright 2026 The ThunderID Authors -// SPDX-License-Identifier: Apache-2.0 - -import {Group, GroupMember, GroupQueryKeys, RoleQueryKeys, addGroupMembers} from '@thunderid/browser'; -import useManagementEndpoint, {ManagementEndpoint} from '../useManagementEndpoint'; -import useResourceMutation, {ResourceMutationOptions, ResourceMutationResult} from '../useResourceMutation'; - -/** - * Variables for {@link useAddGroupMembers}. - */ -export interface GroupMembersVariables { - /** - * Identifier of the group. - */ - groupId: string; - /** - * The identities to change. - */ - members: Pick[]; -} - -/** - * Adds users, agents or groups to a group, then refetches the group, its members, membership lists and role assignments, since membership changes the roles a member inherits. - * - * @param options - Mutation options, including `onSuccess`, `onError`, and a per-hook `fetcher`. - * @returns The mutation state. Call `mutate({groupId, members})`. - */ -const useAddGroupMembers = ( - options?: ResourceMutationOptions, -): ResourceMutationResult => { - const endpoint: ManagementEndpoint = useManagementEndpoint('groups'); - - return useResourceMutation( - ({groupId, members}: GroupMembersVariables, fetcher) => addGroupMembers({...endpoint, fetcher, groupId, members}), - ({groupId}: GroupMembersVariables) => [ - [GroupQueryKeys.GROUP, groupId], - [GroupQueryKeys.GROUP_MEMBERS, groupId], - [GroupQueryKeys.MEMBERSHIPS], - [RoleQueryKeys.ROLE_ASSIGNMENTS], - ], - options, - ); -}; - -export default useAddGroupMembers; diff --git a/packages/react/src/hooks/management/groups/useCreateGroup.ts b/packages/react/src/hooks/management/groups/useCreateGroup.ts deleted file mode 100644 index d13945e5..00000000 --- a/packages/react/src/hooks/management/groups/useCreateGroup.ts +++ /dev/null @@ -1,26 +0,0 @@ -// Copyright 2026 The ThunderID Authors -// SPDX-License-Identifier: Apache-2.0 - -import {CreateGroupRequest, Group, GroupQueryKeys, createGroup} from '@thunderid/browser'; -import useManagementEndpoint, {ManagementEndpoint} from '../useManagementEndpoint'; -import useResourceMutation, {ResourceMutationOptions, ResourceMutationResult} from '../useResourceMutation'; - -/** - * Creates a group, then refetches any mounted group lists and membership lists. - * - * @param options - Mutation options, including `onSuccess`, `onError`, and a per-hook `fetcher`. - * @returns The mutation state. Call `mutate(payload)` to create. - */ -const useCreateGroup = ( - options?: ResourceMutationOptions, -): ResourceMutationResult => { - const endpoint: ManagementEndpoint = useManagementEndpoint('groups'); - - return useResourceMutation( - (payload: CreateGroupRequest, fetcher) => createGroup({...endpoint, fetcher, payload}), - () => [[GroupQueryKeys.GROUPS], [GroupQueryKeys.MEMBERSHIPS]], - options, - ); -}; - -export default useCreateGroup; diff --git a/packages/react/src/hooks/management/groups/useDeleteGroup.ts b/packages/react/src/hooks/management/groups/useDeleteGroup.ts deleted file mode 100644 index 44de417e..00000000 --- a/packages/react/src/hooks/management/groups/useDeleteGroup.ts +++ /dev/null @@ -1,24 +0,0 @@ -// Copyright 2026 The ThunderID Authors -// SPDX-License-Identifier: Apache-2.0 - -import {GroupQueryKeys, RoleQueryKeys, deleteGroup} from '@thunderid/browser'; -import useManagementEndpoint, {ManagementEndpoint} from '../useManagementEndpoint'; -import useResourceMutation, {ResourceMutationOptions, ResourceMutationResult} from '../useResourceMutation'; - -/** - * Deletes a group, then refetches group lists, membership lists and role assignments, since members lose the roles the group carried. - * - * @param options - Mutation options, including `onSuccess`, `onError`, and a per-hook `fetcher`. - * @returns The mutation state. Call `mutate(groupId)` to delete. - */ -const useDeleteGroup = (options?: ResourceMutationOptions): ResourceMutationResult => { - const endpoint: ManagementEndpoint = useManagementEndpoint('groups'); - - return useResourceMutation( - (groupId: string, fetcher) => deleteGroup({...endpoint, fetcher, groupId}), - () => [[GroupQueryKeys.GROUPS], [GroupQueryKeys.MEMBERSHIPS], [RoleQueryKeys.ROLE_ASSIGNMENTS]], - options, - ); -}; - -export default useDeleteGroup; diff --git a/packages/react/src/hooks/management/groups/useGetGroup.ts b/packages/react/src/hooks/management/groups/useGetGroup.ts deleted file mode 100644 index 16482838..00000000 --- a/packages/react/src/hooks/management/groups/useGetGroup.ts +++ /dev/null @@ -1,28 +0,0 @@ -// Copyright 2026 The ThunderID Authors -// SPDX-License-Identifier: Apache-2.0 - -import {Group, GroupQueryKeys, getGroup} from '@thunderid/browser'; -import useManagementEndpoint, {ManagementEndpoint} from '../useManagementEndpoint'; -import useResourceQuery, {ResourceQueryOptions, ResourceQueryResult} from '../useResourceQuery'; - -/** - * Fetches a group. - * - * @param groupId - Identifier of the group. Fetching waits until it is defined. - * @param options - Query options, including a per-hook `fetcher`. - * @returns The query state. - */ -const useGetGroup = (groupId: string | undefined, options?: ResourceQueryOptions): ResourceQueryResult => { - const endpoint: ManagementEndpoint = useManagementEndpoint('groups'); - - return useResourceQuery( - [GroupQueryKeys.GROUP, groupId], - (fetcher) => getGroup({...endpoint, fetcher, groupId: groupId!}), - { - ...options, - enabled: Boolean(groupId) && options?.enabled !== false, - }, - ); -}; - -export default useGetGroup; diff --git a/packages/react/src/hooks/management/groups/useGetGroupMembers.ts b/packages/react/src/hooks/management/groups/useGetGroupMembers.ts deleted file mode 100644 index e3ec5634..00000000 --- a/packages/react/src/hooks/management/groups/useGetGroupMembers.ts +++ /dev/null @@ -1,38 +0,0 @@ -// Copyright 2026 The ThunderID Authors -// SPDX-License-Identifier: Apache-2.0 - -import {GroupMemberListResponse, GroupQueryKeys, getGroupMembers} from '@thunderid/browser'; -import useManagementEndpoint, {ManagementEndpoint} from '../useManagementEndpoint'; -import useResourceQuery, {ResourceQueryOptions, ResourceQueryResult} from '../useResourceQuery'; - -/** - * Pagination parameters for {@link useGetGroupMembers}. - */ -export interface UseGetGroupMembersParams { - limit?: number; - offset?: number; -} - -/** - * Fetches a page of the users, agents and groups in a group. - * - * @param groupId - Identifier of the group. Fetching waits until it is defined. - * @param params - Pagination parameters. - * @param options - Query options, including a per-hook `fetcher`. - * @returns The query state. - */ -const useGetGroupMembers = ( - groupId: string | undefined, - {limit, offset}: UseGetGroupMembersParams = {}, - options?: ResourceQueryOptions, -): ResourceQueryResult => { - const endpoint: ManagementEndpoint = useManagementEndpoint('groups'); - - return useResourceQuery( - [GroupQueryKeys.GROUP_MEMBERS, groupId, {limit, offset}], - (fetcher) => getGroupMembers({...endpoint, fetcher, limit, offset, groupId: groupId!}), - {...options, enabled: Boolean(groupId) && options?.enabled !== false}, - ); -}; - -export default useGetGroupMembers; diff --git a/packages/react/src/hooks/management/groups/useGetGroups.ts b/packages/react/src/hooks/management/groups/useGetGroups.ts deleted file mode 100644 index 520c1aa1..00000000 --- a/packages/react/src/hooks/management/groups/useGetGroups.ts +++ /dev/null @@ -1,36 +0,0 @@ -// Copyright 2026 The ThunderID Authors -// SPDX-License-Identifier: Apache-2.0 - -import {GroupListResponse, GroupQueryKeys, getGroups} from '@thunderid/browser'; -import useManagementEndpoint, {ManagementEndpoint} from '../useManagementEndpoint'; -import useResourceQuery, {ResourceQueryOptions, ResourceQueryResult} from '../useResourceQuery'; - -/** - * Parameters for {@link useGetGroups}. - */ -export interface UseGetGroupsParams { - limit?: number; - offset?: number; -} - -/** - * Fetches a page of groups. - * - * @param params - Pagination parameters. - * @param options - Query options, including a per-hook `fetcher`. - * @returns The query state. - */ -const useGetGroups = ( - {limit, offset}: UseGetGroupsParams = {}, - options?: ResourceQueryOptions, -): ResourceQueryResult => { - const endpoint: ManagementEndpoint = useManagementEndpoint('groups'); - - return useResourceQuery( - [GroupQueryKeys.GROUPS, {limit, offset}], - (fetcher) => getGroups({...endpoint, fetcher, limit, offset}), - options, - ); -}; - -export default useGetGroups; diff --git a/packages/react/src/hooks/management/groups/useRemoveGroupMembers.ts b/packages/react/src/hooks/management/groups/useRemoveGroupMembers.ts deleted file mode 100644 index e53bfda0..00000000 --- a/packages/react/src/hooks/management/groups/useRemoveGroupMembers.ts +++ /dev/null @@ -1,33 +0,0 @@ -// Copyright 2026 The ThunderID Authors -// SPDX-License-Identifier: Apache-2.0 - -import {Group, GroupQueryKeys, RoleQueryKeys, removeGroupMembers} from '@thunderid/browser'; -import {GroupMembersVariables} from './useAddGroupMembers'; -import useManagementEndpoint, {ManagementEndpoint} from '../useManagementEndpoint'; -import useResourceMutation, {ResourceMutationOptions, ResourceMutationResult} from '../useResourceMutation'; - -/** - * Removes users, agents or groups from a group, then refetches the group, its members, membership lists and role assignments, since membership changes the roles a member inherits. - * - * @param options - Mutation options, including `onSuccess`, `onError`, and a per-hook `fetcher`. - * @returns The mutation state. Call `mutate({groupId, members})`. - */ -const useRemoveGroupMembers = ( - options?: ResourceMutationOptions, -): ResourceMutationResult => { - const endpoint: ManagementEndpoint = useManagementEndpoint('groups'); - - return useResourceMutation( - ({groupId, members}: GroupMembersVariables, fetcher) => - removeGroupMembers({...endpoint, fetcher, groupId, members}), - ({groupId}: GroupMembersVariables) => [ - [GroupQueryKeys.GROUP, groupId], - [GroupQueryKeys.GROUP_MEMBERS, groupId], - [GroupQueryKeys.MEMBERSHIPS], - [RoleQueryKeys.ROLE_ASSIGNMENTS], - ], - options, - ); -}; - -export default useRemoveGroupMembers; diff --git a/packages/react/src/hooks/management/groups/useUpdateGroup.ts b/packages/react/src/hooks/management/groups/useUpdateGroup.ts deleted file mode 100644 index a1de0333..00000000 --- a/packages/react/src/hooks/management/groups/useUpdateGroup.ts +++ /dev/null @@ -1,44 +0,0 @@ -// Copyright 2026 The ThunderID Authors -// SPDX-License-Identifier: Apache-2.0 - -import {Group, GroupQueryKeys, UpdateGroupRequest, updateGroup} from '@thunderid/browser'; -import useManagementEndpoint, {ManagementEndpoint} from '../useManagementEndpoint'; -import useResourceMutation, {ResourceMutationOptions, ResourceMutationResult} from '../useResourceMutation'; - -/** - * Variables for {@link useUpdateGroup}. - */ -export interface UpdateGroupVariables { - /** - * The group's new state. - */ - data: UpdateGroupRequest; - /** - * Identifier of the group to update. - */ - groupId: string; -} - -/** - * Updates a group, then refetches the group, any mounted group lists and membership lists. - * - * @param options - Mutation options, including `onSuccess`, `onError`, and a per-hook `fetcher`. - * @returns The mutation state. Call `mutate({groupId, data})` to update. - */ -const useUpdateGroup = ( - options?: ResourceMutationOptions, -): ResourceMutationResult => { - const endpoint: ManagementEndpoint = useManagementEndpoint('groups'); - - return useResourceMutation( - ({groupId, data}: UpdateGroupVariables, fetcher) => updateGroup({...endpoint, fetcher, groupId, payload: data}), - ({groupId}: UpdateGroupVariables) => [ - [GroupQueryKeys.GROUP, groupId], - [GroupQueryKeys.GROUPS], - [GroupQueryKeys.MEMBERSHIPS], - ], - options, - ); -}; - -export default useUpdateGroup; diff --git a/packages/react/src/hooks/management/roles/useAddRoleAssignments.ts b/packages/react/src/hooks/management/roles/useAddRoleAssignments.ts deleted file mode 100644 index bbd62a3a..00000000 --- a/packages/react/src/hooks/management/roles/useAddRoleAssignments.ts +++ /dev/null @@ -1,41 +0,0 @@ -// Copyright 2026 The ThunderID Authors -// SPDX-License-Identifier: Apache-2.0 - -import {RoleAssignmentInput, RoleQueryKeys, addRoleAssignments} from '@thunderid/browser'; -import useManagementEndpoint, {ManagementEndpoint} from '../useManagementEndpoint'; -import useResourceMutation, {ResourceMutationOptions, ResourceMutationResult} from '../useResourceMutation'; - -/** - * Variables for {@link useAddRoleAssignments}. - */ -export interface RoleAssignmentsVariables { - /** - * The identities to change. - */ - assignments: RoleAssignmentInput[]; - /** - * Identifier of the role. - */ - roleId: string; -} - -/** - * Assigns a role to users, groups, apps or agents, then refetches every mounted assignment list, including the roles listed for an agent. - * - * @param options - Mutation options, including `onSuccess`, `onError`, and a per-hook `fetcher`. - * @returns The mutation state. Call `mutate({roleId, assignments})`. - */ -const useAddRoleAssignments = ( - options?: ResourceMutationOptions, -): ResourceMutationResult => { - const endpoint: ManagementEndpoint = useManagementEndpoint('roles'); - - return useResourceMutation( - ({roleId, assignments}: RoleAssignmentsVariables, fetcher) => - addRoleAssignments({...endpoint, assignments, fetcher, roleId}), - () => [[RoleQueryKeys.ROLE_ASSIGNMENTS]], - options, - ); -}; - -export default useAddRoleAssignments; diff --git a/packages/react/src/hooks/management/roles/useCreateRole.ts b/packages/react/src/hooks/management/roles/useCreateRole.ts deleted file mode 100644 index 7fb749d8..00000000 --- a/packages/react/src/hooks/management/roles/useCreateRole.ts +++ /dev/null @@ -1,26 +0,0 @@ -// Copyright 2026 The ThunderID Authors -// SPDX-License-Identifier: Apache-2.0 - -import {CreateRoleRequest, Role, RoleQueryKeys, createRole} from '@thunderid/browser'; -import useManagementEndpoint, {ManagementEndpoint} from '../useManagementEndpoint'; -import useResourceMutation, {ResourceMutationOptions, ResourceMutationResult} from '../useResourceMutation'; - -/** - * Creates a role, then refetches any mounted role lists and assignment lists. - * - * @param options - Mutation options, including `onSuccess`, `onError`, and a per-hook `fetcher`. - * @returns The mutation state. Call `mutate(payload)` to create. - */ -const useCreateRole = ( - options?: ResourceMutationOptions, -): ResourceMutationResult => { - const endpoint: ManagementEndpoint = useManagementEndpoint('roles'); - - return useResourceMutation( - (payload: CreateRoleRequest, fetcher) => createRole({...endpoint, fetcher, payload}), - () => [[RoleQueryKeys.ROLES], [RoleQueryKeys.ROLE_ASSIGNMENTS]], - options, - ); -}; - -export default useCreateRole; diff --git a/packages/react/src/hooks/management/roles/useDeleteRole.ts b/packages/react/src/hooks/management/roles/useDeleteRole.ts deleted file mode 100644 index dffa6529..00000000 --- a/packages/react/src/hooks/management/roles/useDeleteRole.ts +++ /dev/null @@ -1,24 +0,0 @@ -// Copyright 2026 The ThunderID Authors -// SPDX-License-Identifier: Apache-2.0 - -import {RoleQueryKeys, deleteRole} from '@thunderid/browser'; -import useManagementEndpoint, {ManagementEndpoint} from '../useManagementEndpoint'; -import useResourceMutation, {ResourceMutationOptions, ResourceMutationResult} from '../useResourceMutation'; - -/** - * Deletes a role, then refetches any mounted role lists and assignment lists. - * - * @param options - Mutation options, including `onSuccess`, `onError`, and a per-hook `fetcher`. - * @returns The mutation state. Call `mutate(roleId)` to delete. - */ -const useDeleteRole = (options?: ResourceMutationOptions): ResourceMutationResult => { - const endpoint: ManagementEndpoint = useManagementEndpoint('roles'); - - return useResourceMutation( - (roleId: string, fetcher) => deleteRole({...endpoint, fetcher, roleId}), - () => [[RoleQueryKeys.ROLES], [RoleQueryKeys.ROLE_ASSIGNMENTS]], - options, - ); -}; - -export default useDeleteRole; diff --git a/packages/react/src/hooks/management/roles/useGetRole.ts b/packages/react/src/hooks/management/roles/useGetRole.ts deleted file mode 100644 index eca30600..00000000 --- a/packages/react/src/hooks/management/roles/useGetRole.ts +++ /dev/null @@ -1,24 +0,0 @@ -// Copyright 2026 The ThunderID Authors -// SPDX-License-Identifier: Apache-2.0 - -import {Role, RoleQueryKeys, getRole} from '@thunderid/browser'; -import useManagementEndpoint, {ManagementEndpoint} from '../useManagementEndpoint'; -import useResourceQuery, {ResourceQueryOptions, ResourceQueryResult} from '../useResourceQuery'; - -/** - * Fetches a role with the permissions it grants. - * - * @param roleId - Identifier of the role. Fetching waits until it is defined. - * @param options - Query options, including a per-hook `fetcher`. - * @returns The query state. - */ -const useGetRole = (roleId: string | undefined, options?: ResourceQueryOptions): ResourceQueryResult => { - const endpoint: ManagementEndpoint = useManagementEndpoint('roles'); - - return useResourceQuery([RoleQueryKeys.ROLE, roleId], (fetcher) => getRole({...endpoint, fetcher, roleId: roleId!}), { - ...options, - enabled: Boolean(roleId) && options?.enabled !== false, - }); -}; - -export default useGetRole; diff --git a/packages/react/src/hooks/management/roles/useGetRoleAssignments.ts b/packages/react/src/hooks/management/roles/useGetRoleAssignments.ts deleted file mode 100644 index 4186d0f5..00000000 --- a/packages/react/src/hooks/management/roles/useGetRoleAssignments.ts +++ /dev/null @@ -1,42 +0,0 @@ -// Copyright 2026 The ThunderID Authors -// SPDX-License-Identifier: Apache-2.0 - -import {RoleAssigneeType, RoleAssignmentListResponse, RoleQueryKeys, getRoleAssignments} from '@thunderid/browser'; -import useManagementEndpoint, {ManagementEndpoint} from '../useManagementEndpoint'; -import useResourceQuery, {ResourceQueryOptions, ResourceQueryResult} from '../useResourceQuery'; - -/** - * Parameters for {@link useGetRoleAssignments}. - */ -export interface UseGetRoleAssignmentsParams { - limit?: number; - offset?: number; - /** - * Only return assignees of this type. Returns every type when omitted. - */ - type?: RoleAssigneeType; -} - -/** - * Fetches a page of the users, groups, apps and agents a role is assigned to. - * - * @param roleId - Identifier of the role. Fetching waits until it is defined. - * @param params - Pagination and assignee type parameters. - * @param options - Query options, including a per-hook `fetcher`. - * @returns The query state. - */ -const useGetRoleAssignments = ( - roleId: string | undefined, - {limit, offset, type}: UseGetRoleAssignmentsParams = {}, - options?: ResourceQueryOptions, -): ResourceQueryResult => { - const endpoint: ManagementEndpoint = useManagementEndpoint('roles'); - - return useResourceQuery( - [RoleQueryKeys.ROLE_ASSIGNMENTS, roleId, {limit, offset, type}], - (fetcher) => getRoleAssignments({...endpoint, fetcher, limit, offset, roleId: roleId!, type}), - {...options, enabled: Boolean(roleId) && options?.enabled !== false}, - ); -}; - -export default useGetRoleAssignments; diff --git a/packages/react/src/hooks/management/roles/useGetRoles.ts b/packages/react/src/hooks/management/roles/useGetRoles.ts deleted file mode 100644 index ccccd466..00000000 --- a/packages/react/src/hooks/management/roles/useGetRoles.ts +++ /dev/null @@ -1,36 +0,0 @@ -// Copyright 2026 The ThunderID Authors -// SPDX-License-Identifier: Apache-2.0 - -import {RoleListResponse, RoleQueryKeys, getRoles} from '@thunderid/browser'; -import useManagementEndpoint, {ManagementEndpoint} from '../useManagementEndpoint'; -import useResourceQuery, {ResourceQueryOptions, ResourceQueryResult} from '../useResourceQuery'; - -/** - * Parameters for {@link useGetRoles}. - */ -export interface UseGetRolesParams { - limit?: number; - offset?: number; -} - -/** - * Fetches a page of roles. - * - * @param params - Pagination parameters. - * @param options - Query options, including a per-hook `fetcher`. - * @returns The query state. - */ -const useGetRoles = ( - {limit, offset}: UseGetRolesParams = {}, - options?: ResourceQueryOptions, -): ResourceQueryResult => { - const endpoint: ManagementEndpoint = useManagementEndpoint('roles'); - - return useResourceQuery( - [RoleQueryKeys.ROLES, {limit, offset}], - (fetcher) => getRoles({...endpoint, fetcher, limit, offset}), - options, - ); -}; - -export default useGetRoles; diff --git a/packages/react/src/hooks/management/roles/useRemoveRoleAssignments.ts b/packages/react/src/hooks/management/roles/useRemoveRoleAssignments.ts deleted file mode 100644 index e1588476..00000000 --- a/packages/react/src/hooks/management/roles/useRemoveRoleAssignments.ts +++ /dev/null @@ -1,28 +0,0 @@ -// Copyright 2026 The ThunderID Authors -// SPDX-License-Identifier: Apache-2.0 - -import {RoleQueryKeys, removeRoleAssignments} from '@thunderid/browser'; -import {RoleAssignmentsVariables} from './useAddRoleAssignments'; -import useManagementEndpoint, {ManagementEndpoint} from '../useManagementEndpoint'; -import useResourceMutation, {ResourceMutationOptions, ResourceMutationResult} from '../useResourceMutation'; - -/** - * Removes a role from users, groups, apps or agents, then refetches every mounted assignment list, including the roles listed for an agent. - * - * @param options - Mutation options, including `onSuccess`, `onError`, and a per-hook `fetcher`. - * @returns The mutation state. Call `mutate({roleId, assignments})`. - */ -const useRemoveRoleAssignments = ( - options?: ResourceMutationOptions, -): ResourceMutationResult => { - const endpoint: ManagementEndpoint = useManagementEndpoint('roles'); - - return useResourceMutation( - ({roleId, assignments}: RoleAssignmentsVariables, fetcher) => - removeRoleAssignments({...endpoint, assignments, fetcher, roleId}), - () => [[RoleQueryKeys.ROLE_ASSIGNMENTS]], - options, - ); -}; - -export default useRemoveRoleAssignments; diff --git a/packages/react/src/hooks/management/roles/useUpdateRole.ts b/packages/react/src/hooks/management/roles/useUpdateRole.ts deleted file mode 100644 index 67d2c70c..00000000 --- a/packages/react/src/hooks/management/roles/useUpdateRole.ts +++ /dev/null @@ -1,40 +0,0 @@ -// Copyright 2026 The ThunderID Authors -// SPDX-License-Identifier: Apache-2.0 - -import {Role, RoleQueryKeys, UpdateRoleRequest, updateRole} from '@thunderid/browser'; -import useManagementEndpoint, {ManagementEndpoint} from '../useManagementEndpoint'; -import useResourceMutation, {ResourceMutationOptions, ResourceMutationResult} from '../useResourceMutation'; - -/** - * Variables for {@link useUpdateRole}. - */ -export interface UpdateRoleVariables { - /** - * The role's new state. - */ - data: UpdateRoleRequest; - /** - * Identifier of the role to update. - */ - roleId: string; -} - -/** - * Replaces a role's name, description and permissions, then refetches the role and any mounted role lists. - * - * @param options - Mutation options, including `onSuccess`, `onError`, and a per-hook `fetcher`. - * @returns The mutation state. Call `mutate({roleId, data})` to update. - */ -const useUpdateRole = ( - options?: ResourceMutationOptions, -): ResourceMutationResult => { - const endpoint: ManagementEndpoint = useManagementEndpoint('roles'); - - return useResourceMutation( - ({roleId, data}: UpdateRoleVariables, fetcher) => updateRole({...endpoint, fetcher, payload: data, roleId}), - ({roleId}: UpdateRoleVariables) => [[RoleQueryKeys.ROLE, roleId], [RoleQueryKeys.ROLES]], - options, - ); -}; - -export default useUpdateRole; diff --git a/packages/react/src/hooks/management/useManagementEndpoint.ts b/packages/react/src/hooks/management/useManagementEndpoint.ts index 1e958d9b..05e2bef6 100644 --- a/packages/react/src/hooks/management/useManagementEndpoint.ts +++ b/packages/react/src/hooks/management/useManagementEndpoint.ts @@ -16,6 +16,34 @@ export interface ManagementEndpoint { url: string | undefined; } +/** + * The management collections an app can call through the SDK's API functions. + */ +export type ManagementCollection = Extract< + ResourceEndpointKey, + | 'agents' + | 'agentTypes' + | 'applications' + | 'configurationVersions' + | 'connections' + | 'design' + | 'export' + | 'flows' + | 'gateways' + | 'groups' + | 'i18n' + | 'import' + | 'notificationTemplates' + | 'organizationUnits' + | 'resourceServers' + | 'roles' + | 'secrets' + | 'serverConfig' + | 'users' + | 'userTypes' + | 'variables' +>; + /** * Resolves where a management resource collection lives, honoring the provider's `endpoints` * override so the management API can run on a different host from the authorization server. @@ -23,12 +51,7 @@ export interface ManagementEndpoint { * @param key - The resource collection. * @returns The base URL and optional collection URL override. */ -const useManagementEndpoint = ( - key: Extract< - ResourceEndpointKey, - 'agents' | 'agentTypes' | 'applications' | 'flows' | 'groups' | 'roles' | 'users' | 'userTypes' - >, -): ManagementEndpoint => { +const useManagementEndpoint = (key: ManagementCollection): ManagementEndpoint => { const {baseUrl, endpoints} = useThunderID(); return {baseUrl, url: resolveResourceEndpoint(key, {endpoints})}; diff --git a/packages/react/src/hooks/management/useManagementRequestConfig.ts b/packages/react/src/hooks/management/useManagementRequestConfig.ts new file mode 100644 index 00000000..8a42d35e --- /dev/null +++ b/packages/react/src/hooks/management/useManagementRequestConfig.ts @@ -0,0 +1,34 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ApiFetcher, ManagementRequestConfig} from '@thunderid/browser'; +import {useMemo} from 'react'; +import useManagementEndpoint, {ManagementCollection, ManagementEndpoint} from './useManagementEndpoint'; +import {useResolvedFetcher} from './useResourceQuery'; + +export type {ManagementCollection}; + +/** + * Returns the request config the SDK's management API functions take for a collection: the server URL, + * the collection URL when `endpoints` overrides it, and a fetcher that sends the signed-in user's access + * token (`ThunderIDProvider`'s `http.fetcher` when set). + * + * Use it to call the API functions from an app's own data layer, such as TanStack Query: + * + * @example + * ```tsx + * const config = useManagementRequestConfig('groups'); + * const groups = useQuery({ + * queryKey: [GroupQueryKeys.GROUPS, {limit, offset}], + * queryFn: () => getGroups({...config, limit, offset}), + * }); + * ``` + */ +const useManagementRequestConfig = (collection: ManagementCollection): ManagementRequestConfig => { + const {baseUrl, url}: ManagementEndpoint = useManagementEndpoint(collection); + const fetcher: ApiFetcher = useResolvedFetcher(); + + return useMemo(() => ({baseUrl, fetcher, url}), [baseUrl, fetcher, url]); +}; + +export default useManagementRequestConfig; diff --git a/packages/react/src/hooks/management/users/useCreateUser.ts b/packages/react/src/hooks/management/users/useCreateUser.ts deleted file mode 100644 index 700e672e..00000000 --- a/packages/react/src/hooks/management/users/useCreateUser.ts +++ /dev/null @@ -1,26 +0,0 @@ -// Copyright 2025-2026 The ThunderID Authors -// SPDX-License-Identifier: Apache-2.0 - -import {CreateManagedUserRequest, ManagedUser, UserQueryKeys, createUser} from '@thunderid/browser'; -import useManagementEndpoint, {ManagementEndpoint} from '../useManagementEndpoint'; -import useResourceMutation, {ResourceMutationOptions, ResourceMutationResult} from '../useResourceMutation'; - -/** - * Creates a user, then refetches any mounted user lists. - * - * @param options - Mutation options, including `onSuccess`, `onError`, and a per-hook `fetcher`. - * @returns The mutation state. Call `mutate(payload)` to create. - */ -const useCreateUser = ( - options?: ResourceMutationOptions, -): ResourceMutationResult => { - const endpoint: ManagementEndpoint = useManagementEndpoint('users'); - - return useResourceMutation( - (payload: CreateManagedUserRequest, fetcher) => createUser({...endpoint, fetcher, payload}), - () => [[UserQueryKeys.USERS]], - options, - ); -}; - -export default useCreateUser; diff --git a/packages/react/src/hooks/management/users/useDeleteUser.ts b/packages/react/src/hooks/management/users/useDeleteUser.ts deleted file mode 100644 index a69ae9da..00000000 --- a/packages/react/src/hooks/management/users/useDeleteUser.ts +++ /dev/null @@ -1,24 +0,0 @@ -// Copyright 2025-2026 The ThunderID Authors -// SPDX-License-Identifier: Apache-2.0 - -import {UserQueryKeys, deleteUser} from '@thunderid/browser'; -import useManagementEndpoint, {ManagementEndpoint} from '../useManagementEndpoint'; -import useResourceMutation, {ResourceMutationOptions, ResourceMutationResult} from '../useResourceMutation'; - -/** - * Deletes a user, then refetches any mounted user lists. - * - * @param options - Mutation options, including `onSuccess`, `onError`, and a per-hook `fetcher`. - * @returns The mutation state. Call `mutate(userId)` to delete. - */ -const useDeleteUser = (options?: ResourceMutationOptions): ResourceMutationResult => { - const endpoint: ManagementEndpoint = useManagementEndpoint('users'); - - return useResourceMutation( - (userId: string, fetcher) => deleteUser({...endpoint, fetcher, userId}), - () => [[UserQueryKeys.USERS]], - options, - ); -}; - -export default useDeleteUser; diff --git a/packages/react/src/hooks/management/users/useGetUser.ts b/packages/react/src/hooks/management/users/useGetUser.ts deleted file mode 100644 index 5be9b67c..00000000 --- a/packages/react/src/hooks/management/users/useGetUser.ts +++ /dev/null @@ -1,24 +0,0 @@ -// Copyright 2025-2026 The ThunderID Authors -// SPDX-License-Identifier: Apache-2.0 - -import {ManagedUser, UserQueryKeys, getUser} from '@thunderid/browser'; -import useManagementEndpoint, {ManagementEndpoint} from '../useManagementEndpoint'; -import useResourceQuery, {ResourceQueryOptions, ResourceQueryResult} from '../useResourceQuery'; - -/** - * Fetches a single user. Does nothing until `userId` is set. - * - * @param userId - Identifier of the user. - * @param options - Query options, including a per-hook `fetcher`. - * @returns The query state. - */ -const useGetUser = (userId: string | undefined, options?: ResourceQueryOptions): ResourceQueryResult => { - const endpoint: ManagementEndpoint = useManagementEndpoint('users'); - - return useResourceQuery([UserQueryKeys.USER, userId], (fetcher) => getUser({...endpoint, fetcher, userId: userId!}), { - ...options, - enabled: Boolean(userId) && options?.enabled !== false, - }); -}; - -export default useGetUser; diff --git a/packages/react/src/hooks/management/users/useGetUserGroups.ts b/packages/react/src/hooks/management/users/useGetUserGroups.ts deleted file mode 100644 index f7876dbb..00000000 --- a/packages/react/src/hooks/management/users/useGetUserGroups.ts +++ /dev/null @@ -1,30 +0,0 @@ -// Copyright 2026 The ThunderID Authors -// SPDX-License-Identifier: Apache-2.0 - -import {GroupQueryKeys, MembershipGroupListResponse, getUserGroups} from '@thunderid/browser'; -import useManagementEndpoint, {ManagementEndpoint} from '../useManagementEndpoint'; -import useResourceQuery, {ResourceQueryOptions, ResourceQueryResult} from '../useResourceQuery'; - -/** - * Fetches a page of the groups a user belongs to. - * - * @param userId - Identifier of the user. Fetching waits until it is defined. - * @param params - Pagination parameters. - * @param options - Query options, including a per-hook `fetcher`. - * @returns The query state. - */ -const useGetUserGroups = ( - userId: string | undefined, - {limit, offset}: {limit?: number; offset?: number} = {}, - options?: ResourceQueryOptions, -): ResourceQueryResult => { - const endpoint: ManagementEndpoint = useManagementEndpoint('users'); - - return useResourceQuery( - [GroupQueryKeys.MEMBERSHIPS, 'user', userId, {limit, offset}], - (fetcher) => getUserGroups({...endpoint, fetcher, limit, offset, userId: userId!}), - {...options, enabled: Boolean(userId) && options?.enabled !== false}, - ); -}; - -export default useGetUserGroups; diff --git a/packages/react/src/hooks/management/users/useGetUserTypes.ts b/packages/react/src/hooks/management/users/useGetUserTypes.ts deleted file mode 100644 index b8e6cb72..00000000 --- a/packages/react/src/hooks/management/users/useGetUserTypes.ts +++ /dev/null @@ -1,28 +0,0 @@ -// Copyright 2026 The ThunderID Authors -// SPDX-License-Identifier: Apache-2.0 - -import {ManagedUserTypeListResponse, UserQueryKeys, getUserTypes} from '@thunderid/browser'; -import useManagementEndpoint, {ManagementEndpoint} from '../useManagementEndpoint'; -import useResourceQuery, {ResourceQueryOptions, ResourceQueryResult} from '../useResourceQuery'; - -/** - * Fetches a page of user types. - * - * @param params - Pagination parameters. - * @param options - Query options, including a per-hook `fetcher`. - * @returns The query state. - */ -const useGetUserTypes = ( - {limit, offset}: {limit?: number; offset?: number} = {}, - options?: ResourceQueryOptions, -): ResourceQueryResult => { - const endpoint: ManagementEndpoint = useManagementEndpoint('userTypes'); - - return useResourceQuery( - [UserQueryKeys.USER_TYPES, {limit, offset}], - (fetcher) => getUserTypes({...endpoint, fetcher, limit, offset}), - options, - ); -}; - -export default useGetUserTypes; diff --git a/packages/react/src/hooks/management/users/useGetUsers.ts b/packages/react/src/hooks/management/users/useGetUsers.ts deleted file mode 100644 index e505f7c8..00000000 --- a/packages/react/src/hooks/management/users/useGetUsers.ts +++ /dev/null @@ -1,33 +0,0 @@ -// Copyright 2025-2026 The ThunderID Authors -// SPDX-License-Identifier: Apache-2.0 - -import {ApiFilteringParams, ManagedUserListResponse, UserQueryKeys, getUsers} from '@thunderid/browser'; -import useManagementEndpoint, {ManagementEndpoint} from '../useManagementEndpoint'; -import useResourceQuery, {ResourceQueryOptions, ResourceQueryResult} from '../useResourceQuery'; - -/** - * Fetches a page of users. - * - * @param params - Pagination and filter parameters. - * @param options - Query options, including a per-hook `fetcher`. - * @returns The query state. - * - * @example - * ```tsx - * const {data, isLoading} = useGetUsers({limit: 20, filter: 'username sw "a"'}); - * ``` - */ -const useGetUsers = ( - {filter, limit, offset}: ApiFilteringParams = {}, - options?: ResourceQueryOptions, -): ResourceQueryResult => { - const endpoint: ManagementEndpoint = useManagementEndpoint('users'); - - return useResourceQuery( - [UserQueryKeys.USERS, {filter, limit, offset}], - (fetcher) => getUsers({...endpoint, fetcher, filter, limit, offset}), - options, - ); -}; - -export default useGetUsers; diff --git a/packages/react/src/hooks/management/users/useUpdateUser.ts b/packages/react/src/hooks/management/users/useUpdateUser.ts deleted file mode 100644 index 8ddd6fc7..00000000 --- a/packages/react/src/hooks/management/users/useUpdateUser.ts +++ /dev/null @@ -1,34 +0,0 @@ -// Copyright 2025-2026 The ThunderID Authors -// SPDX-License-Identifier: Apache-2.0 - -import {ManagedUser, UpdateManagedUserRequest, UserQueryKeys, updateUser} from '@thunderid/browser'; -import useManagementEndpoint, {ManagementEndpoint} from '../useManagementEndpoint'; -import useResourceMutation, {ResourceMutationOptions, ResourceMutationResult} from '../useResourceMutation'; - -/** - * Variables accepted by {@link useUpdateUser}'s `mutate`. - */ -export interface UpdateUserVariables { - data: UpdateManagedUserRequest; - userId: string; -} - -/** - * Updates a user, then refetches it and any mounted user lists. - * - * @param options - Mutation options, including `onSuccess`, `onError`, and a per-hook `fetcher`. - * @returns The mutation state. Call `mutate({userId, data})` to update. - */ -const useUpdateUser = ( - options?: ResourceMutationOptions, -): ResourceMutationResult => { - const endpoint: ManagementEndpoint = useManagementEndpoint('users'); - - return useResourceMutation( - ({userId, data}: UpdateUserVariables, fetcher) => updateUser({...endpoint, fetcher, payload: data, userId}), - ({userId}: UpdateUserVariables) => [[UserQueryKeys.USER, userId], [UserQueryKeys.USERS]], - options, - ); -}; - -export default useUpdateUser; diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index a1dc9a89..2d3cd33c 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -49,69 +49,17 @@ export * from './hooks/useBrowserUrl'; export {default as useTranslation} from './hooks/useTranslation'; export * from './hooks/useTranslation'; - -export {default as useInfiniteOptions} from './hooks/management/useInfiniteOptions'; -export * from './hooks/management/useInfiniteOptions'; - -export {default as useFetchUsers} from './hooks/management/useFetchUsers'; export {default as useFetchAuthFlows} from './hooks/auth/useFetchAuthFlows'; -export {default as useFetchUserTypes} from './hooks/management/useFetchUserTypes'; export {normalizeFlowResponse, extractErrorMessage} from './utils/flowTransformer'; export {default as useForm} from './hooks/useForm'; - -export type {ResourceQueryOptions, ResourceQueryResult} from './hooks/management/useResourceQuery'; -export type {ResourceMutationOptions, ResourceMutationResult} from './hooks/management/useResourceMutation'; -export {default as useGetApplications} from './hooks/management/applications/useGetApplications'; -export type {UseGetApplicationsParams} from './hooks/management/applications/useGetApplications'; -export {default as useGetApplication} from './hooks/management/applications/useGetApplication'; -export {default as useCreateApplication} from './hooks/management/applications/useCreateApplication'; -export {default as useUpdateApplication} from './hooks/management/applications/useUpdateApplication'; -export type {UpdateApplicationVariables} from './hooks/management/applications/useUpdateApplication'; -export {default as useDeleteApplication} from './hooks/management/applications/useDeleteApplication'; -export {default as useGetUsers} from './hooks/management/users/useGetUsers'; -export {default as useGetUser} from './hooks/management/users/useGetUser'; -export {default as useCreateUser} from './hooks/management/users/useCreateUser'; -export {default as useUpdateUser} from './hooks/management/users/useUpdateUser'; -export type {UpdateUserVariables} from './hooks/management/users/useUpdateUser'; -export {default as useDeleteUser} from './hooks/management/users/useDeleteUser'; -export {default as useGetAgents} from './hooks/management/agents/useGetAgents'; -export type {UseGetAgentsParams} from './hooks/management/agents/useGetAgents'; -export {default as useGetAgent} from './hooks/management/agents/useGetAgent'; -export {default as useCreateAgent} from './hooks/management/agents/useCreateAgent'; -export {default as useUpdateAgent} from './hooks/management/agents/useUpdateAgent'; -export type {UpdateAgentVariables} from './hooks/management/agents/useUpdateAgent'; -export {default as useDeleteAgent} from './hooks/management/agents/useDeleteAgent'; -export {default as useGetUserGroups} from './hooks/management/users/useGetUserGroups'; -export {default as useGetAgentGroups} from './hooks/management/agents/useGetAgentGroups'; -export {default as useGetAgentRoles} from './hooks/management/agents/useGetAgentRoles'; -export {default as useGetRoles} from './hooks/management/roles/useGetRoles'; -export type {UseGetRolesParams} from './hooks/management/roles/useGetRoles'; -export {default as useGetRole} from './hooks/management/roles/useGetRole'; -export {default as useCreateRole} from './hooks/management/roles/useCreateRole'; -export {default as useUpdateRole} from './hooks/management/roles/useUpdateRole'; -export type {UpdateRoleVariables} from './hooks/management/roles/useUpdateRole'; -export {default as useDeleteRole} from './hooks/management/roles/useDeleteRole'; -export {default as useGetRoleAssignments} from './hooks/management/roles/useGetRoleAssignments'; -export type {UseGetRoleAssignmentsParams} from './hooks/management/roles/useGetRoleAssignments'; -export {default as useAddRoleAssignments} from './hooks/management/roles/useAddRoleAssignments'; -export type {RoleAssignmentsVariables} from './hooks/management/roles/useAddRoleAssignments'; -export {default as useRemoveRoleAssignments} from './hooks/management/roles/useRemoveRoleAssignments'; -export {default as useGetGroups} from './hooks/management/groups/useGetGroups'; -export type {UseGetGroupsParams} from './hooks/management/groups/useGetGroups'; -export {default as useGetGroup} from './hooks/management/groups/useGetGroup'; -export {default as useCreateGroup} from './hooks/management/groups/useCreateGroup'; -export {default as useUpdateGroup} from './hooks/management/groups/useUpdateGroup'; -export type {UpdateGroupVariables} from './hooks/management/groups/useUpdateGroup'; -export {default as useDeleteGroup} from './hooks/management/groups/useDeleteGroup'; -export {default as useGetGroupMembers} from './hooks/management/groups/useGetGroupMembers'; -export type {UseGetGroupMembersParams} from './hooks/management/groups/useGetGroupMembers'; -export {default as useAddGroupMembers} from './hooks/management/groups/useAddGroupMembers'; -export type {GroupMembersVariables} from './hooks/management/groups/useAddGroupMembers'; -export {default as useRemoveGroupMembers} from './hooks/management/groups/useRemoveGroupMembers'; -export {default as useGetUserTypes} from './hooks/management/users/useGetUserTypes'; -export {default as useGetAgentTypes} from './hooks/management/agents/useGetAgentTypes'; +export {default as useInfiniteOptions} from './hooks/management/useInfiniteOptions'; +export * from './hooks/management/useInfiniteOptions'; +export {default as useFetchUsers} from './hooks/management/useFetchUsers'; +export {default as useFetchUserTypes} from './hooks/management/useFetchUserTypes'; +export {default as useManagementRequestConfig} from './hooks/management/useManagementRequestConfig'; +export type {ManagementCollection} from './hooks/management/useManagementRequestConfig'; export {default as useGrantedPermissions} from './hooks/management/useGrantedPermissions'; export type {GrantedPermissions} from './hooks/management/useGrantedPermissions'; @@ -281,6 +229,26 @@ export * from './components/admin/AgentDetails/BaseAgentDetails'; export {default as AgentDetails} from './components/admin/AgentDetails/AgentDetails'; export * from './components/admin/AgentDetails/AgentDetails'; +export {default as BaseApplicationList} from './components/admin/ApplicationList/BaseApplicationList'; +export * from './components/admin/ApplicationList/BaseApplicationList'; + +export {default as ApplicationList} from './components/admin/ApplicationList/ApplicationList'; +export * from './components/admin/ApplicationList/ApplicationList'; + +export {default as BaseApplicationCreate} from './components/admin/ApplicationCreate/BaseApplicationCreate'; +export * from './components/admin/ApplicationCreate/BaseApplicationCreate'; + +export {default as ApplicationCreate} from './components/admin/ApplicationCreate/ApplicationCreate'; +export * from './components/admin/ApplicationCreate/ApplicationCreate'; + +export {default as BaseApplicationDetails} from './components/admin/ApplicationDetails/BaseApplicationDetails'; +export * from './components/admin/ApplicationDetails/BaseApplicationDetails'; + +export {default as ApplicationDetails} from './components/admin/ApplicationDetails/ApplicationDetails'; +export * from './components/admin/ApplicationDetails/ApplicationDetails'; + +export type {ApplicationFormValues, ApplicationTextField} from './components/admin/shared/ApplicationFormFields'; + export {default as DataTable} from './components/primitives/DataTable/DataTable'; export * from './components/primitives/DataTable/DataTable'; @@ -575,4 +543,475 @@ export { type RoleSummary, type UpdateGroupRequest, type UpdateRoleRequest, + AdministrationFlowConfigKey, + AdministrationFlowData, + AdministrationFlowError, + AdministrationFlowInput, + AdministrationModes, + ADMINISTRATION_FLOW_TYPE, + createUserType, + deleteUserType, + executeAdministrationFlow, + findAdministrationFlows, + getAgentType, + getUserType, + getUserTypes, + getUserUsages, + previewCimdDocument, + regenerateAgentSecret, + regenerateClientSecret, + regenerateFlowSecret, + resolveAdministrationFlowHandles, + resolveAdministrationFlowId, + resolveAdministrationFlows, + runAdministrationFlow, + updateAgentType, + updateUserCredentials, + updateUserType, +} from '@thunderid/browser'; +export type { + AdministrationFlowConfig, + ServerError, + ServerErrorMessage, + AdministrationFlowConfigKeyValue, + AdministrationFlowExecutionResponse, + AdministrationFlowStepError, + AdministrationMode, + AdministrationOptions, + AgentTypeDetails, + ArrayItemDefinition, + ArrayPropertyDefinition, + BooleanPropertyDefinition, + CimdPreviewResponse, + CreateUserTypeConfig, + CreateUserTypeRequest, + DeleteUserTypeConfig, + EntityTypeSchema, + EntityTypeSystemAttributes, + GetAgentTypeConfig, + GetUserTypeConfig, + GetUserTypesConfig, + GetUserUsagesConfig, + ManagedUserTypeDetails, + NumberPropertyDefinition, + ObjectPropertyDefinition, + PreviewCimdDocumentConfig, + PropertyDefinition, + RegenerateAgentSecretConfig, + RegenerateAgentSecretResult, + RegenerateClientSecretConfig, + RegenerateClientSecretResult, + RegenerateFlowSecretConfig, + RegenerateFlowSecretResult, + StringPropertyDefinition, + UpdateAgentTypeConfig, + UpdateAgentTypeRequest, + UpdateUserCredentialsConfig, + UpdateUserCredentialsRequest, + UpdateUserTypeConfig, + UpdateUserTypeRequest, + UserUsage, + UserUsagesResponse, +} from '@thunderid/browser'; +// The management operations beyond users, groups, roles, agents and applications. +export { + applyGatewayConfiguration, + createAction, + createAgentType, + createApplicationSharingPolicy, + createConfigurationVersion, + createConnection, + createDesignTheme, + createFlow, + createGateway, + createGatewaySecret, + createGatewayVariable, + createLayout, + createNotificationTemplate, + createOrganizationUnit, + createResource, + createResourceServer, + createSecret, + createUserByPath, + createUserSharingPolicy, + createVariable, + deleteAction, + deleteAgentType, + deleteApplicationSharingPolicy, + deleteConnection, + deleteDesignTheme, + deleteFlow, + deleteGateway, + deleteGatewaySecret, + deleteGatewayVariable, + deleteImportedConfiguration, + deleteLayout, + deleteNotificationTemplate, + deleteOrganizationUnit, + deleteOrganizationUnitByPath, + deleteResource, + deleteResourceServer, + deleteSecret, + deleteTranslation, + deleteTranslations, + deleteUserSharingPolicy, + deleteVariable, + exportConfiguration, + getAction, + getActions, + getApplicationOverlayRules, + getApplicationSharingPolicies, + getApplicationSharingPolicy, + getConfigurationVersion, + getConfigurationVersions, + getConnection, + getConnectionUsages, + getConnections, + getConnectionsMeta, + getDesignTheme, + getDesignThemeUsages, + getDesignThemes, + getFlow, + getFlowUsages, + getFlowVersion, + getFlowVersions, + getFlows, + getGateway, + getGatewayAppliedConfiguration, + getGatewayAppliedVersion, + getGatewayConfigurationDiff, + getGatewaySecret, + getGatewaySecrets, + getGatewayVariable, + getGatewayVariables, + getGateways, + getLanguages, + getLayout, + getLayoutUsages, + getLayouts, + getNotificationTemplate, + getNotificationTemplates, + getOrganizationUnit, + getOrganizationUnitByPath, + getOrganizationUnitChildren, + getOrganizationUnitGroups, + getOrganizationUnitRoles, + getOrganizationUnitUsers, + getOrganizationUnits, + getResource, + getResourceServer, + getResourceServers, + getResources, + getSecret, + getSecrets, + getServerConfig, + getServerConfigs, + getUserOverlayRules, + getUserSharingPolicies, + getUserSharingPolicy, + getUsersByPath, + getVariable, + getVariables, + importConfiguration, + resolveDesign, + resolveTranslation, + resolveTranslations, + restoreFlowVersion, + revertGatewayConfiguration, + setTranslation, + setTranslations, + suspendAgent, + suspendApplication, + suspendUser, + unlockAgent, + unlockUser, + unsuspendAgent, + unsuspendApplication, + unsuspendUser, + updateAction, + updateApplicationSharingPolicy, + updateConnection, + updateDesignTheme, + updateFlow, + updateGateway, + updateGatewaySecret, + updateGatewayVariable, + updateLayout, + updateNotificationTemplate, + updateOrganizationUnit, + updateOrganizationUnitByPath, + updateResource, + updateResourceServer, + updateSecret, + updateServerConfig, + updateUserSharingPolicy, + updateVariable, +} from '@thunderid/browser'; +export type { + AccountLifecycleOptions, + AccountLifecycleResult, + ApplyGatewayConfigurationConfig, + ApplyGatewayConfigurationRequest, + CaptureConfigurationVersionRequest, + ConfigurationActor, + ConfigurationExportOptions, + ConfigurationExportRequest, + ConfigurationExportResponse, + ConfigurationImportOptions, + ConfigurationImportOutcome, + ConfigurationImportRequest, + ConfigurationImportResponse, + ConfigurationResourceDeletion, + ConfigurationValueReference, + ConfigurationVersion, + Connection, + ConnectionCategory, + ConnectionInstance, + ConnectionListResponse, + ConnectionMetaResponse, + ConnectionRequest, + ConnectionSummary, + ConnectionType, + ConnectionTypeWithUsages, + ConnectionUsage, + ConnectionUsagesResponse, + ConnectionVendorMeta, + CreateActionConfig, + CreateActionRequest, + CreateAgentTypeConfig, + CreateAgentTypeRequest, + CreateApplicationSharingPolicyConfig, + CreateConfigurationVersionConfig, + CreateConnectionConfig, + CreateDesignThemeConfig, + CreateEmailTemplateRequest, + CreateFlowConfig, + CreateGatewayConfig, + CreateGatewaySecretConfig, + CreateGatewayVariableConfig, + CreateLayoutConfig, + CreateNotificationTemplateConfig, + CreateNotificationTemplateRequest, + CreateOrganizationUnitConfig, + CreateOrganizationUnitRequest, + CreateResourceConfig, + CreateResourceRequest, + CreateResourceServerConfig, + CreateResourceServerRequest, + CreateSecretConfig, + CreateSmsTemplateRequest, + CreateUserByPathConfig, + CreateUserByPathRequest, + CreateUserSharingPolicyConfig, + CreateVariableConfig, + DeleteActionConfig, + DeleteAgentTypeConfig, + DeleteApplicationSharingPolicyConfig, + DeleteConnectionConfig, + DeleteDesignThemeConfig, + DeleteFlowConfig, + DeleteGatewayConfig, + DeleteGatewaySecretConfig, + DeleteGatewayVariableConfig, + DeleteImportedConfigurationConfig, + DeleteImportedConfigurationRequest, + DeleteImportedConfigurationResponse, + DeleteLayoutConfig, + DeleteNotificationTemplateConfig, + DeleteOrganizationUnitByPathConfig, + DeleteOrganizationUnitConfig, + DeleteResourceConfig, + DeleteResourceServerConfig, + DeleteSecretConfig, + DeleteTranslationConfig, + DeleteTranslationsConfig, + DeleteUserSharingPolicyConfig, + DeleteVariableConfig, + DesignLayout, + DesignLayoutListItem, + DesignLayoutListResponse, + DesignLayoutRequest, + DesignResolveResponse, + DesignResolveType, + DesignTheme, + DesignThemeListItem, + DesignThemeListResponse, + DesignThemeRequest, + EmailNotificationTemplate, + EmailTemplateContent, + ExportConfigurationConfig, + Gateway, + GatewayAppliedConfiguration, + GatewayAppliedVersion, + GatewayApplyResult, + GatewayConfigurationChange, + GatewayConfigurationDiff, + GatewayRegistration, + GatewayValueChanges, + GetActionConfig, + GetActionsConfig, + GetApplicationOverlayRulesConfig, + GetApplicationSharingPoliciesConfig, + GetApplicationSharingPolicyConfig, + GetConfigurationVersionConfig, + GetConfigurationVersionsConfig, + GetConnectionConfig, + GetConnectionUsagesConfig, + GetConnectionsConfig, + GetConnectionsMetaConfig, + GetConnectionsResult, + GetDesignThemeConfig, + GetDesignThemeUsagesConfig, + GetDesignThemesConfig, + GetFlowConfig, + GetFlowUsagesConfig, + GetFlowVersionConfig, + GetFlowVersionsConfig, + GetFlowsConfig, + GetGatewayAppliedConfigurationConfig, + GetGatewayAppliedVersionConfig, + GetGatewayConfig, + GetGatewayConfigurationDiffConfig, + GetGatewaySecretConfig, + GetGatewaySecretsConfig, + GetGatewayVariableConfig, + GetGatewayVariablesConfig, + GetGatewaysConfig, + GetLanguagesConfig, + GetLayoutConfig, + GetLayoutUsagesConfig, + GetLayoutsConfig, + GetNotificationTemplateConfig, + GetNotificationTemplatesConfig, + GetOrganizationUnitByPathConfig, + GetOrganizationUnitChildrenConfig, + GetOrganizationUnitConfig, + GetOrganizationUnitGroupsConfig, + GetOrganizationUnitRolesConfig, + GetOrganizationUnitUsersConfig, + GetOrganizationUnitsConfig, + GetResourceConfig, + GetResourceServerConfig, + GetResourceServersConfig, + GetResourcesConfig, + GetSecretConfig, + GetSecretsConfig, + GetServerConfigConfig, + GetServerConfigsConfig, + GetUserOverlayRulesConfig, + GetUserSharingPoliciesConfig, + GetUserSharingPolicyConfig, + GetUsersByPathConfig, + GetVariableConfig, + GetVariablesConfig, + ImportConfigurationConfig, + LanguageListResponse, + LanguageTranslationsResponse, + ManagedAction, + ManagedActionKind, + ManagedActionListResponse, + ManagedFlow, + ManagedFlowDefinition, + ManagedFlowListResponse, + ManagedFlowNode, + ManagedFlowRequest, + ManagedFlowVersion, + ManagedFlowVersionInfo, + ManagedFlowVersionListResponse, + ManagedResource, + ManagedResourceListResponse, + ManagedTranslation, + NotificationChannel, + NotificationTemplate, + NotificationTemplateDesign, + NotificationTemplateListResponse, + NotificationTemplateSummary, + OrganizationUnit, + OrganizationUnitBasic, + OrganizationUnitGroup, + OrganizationUnitGroupListResponse, + OrganizationUnitListResponse, + OrganizationUnitReference, + OrganizationUnitRole, + OrganizationUnitRoleListResponse, + OrganizationUnitUser, + OrganizationUnitUserListResponse, + OverlayRule, + RegisterGatewayRequest, + ResolveDesignConfig, + ResolveTranslationConfig, + ResolveTranslationsConfig, + ResolvedOverlay, + ResourceServer, + ResourceServerAuthorizationEngine, + ResourceServerListResponse, + ResourceServerType, + ResourceUsage, + ResourceUsagesResponse, + RestoreFlowVersionConfig, + RevertGatewayConfigurationConfig, + RevertGatewayConfigurationRequest, + Secret, + SecretListResponse, + SecretRequest, + SecretUpdateRequest, + ServerConfigLayers, + SetTranslationConfig, + SetTranslationRequest, + SetTranslationsConfig, + SetTranslationsRequest, + SharingPolicy, + SharingPolicyAllUnits, + SharingPolicyChildUnit, + SharingPolicyChildUnits, + SharingPolicyChildUnitsAll, + SharingPolicyChildUnitsSelected, + SharingPolicyListResponse, + SharingPolicyOtherTreeRoots, + SharingPolicyOtherTreeRootsAll, + SharingPolicyOtherTreeRootsSelected, + SharingPolicyRequest, + SharingPolicyUpdateRequest, + SmsNotificationTemplate, + SmsTemplateContent, + SuspendAgentConfig, + SuspendApplicationConfig, + SuspendUserConfig, + TranslationsByNamespace, + UnlockAgentConfig, + UnlockUserConfig, + UnsuspendAgentConfig, + UnsuspendApplicationConfig, + UnsuspendUserConfig, + UpdateActionConfig, + UpdateActionRequest, + UpdateApplicationSharingPolicyConfig, + UpdateConnectionConfig, + UpdateDesignThemeConfig, + UpdateEmailTemplateRequest, + UpdateFlowConfig, + UpdateGatewayConfig, + UpdateGatewayRequest, + UpdateGatewaySecretConfig, + UpdateGatewayVariableConfig, + UpdateLayoutConfig, + UpdateNotificationTemplateConfig, + UpdateNotificationTemplateRequest, + UpdateOrganizationUnitByPathConfig, + UpdateOrganizationUnitByPathRequest, + UpdateOrganizationUnitConfig, + UpdateOrganizationUnitRequest, + UpdateResourceConfig, + UpdateResourceRequest, + UpdateResourceServerConfig, + UpdateResourceServerRequest, + UpdateSecretConfig, + UpdateServerConfigConfig, + UpdateSmsTemplateRequest, + UpdateUserSharingPolicyConfig, + UpdateVariableConfig, + Variable, + VariableListResponse, + VariableRequest, + VariableUpdateRequest, } from '@thunderid/browser'; diff --git a/packages/react/src/models/config.ts b/packages/react/src/models/config.ts index 8a287c67..652892c9 100644 --- a/packages/react/src/models/config.ts +++ b/packages/react/src/models/config.ts @@ -9,10 +9,9 @@ export type ThunderIDReactConfig = ThunderIDBrowserConfig & { */ http?: { /** - * Transport used by the management hooks (`useGetApplications`, `useCreateUser`, and so on). - * Applies to management operations only. Defaults to the SDK's authenticated HTTP client, - * which attaches the signed-in user's access token. A `fetcher` passed to an individual hook - * takes precedence over this one. + * Transport used for management operations: by the admin components and by the request config + * `useManagementRequestConfig` returns. Defaults to the SDK's authenticated HTTP client, which + * attaches the signed-in user's access token. */ fetcher?: ApiFetcher; }; diff --git a/packages/vue/src/__tests__/components/admin/admin-components.test.ts b/packages/vue/src/__tests__/components/admin/admin-components.test.ts index 27351efd..741fa768 100644 --- a/packages/vue/src/__tests__/components/admin/admin-components.test.ts +++ b/packages/vue/src/__tests__/components/admin/admin-components.test.ts @@ -1,31 +1,61 @@ // Copyright 2026 The ThunderID Authors // SPDX-License-Identifier: Apache-2.0 -import type {Group, ManagedUser, RoleResourcePermissions} from '@thunderid/browser'; +import {AgentQueryKeys, ApplicationQueryKeys, GroupQueryKeys, RoleQueryKeys, UserQueryKeys} from '@thunderid/browser'; +import type { + Application, + BasicApplication, + Group, + ManagedUser, + ResourceQueryKey, + RoleResourcePermissions, +} from '@thunderid/browser'; import {mount} from '@vue/test-utils'; import {afterEach, describe, expect, it, vi} from 'vitest'; import {h, nextTick} from 'vue'; +import AgentCreate from '../../../components/admin/AgentCreate/AgentCreate'; +import type {BaseAgentCreateRenderProps} from '../../../components/admin/AgentCreate/BaseAgentCreate'; +import AgentDetails from '../../../components/admin/AgentDetails/AgentDetails'; import BaseAgentDetails from '../../../components/admin/AgentDetails/BaseAgentDetails'; -import AgentList from '../../../components/admin/AgentList/AgentList'; import type {BaseAgentDetailsRenderProps} from '../../../components/admin/AgentDetails/BaseAgentDetails'; +import AgentList from '../../../components/admin/AgentList/AgentList'; +import ApplicationCreate from '../../../components/admin/ApplicationCreate/ApplicationCreate'; +import BaseApplicationCreate from '../../../components/admin/ApplicationCreate/BaseApplicationCreate'; +import type {BaseApplicationCreateRenderProps} from '../../../components/admin/ApplicationCreate/BaseApplicationCreate'; +import ApplicationDetails from '../../../components/admin/ApplicationDetails/ApplicationDetails'; +import BaseApplicationDetails from '../../../components/admin/ApplicationDetails/BaseApplicationDetails'; +import type {BaseApplicationDetailsRenderProps} from '../../../components/admin/ApplicationDetails/BaseApplicationDetails'; +import ApplicationList from '../../../components/admin/ApplicationList/ApplicationList'; +import BaseApplicationList from '../../../components/admin/ApplicationList/BaseApplicationList'; import BaseGroupCreate from '../../../components/admin/GroupCreate/BaseGroupCreate'; import type {BaseGroupCreateRenderProps} from '../../../components/admin/GroupCreate/BaseGroupCreate'; +import GroupCreate from '../../../components/admin/GroupCreate/GroupCreate'; import GroupList from '../../../components/admin/GroupList/GroupList'; import BaseGroupMembers from '../../../components/admin/GroupMembers/BaseGroupMembers'; import type {BaseGroupMembersRenderProps} from '../../../components/admin/GroupMembers/BaseGroupMembers'; -import RoleCreate from '../../../components/admin/RoleCreate/RoleCreate'; +import GroupMembers from '../../../components/admin/GroupMembers/GroupMembers'; +import type {BaseRoleAssignmentsRenderProps} from '../../../components/admin/RoleAssignments/BaseRoleAssignments'; import RoleAssignments from '../../../components/admin/RoleAssignments/RoleAssignments'; +import RoleCreate from '../../../components/admin/RoleCreate/RoleCreate'; +import RoleList from '../../../components/admin/RoleList/RoleList'; import BaseRolePermissions from '../../../components/admin/RolePermissions/BaseRolePermissions'; import type {BaseRolePermissionsRenderProps} from '../../../components/admin/RolePermissions/BaseRolePermissions'; +import RolePermissions from '../../../components/admin/RolePermissions/RolePermissions'; import type {AdminListRenderProps} from '../../../components/admin/shared/createAdminList'; import BaseUserCreate from '../../../components/admin/UserCreate/BaseUserCreate'; import type {BaseUserCreateRenderProps} from '../../../components/admin/UserCreate/BaseUserCreate'; +import UserCreate from '../../../components/admin/UserCreate/UserCreate'; import BaseUserDetails from '../../../components/admin/UserDetails/BaseUserDetails'; import type {BaseUserDetailsRenderProps} from '../../../components/admin/UserDetails/BaseUserDetails'; import UserDetails from '../../../components/admin/UserDetails/UserDetails'; import BaseUserList from '../../../components/admin/UserList/BaseUserList'; import UserList from '../../../components/admin/UserList/UserList'; -import {createManagementContext, jsonResponse, translate} from '../../helpers/managementContext'; +import { + type ManagementTestContext, + createManagementContext, + jsonResponse, + translate, +} from '../../helpers/managementContext'; import {chooseOption, findAllSelectControls} from '../../helpers/selectControl'; const mounted: {unmount: () => void}[] = []; @@ -47,6 +77,18 @@ const mountWith = ( return {context, wrapper}; }; +/** The keys the queries mounted on a context subscribed with, in order. */ +const subscribedKeysOf = (context: ManagementTestContext) => { + const subscribe = vi.spyOn(context.thunderID.invalidator, 'subscribe'); + + return (): ResourceQueryKey[] => + subscribe.mock.calls.map((call) => JSON.parse(JSON.stringify(call[0])) as ResourceQueryKey); +}; + +/** The key prefixes a spied `invalidate` was called with, in order. */ +const invalidatedKeys = (invalidate: {mock: {calls: unknown[][]}}): unknown[] => + invalidate.mock.calls.map((call) => call[0]); + const users = [ {attributes: {given_name: 'Jane', family_name: 'Doe'}, id: 'u1', ouHandle: 'default', ouId: 'ou-1', type: 'person'}, {display: 'John Roe', id: 'u2', ouId: 'ou-1', type: 'person'}, @@ -290,6 +332,7 @@ describe('admin components (vue)', () => { it("RoleCreate creates the role in the signed-in user's organization unit and emits created", async () => { const context = createManagementContext(); + const invalidate = vi.spyOn(context.thunderID.invalidator, 'invalidate'); context.fetcher.mockResolvedValue(jsonResponse({id: 'r1', name: 'Auditor', ouId: 'ou-1', permissions: []}, 201)); const {wrapper} = mountWith(RoleCreate, {}, context); @@ -300,6 +343,380 @@ describe('admin components (vue)', () => { expect(context.fetcher.mock.calls[0][0]).toBe('https://thunder.example/roles'); expect(JSON.parse(context.fetcher.mock.calls[0][1].body as string)).toEqual({name: 'Auditor', ouId: 'ou-1'}); expect(wrapper.emitted('created')![0][0]).toMatchObject({id: 'r1'}); + expect(invalidatedKeys(invalidate)).toEqual([[RoleQueryKeys.ROLES], [RoleQueryKeys.ROLE_ASSIGNMENTS]]); + }); + }); + + describe('connected components data loading and mutations', () => { + const page = {count: 0, startIndex: 1, totalResults: 0}; + + /** Answers each request with an empty page of every list shape, or the given resource. */ + const respondWith = + (resolve: (url: URL, init: RequestInit) => unknown = () => undefined) => + (url: string, init: RequestInit): Promise => { + const body: unknown = resolve(new URL(url), init); + + if (body === null) return Promise.resolve(jsonResponse(null, 204)); + + return Promise.resolve( + jsonResponse( + body ?? {...page, agents: [], assignments: [], groups: [], members: [], roles: [], types: [], users: []}, + ), + ); + }; + + const requests = (context: ManagementTestContext): string[] => + context.fetcher.mock.calls.map((call) => { + const url = new URL(call[0]); + + return `${call[1].method ?? 'GET'} ${url.pathname}${url.search}`; + }); + + const subscribedKeys = (context: ManagementTestContext) => { + const subscribe = vi.spyOn(context.thunderID.invalidator, 'subscribe'); + + return (): ResourceQueryKey[] => + subscribe.mock.calls.map((call) => JSON.parse(JSON.stringify(call[0])) as ResourceQueryKey); + }; + + it('RoleList fetches a page of roles under the roles key', async () => { + const context = createManagementContext(); + const keys = subscribedKeys(context); + context.fetcher.mockImplementation( + respondWith(() => ({...page, roles: [{id: 'r1', name: 'Auditor', ouId: 'ou-1'}], totalResults: 1})), + ); + const {wrapper} = mountWith(RoleList, {}, context); + + await vi.waitFor(() => expect(wrapper.text()).toContain('Auditor')); + expect(requests(context)).toEqual(['GET /roles?include=display&limit=20&offset=0']); + expect(keys()).toEqual([[RoleQueryKeys.ROLES, {limit: 20, offset: 0}]]); + }); + + it('UserDetails keys the user and their groups, and updates through PUT', async () => { + const context = createManagementContext(); + const keys = subscribedKeys(context); + const invalidate = vi.spyOn(context.thunderID.invalidator, 'invalidate'); + context.fetcher.mockImplementation( + respondWith((url, init) => (url.pathname === '/users/u2' || init.method === 'PUT' ? users[1] : undefined)), + ); + let renderProps: BaseUserDetailsRenderProps | undefined; + mountWith(UserDetails, {editable: true, userId: 'u2'}, context, { + default: (props: BaseUserDetailsRenderProps) => { + renderProps = props; + + return h('span', props.displayName); + }, + }); + + await vi.waitFor(() => expect(renderProps?.user?.id).toBe('u2')); + expect(keys()).toEqual([ + [UserQueryKeys.USER, 'u2'], + [GroupQueryKeys.MEMBERSHIPS, 'user', 'u2', {limit: 100}], + ]); + expect(requests(context)).toEqual( + expect.arrayContaining(['GET /users/u2?include=display', 'GET /users/u2/groups?limit=100']), + ); + + renderProps!.onUpdate!({email: 'john@example.com'}); + + await vi.waitFor(() => expect(invalidate).toHaveBeenCalled()); + const update = context.fetcher.mock.calls.find((call) => call[1].method === 'PUT')!; + expect(update[0]).toBe('https://thunder.example/users/u2'); + expect(JSON.parse(update[1].body as string)).toEqual({ + attributes: {email: 'john@example.com'}, + ouId: 'ou-1', + type: 'person', + }); + expect(invalidatedKeys(invalidate)).toEqual([[UserQueryKeys.USER, 'u2'], [UserQueryKeys.USERS]]); + }); + + it('UserDetails invalidates the users lists after a delete', async () => { + const context = createManagementContext(); + const invalidate = vi.spyOn(context.thunderID.invalidator, 'invalidate'); + context.fetcher.mockImplementation( + respondWith((url, init) => + init.method === 'DELETE' ? null : url.pathname === '/users/u2' ? users[1] : undefined, + ), + ); + let renderProps: BaseUserDetailsRenderProps | undefined; + const {wrapper} = mountWith(UserDetails, {deletable: true, userId: 'u2'}, context, { + default: (props: BaseUserDetailsRenderProps) => { + renderProps = props; + + return h('span', props.displayName); + }, + }); + + await vi.waitFor(() => expect(renderProps?.user?.id).toBe('u2')); + renderProps!.onDelete!(); + + await vi.waitFor(() => expect(wrapper.emitted('deleted')).toHaveLength(1)); + expect(invalidatedKeys(invalidate)).toEqual([[UserQueryKeys.USERS]]); + }); + + it('AgentDetails loads the agent, its groups and roles, and deletes it', async () => { + const context = createManagementContext(); + const keys = subscribedKeys(context); + const invalidate = vi.spyOn(context.thunderID.invalidator, 'invalidate'); + context.fetcher.mockImplementation( + respondWith((url, init) => { + if (init.method === 'DELETE') return null; + if (url.pathname === '/agents/a1') return {id: 'a1', name: 'Concierge', ouId: 'ou-1', type: 'assistant'}; + if (url.pathname === '/agents/a1/roles') return {...page, roles: ['viewer']}; + + return {...page, groups: [{id: 'g1', name: 'Ops', ouId: 'ou-1'}]}; + }), + ); + let renderProps: BaseAgentDetailsRenderProps | undefined; + const {wrapper} = mountWith(AgentDetails, {agentId: 'a1', deletable: true}, context, { + default: (props: BaseAgentDetailsRenderProps) => { + renderProps = props; + + return h('span', props.agent?.name); + }, + }); + + await vi.waitFor(() => { + expect(renderProps?.agent?.name).toBe('Concierge'); + expect(renderProps?.roles).toEqual(['viewer']); + expect(renderProps?.groups.map((group) => group.name)).toEqual(['Ops']); + }); + expect(keys()).toEqual([ + [AgentQueryKeys.AGENT, 'a1'], + [GroupQueryKeys.MEMBERSHIPS, 'agent', 'a1', {limit: 100}], + [RoleQueryKeys.ROLE_ASSIGNMENTS, 'agent', 'a1', {limit: 100}], + ]); + expect(requests(context)).toEqual( + expect.arrayContaining([ + 'GET /agents/a1?include=display', + 'GET /agents/a1/groups?limit=100', + 'GET /agents/a1/roles?limit=100', + ]), + ); + + renderProps!.onDelete!(); + + await vi.waitFor(() => expect(wrapper.emitted('deleted')).toHaveLength(1)); + expect(requests(context)).toContain('DELETE /agents/a1'); + expect(invalidatedKeys(invalidate)).toEqual([[AgentQueryKeys.AGENTS]]); + }); + + it('GroupCreate creates the group and invalidates group and membership lists', async () => { + const context = createManagementContext(); + const invalidate = vi.spyOn(context.thunderID.invalidator, 'invalidate'); + context.fetcher.mockResolvedValue(jsonResponse({id: 'g1', name: 'Ops', ouId: 'ou-1'}, 201)); + const {wrapper} = mountWith(GroupCreate, {}, context); + + await wrapper.findAll('input')[0].setValue('Ops'); + await wrapper.find('form').trigger('submit'); + + await vi.waitFor(() => expect(wrapper.emitted('created')).toHaveLength(1)); + expect(requests(context)).toEqual(['POST /groups']); + expect(JSON.parse(context.fetcher.mock.calls[0][1].body as string)).toEqual({name: 'Ops', ouId: 'ou-1'}); + expect(invalidatedKeys(invalidate)).toEqual([[GroupQueryKeys.GROUPS], [GroupQueryKeys.MEMBERSHIPS]]); + }); + + it('AgentCreate offers the agent types and creates the agent', async () => { + const context = createManagementContext(); + const keys = subscribedKeys(context); + const invalidate = vi.spyOn(context.thunderID.invalidator, 'invalidate'); + context.fetcher.mockImplementation( + respondWith((_url, init) => + init.method === 'POST' + ? {id: 'a1', name: 'Concierge', ouId: 'ou-1', type: 'assistant'} + : {...page, types: [{displayName: 'Assistant', handle: 'assistant'}]}, + ), + ); + let renderProps: BaseAgentCreateRenderProps | undefined; + const {wrapper} = mountWith(AgentCreate, {}, context, { + default: (props: BaseAgentCreateRenderProps) => { + renderProps = props; + + return h('span', props.agentTypes.map((option) => option.label).join(',')); + }, + }); + + await vi.waitFor(() => expect(wrapper.text()).toBe('Assistant')); + expect(requests(context)).toEqual(['GET /agent-types?include=display&limit=100']); + expect(keys()).toEqual([[AgentQueryKeys.AGENT_TYPES, {limit: 100}]]); + + renderProps!.handleInputChange('name', 'Concierge'); + renderProps!.handleInputChange('type', 'assistant'); + await nextTick(); + renderProps!.handleSubmit(); + + await vi.waitFor(() => expect(wrapper.emitted('created')).toHaveLength(1)); + const create = context.fetcher.mock.calls.find((call) => call[1].method === 'POST')!; + expect(create[0]).toBe('https://thunder.example/agents'); + expect(JSON.parse(create[1].body as string)).toMatchObject({name: 'Concierge', ouId: 'ou-1', type: 'assistant'}); + expect(invalidatedKeys(invalidate)).toEqual([[AgentQueryKeys.AGENTS]]); + }); + + it('UserCreate offers the user types and creates the user', async () => { + const context = createManagementContext(); + const keys = subscribedKeys(context); + const invalidate = vi.spyOn(context.thunderID.invalidator, 'invalidate'); + context.fetcher.mockImplementation( + respondWith((_url, init) => + init.method === 'POST' ? users[1] : {...page, types: [{displayName: 'Person', handle: 'person'}]}, + ), + ); + let renderProps: BaseUserCreateRenderProps | undefined; + const {wrapper} = mountWith(UserCreate, {}, context, { + default: (props: BaseUserCreateRenderProps) => { + renderProps = props; + + return h('span', props.userTypes.map((option) => option.label).join(',')); + }, + }); + + await vi.waitFor(() => expect(wrapper.text()).toBe('Person')); + expect(requests(context)).toEqual(['GET /user-types?include=display&limit=100']); + expect(keys()).toEqual([[UserQueryKeys.USER_TYPES, {limit: 100}]]); + + renderProps!.handleInputChange('username', 'john'); + renderProps!.handleInputChange('email', 'john@example.com'); + await nextTick(); + renderProps!.handleSubmit(); + + await vi.waitFor(() => expect(wrapper.emitted('created')).toHaveLength(1)); + const create = context.fetcher.mock.calls.find((call) => call[1].method === 'POST')!; + expect(create[0]).toBe('https://thunder.example/users'); + expect(JSON.parse(create[1].body as string)).toEqual({ + attributes: {email: 'john@example.com', username: 'john'}, + ouId: 'ou-1', + type: 'person', + }); + expect(invalidatedKeys(invalidate)).toEqual([[UserQueryKeys.USERS]]); + }); + + it('GroupMembers loads members, fetches candidates only when editable, and adds and removes members', async () => { + const readOnly = createManagementContext(); + const readOnlyKeys = subscribedKeys(readOnly); + readOnly.fetcher.mockImplementation(respondWith()); + mountWith(GroupMembers, {groupId: 'g1'}, readOnly); + + await vi.waitFor(() => expect(readOnly.fetcher).toHaveBeenCalled()); + await nextTick(); + expect(requests(readOnly)).toEqual(['GET /groups/g1/members?include=display&limit=100']); + expect(readOnlyKeys()).toEqual([[GroupQueryKeys.GROUP_MEMBERS, 'g1', {limit: 100}]]); + + const context = createManagementContext(); + const keys = subscribedKeys(context); + const invalidate = vi.spyOn(context.thunderID.invalidator, 'invalidate'); + context.fetcher.mockImplementation( + respondWith((_url, init) => (init.method === 'POST' ? {id: 'g1', name: 'Ops', ouId: 'ou-1'} : undefined)), + ); + let renderProps: BaseGroupMembersRenderProps | undefined; + mountWith(GroupMembers, {editable: true, groupId: 'g1'}, context, { + default: (props: BaseGroupMembersRenderProps) => { + renderProps = props; + + return h('span'); + }, + }); + + await vi.waitFor(() => expect(context.fetcher).toHaveBeenCalledTimes(3)); + expect(requests(context)).toEqual( + expect.arrayContaining([ + 'GET /groups/g1/members?include=display&limit=100', + 'GET /users?include=display&limit=100', + 'GET /agents?include=display&limit=100', + ]), + ); + expect(keys()).toEqual([ + [GroupQueryKeys.GROUP_MEMBERS, 'g1', {limit: 100}], + [UserQueryKeys.USERS, {limit: 100}], + [AgentQueryKeys.AGENTS, {limit: 100}], + ]); + const membershipKeys = [ + [GroupQueryKeys.GROUP, 'g1'], + [GroupQueryKeys.GROUP_MEMBERS, 'g1'], + [GroupQueryKeys.MEMBERSHIPS], + [RoleQueryKeys.ROLE_ASSIGNMENTS], + ]; + + renderProps!.onAdd!({id: 'u1', type: 'user'}); + await vi.waitFor(() => expect(invalidate).toHaveBeenCalledTimes(4)); + expect(requests(context)).toContain('POST /groups/g1/members/add'); + expect(invalidatedKeys(invalidate)).toEqual(membershipKeys); + + invalidate.mockClear(); + renderProps!.onRemove!({display: 'Jane Doe', id: 'u1', type: 'user'}); + await vi.waitFor(() => expect(invalidate).toHaveBeenCalledTimes(4)); + expect(requests(context)).toContain('POST /groups/g1/members/remove'); + expect(invalidatedKeys(invalidate)).toEqual(membershipKeys); + }); + + it('RoleAssignments loads assignments and candidates, and assigns and unassigns', async () => { + const context = createManagementContext(); + const keys = subscribedKeys(context); + const invalidate = vi.spyOn(context.thunderID.invalidator, 'invalidate'); + context.fetcher.mockImplementation(respondWith((_url, init) => (init.method === 'POST' ? null : undefined))); + let renderProps: BaseRoleAssignmentsRenderProps | undefined; + mountWith(RoleAssignments, {editable: true, roleId: 'r1'}, context, { + default: (props: BaseRoleAssignmentsRenderProps) => { + renderProps = props; + + return h('span'); + }, + }); + + await vi.waitFor(() => expect(context.fetcher).toHaveBeenCalledTimes(4)); + expect(requests(context)).toEqual( + expect.arrayContaining([ + 'GET /roles/r1/assignments?include=display&limit=100', + 'GET /users?include=display&limit=100', + 'GET /groups?include=display&limit=100', + 'GET /agents?include=display&limit=100', + ]), + ); + expect(keys()).toEqual([ + [RoleQueryKeys.ROLE_ASSIGNMENTS, 'r1', {limit: 100}], + [UserQueryKeys.USERS, {limit: 100}], + [GroupQueryKeys.GROUPS, {limit: 100}], + [AgentQueryKeys.AGENTS, {limit: 100}], + ]); + + renderProps!.onTypeFilterChange!('group'); + await vi.waitFor(() => + expect(keys()).toContainEqual([RoleQueryKeys.ROLE_ASSIGNMENTS, 'r1', {limit: 100, type: 'group'}]), + ); + + renderProps!.onAssign!({id: 'g1', type: 'group'}); + await vi.waitFor(() => expect(invalidate).toHaveBeenCalledTimes(1)); + expect(requests(context)).toContain('POST /roles/r1/assignments/add'); + expect(invalidatedKeys(invalidate)).toEqual([[RoleQueryKeys.ROLE_ASSIGNMENTS]]); + + renderProps!.onUnassign!({display: 'Ops', id: 'g1', type: 'group'}); + await vi.waitFor(() => expect(invalidate).toHaveBeenCalledTimes(2)); + expect(requests(context)).toContain('POST /roles/r1/assignments/remove'); + expect(invalidatedKeys(invalidate)).toEqual([[RoleQueryKeys.ROLE_ASSIGNMENTS], [RoleQueryKeys.ROLE_ASSIGNMENTS]]); + }); + + it('RolePermissions loads the role under its key', async () => { + const context = createManagementContext(); + const keys = subscribedKeys(context); + context.fetcher.mockResolvedValue( + jsonResponse({ + id: 'r1', + name: 'Auditor', + ouId: 'ou-1', + permissions: [{permissions: ['read'], resourceServerId: 'rs-1'}], + }), + ); + let renderProps: BaseRolePermissionsRenderProps | undefined; + mountWith(RolePermissions, {roleId: 'r1'}, context, { + default: (props: BaseRolePermissionsRenderProps) => { + renderProps = props; + + return h('span'); + }, + }); + + await vi.waitFor(() => expect(renderProps?.permissions).toHaveLength(1)); + expect(requests(context)).toEqual(['GET /roles/r1?include=display']); + expect(keys()).toEqual([[RoleQueryKeys.ROLE, 'r1']]); }); }); @@ -474,7 +891,7 @@ describe('admin components (vue)', () => { describe('search and filters', () => { const lastUrl = (context: ReturnType): URL => - new URL(context.fetcher.mock.calls[context.fetcher.mock.calls.length - 1][0] as string); + new URL(context.fetcher.mock.calls[context.fetcher.mock.calls.length - 1][0]); it('searches users on the server by the chosen attribute and clears the search', async () => { const context = createManagementContext(); @@ -533,4 +950,404 @@ describe('admin components (vue)', () => { await vi.waitFor(() => expect(lastUrl(context).searchParams.get('type')).toBe('group')); }); }); + + describe('applications', () => { + const application = { + description: 'Customer portal', + id: 'app-1', + inboundAuthConfig: [ + { + config: { + clientId: 'portal-client', + clientSecret: 'stored-secret', + grantTypes: ['authorization_code', 'refresh_token'], + redirectUris: ['https://portal.example/callback'], + responseTypes: ['code'], + }, + type: 'oauth2', + }, + ], + name: 'Portal', + ouId: 'ou-1', + template: 'browser', + url: 'https://portal.example', + }; + const listPage = { + applications: [{clientId: 'portal-client', id: 'app-1', name: 'Portal', template: 'browser'}], + count: 1, + totalResults: 1, + }; + + const routeApplications = + (created: unknown = application) => + (url: string, init: RequestInit): Promise => { + const {pathname} = new URL(url); + + if (init.method === 'DELETE') return Promise.resolve(jsonResponse(null, 204)); + if (init.method === 'POST') return Promise.resolve(jsonResponse(created, 201)); + if (init.method === 'PUT') return Promise.resolve(jsonResponse(JSON.parse(init.body as string))); + if (pathname === '/applications/app-1') return Promise.resolve(jsonResponse(application)); + + return Promise.resolve(jsonResponse(listPage)); + }; + + const requestsOf = (context: ManagementTestContext): string[] => + context.fetcher.mock.calls.map((call) => { + const url = new URL(call[0]); + + return `${call[1].method ?? 'GET'} ${url.pathname}${url.search}`; + }); + + const field = (wrapper: {find: (selector: string) => {setValue: (value: string) => Promise}}, name: string) => + wrapper.find(`input[name$="-${name}"]`); + + const buttonNamed = (wrapper: {findAll: (selector: string) => {text: () => string}[]}, label: string) => + wrapper.findAll('button').find((button) => button.text() === label) as unknown as { + attributes: (name: string) => string | undefined; + trigger: (event: string) => Promise; + }; + + it('BaseApplicationList shows each application with its client ID and type', () => { + const {wrapper} = mountWith(BaseApplicationList, {applications: listPage.applications}); + + expect(wrapper.find('caption').text()).toBe(translate('admin.applications.list.title')); + expect(wrapper.findAll('th').map((th) => th.text())).toEqual([ + translate('admin.applications.list.columns.name'), + translate('admin.applications.list.columns.client_id'), + translate('admin.applications.list.columns.type'), + ]); + expect(wrapper.find('tbody tr').text()).toContain('portal-client'); + expect(wrapper.find('tbody tr').text()).toContain('browser'); + }); + + it('BaseApplicationList shows a translated status column only when the server reports a status', () => { + const {wrapper} = mountWith(BaseApplicationList, { + applications: [ + {id: 'app-1', name: 'Portal', status: {value: 'ACTIVE'}}, + {id: 'app-2', name: 'Legacy', status: {value: 'SUSPENDED'}}, + ], + }); + + expect(wrapper.findAll('th').map((th) => th.text())).toContain( + translate('admin.applications.list.columns.status'), + ); + const rows = wrapper.findAll('tbody tr'); + expect(rows[0].text()).toContain(translate('admin.applications.status.active')); + expect(rows[1].text()).toContain(translate('admin.applications.status.suspended')); + + const {wrapper: withoutStatus} = mountWith(BaseApplicationList, {applications: listPage.applications}); + expect(withoutStatus.findAll('th').map((th) => th.text())).not.toContain( + translate('admin.applications.list.columns.status'), + ); + }); + + it('ApplicationList fetches a page under the applications key and hands it to its slot', async () => { + const context = createManagementContext(); + context.fetcher.mockImplementation(routeApplications()); + const {wrapper} = mountWith(ApplicationList, {limit: 5}, context, { + default: (props: AdminListRenderProps & {applications: BasicApplication[]}) => + h( + 'p', + `${props.applications.map((row) => row.name).join()} ${props.columns.map((column) => column.id).join()}`, + ), + }); + + await vi.waitFor(() => expect(wrapper.text()).toBe('Portal name,clientId,type')); + expect(requestsOf(context)).toEqual(['GET /applications?limit=5&offset=0']); + }); + + it('BaseApplicationCreate needs a redirect URI for the authorization code grant and emits the OAuth basics', async () => { + const {wrapper} = mountWith(BaseApplicationCreate, {}); + const submit = () => buttonNamed(wrapper, translate('admin.applications.create.submit')); + + await field(wrapper, 'name').setValue(' Portal '); + expect(submit().attributes('disabled')).toBeDefined(); + await field(wrapper, 'redirectUris').setValue('https://a.example/cb, https://b.example/cb'); + expect(submit().attributes('disabled')).toBeUndefined(); + await wrapper.find('input[name$="-public-client"]').setValue(true); + await wrapper.find('form').trigger('submit'); + + expect(wrapper.emitted('submit')?.[0]).toEqual([ + { + description: undefined, + grantTypes: ['authorization_code', 'refresh_token'], + logoUrl: undefined, + name: 'Portal', + publicClient: true, + redirectUris: ['https://a.example/cb', 'https://b.example/cb'], + url: undefined, + }, + ]); + + await wrapper.find('input[name$="-grant-type-client_credentials"]').setValue(true); + expect(submit().attributes('disabled')).toBeDefined(); + }); + + it('BaseApplicationCreate hands its slot the values, setters and grant types', async () => { + let form: BaseApplicationCreateRenderProps | undefined; + const {wrapper} = mountWith(BaseApplicationCreate, {}, createManagementContext(), { + default: (props: BaseApplicationCreateRenderProps) => { + form = props; + + return h('p', props.grantTypeOptions.map((option) => option.label).join(',')); + }, + }); + + expect(wrapper.text()).toBe( + ['authorization_code', 'refresh_token', 'client_credentials'] + .map((grantType) => translate(`admin.applications.grant_types.${grantType}`)) + .join(','), + ); + form!.handleInputChange('name', 'Daemon'); + form!.handleGrantTypesChange(['client_credentials']); + await nextTick(); + expect(form!.isValid).toBe(true); + form!.handleSubmit(); + expect(wrapper.emitted('submit')?.[0][0]).toMatchObject({grantTypes: ['client_credentials'], name: 'Daemon'}); + }); + + it('ApplicationCreate creates the application and shows the returned secret once before emitting created', async () => { + const context = createManagementContext(); + const invalidate = vi.spyOn(context.thunderID.invalidator, 'invalidate'); + context.fetcher.mockImplementation( + routeApplications({ + ...application, + id: 'app-9', + inboundAuthConfig: [{config: {clientSecret: 's3cr3t', grantTypes: [], responseTypes: []}, type: 'oauth2'}], + }), + ); + const {wrapper} = mountWith(ApplicationCreate, {}, context); + + await field(wrapper, 'name').setValue('Portal'); + await field(wrapper, 'redirectUris').setValue('https://p.example/cb'); + await wrapper.find('form').trigger('submit'); + + await vi.waitFor(() => + expect(wrapper.findAll('input').some((input) => (input.element as HTMLInputElement).value === 's3cr3t')).toBe( + true, + ), + ); + expect(wrapper.emitted('created')).toBeUndefined(); + expect(requestsOf(context)).toEqual(['POST /applications']); + expect(context.fetcher.mock.calls[0][0]).toBe('https://thunder.example/applications'); + expect(JSON.parse(context.fetcher.mock.calls[0][1].body as string)).toEqual({ + inboundAuthConfig: [ + { + config: { + grantTypes: ['authorization_code', 'refresh_token'], + publicClient: false, + redirectUris: ['https://p.example/cb'], + responseTypes: ['code'], + tokenEndpointAuthMethod: 'client_secret_basic', + }, + type: 'oauth2', + }, + ], + name: 'Portal', + ouId: 'ou-1', + }); + expect(invalidatedKeys(invalidate)).toEqual([[ApplicationQueryKeys.APPLICATIONS]]); + + await buttonNamed(wrapper, translate('admin.applications.secret.done')).trigger('click'); + + expect(wrapper.emitted('created')?.[0][0]).toMatchObject({id: 'app-9'}); + expect(wrapper.text()).not.toContain('s3cr3t'); + expect((wrapper.find('input[name$="-name"]').element as HTMLInputElement).value).toBe(''); + }); + + it('BaseApplicationDetails shows the OAuth configuration without the stored secret and no actions by default', () => { + const {wrapper} = mountWith(BaseApplicationDetails, {application}); + + expect(wrapper.find('h2').text()).toBe('Portal'); + expect(wrapper.find(`ul[aria-label="${translate('admin.applications.fields.redirect_uris')}"]`).text()).toContain( + 'https://portal.example/callback', + ); + expect(wrapper.html()).not.toContain('stored-secret'); + expect(wrapper.findAll('button').map((button) => button.text())).not.toEqual( + expect.arrayContaining([ + translate('admin.applications.details.edit.action'), + translate('admin.applications.details.delete.action'), + translate('admin.applications.details.secret.action'), + ]), + ); + }); + + it('BaseApplicationDetails confirms deletion and secret regeneration, and edits the OAuth basics', async () => { + const onDelete = vi.fn(); + const onRegenerateSecret = vi.fn(); + const onUpdate = vi.fn(); + const {wrapper} = mountWith(BaseApplicationDetails, {application, onDelete, onRegenerateSecret, onUpdate}); + + await buttonNamed(wrapper, translate('admin.applications.details.secret.action')).trigger('click'); + expect(onRegenerateSecret).not.toHaveBeenCalled(); + await wrapper.find('[role="group"]').findAll('button')[1].trigger('click'); + expect(onRegenerateSecret).toHaveBeenCalledTimes(1); + + await buttonNamed(wrapper, translate('admin.applications.details.delete.action')).trigger('click'); + const confirm = wrapper + .findAll('[role="group"]') + .find( + (group) => + group.attributes('aria-label') === translate('admin.applications.details.delete.confirm', {name: 'Portal'}), + )!; + expect(onDelete).not.toHaveBeenCalled(); + await confirm.findAll('button')[1].trigger('click'); + expect(onDelete).toHaveBeenCalledTimes(1); + + await buttonNamed(wrapper, translate('admin.applications.details.edit.action')).trigger('click'); + await field(wrapper, 'name').setValue('Portal 2'); + await wrapper.find('input[name$="-grant-type-refresh_token"]').setValue(false); + await wrapper.find('form').trigger('submit'); + expect(onUpdate).toHaveBeenCalledWith({ + description: 'Customer portal', + grantTypes: ['authorization_code'], + logoUrl: undefined, + name: 'Portal 2', + redirectUris: ['https://portal.example/callback'], + url: 'https://portal.example', + }); + }); + + it('BaseApplicationDetails hands its slot only the allowed actions and never the stored secret', () => { + let details: BaseApplicationDetailsRenderProps | undefined; + mountWith( + BaseApplicationDetails, + { + application: { + ...application, + inboundAuthConfig: [{config: {grantTypes: [], publicClient: true, responseTypes: []}, type: 'oauth2'}], + }, + onDelete: vi.fn(), + onRegenerateSecret: vi.fn(), + }, + createManagementContext(), + { + default: (props: BaseApplicationDetailsRenderProps) => { + details = props; + + return h('span', props.application?.name); + }, + }, + ); + + expect(details!.onDelete).toBeTypeOf('function'); + expect(details!.onUpdate).toBeUndefined(); + // A public client has no secret to regenerate. + expect(details!.onRegenerateSecret).toBeUndefined(); + + mountWith( + BaseApplicationDetails, + {application: {...application, isReadOnly: true}, onDelete: vi.fn()}, + undefined, + { + default: (props: BaseApplicationDetailsRenderProps) => { + details = props; + + return h('span'); + }, + }, + ); + expect(details!.onDelete).toBeUndefined(); + expect(details!.oauth).not.toHaveProperty('clientSecret'); + }); + + it('ApplicationDetails loads the application, saves an edit as a full update and invalidates its keys', async () => { + const context = createManagementContext(); + const keys = subscribedKeysOf(context); + const invalidate = vi.spyOn(context.thunderID.invalidator, 'invalidate'); + context.fetcher.mockImplementation(routeApplications()); + let details: BaseApplicationDetailsRenderProps | undefined; + mountWith(ApplicationDetails, {applicationId: 'app-1', editable: true}, context, { + default: (props: BaseApplicationDetailsRenderProps) => { + details = props; + + return h('span', props.application?.name); + }, + }); + + await vi.waitFor(() => expect(details?.application?.name).toBe('Portal')); + expect(keys()).toEqual([[ApplicationQueryKeys.APPLICATION, 'app-1']]); + expect(requestsOf(context)).toEqual(['GET /applications/app-1']); + expect(details!.onDelete).toBeUndefined(); + expect(details!.onRegenerateSecret).toBeUndefined(); + + details!.onUpdate!({ + grantTypes: ['authorization_code'], + name: 'Portal 2', + redirectUris: ['https://portal.example/callback'], + }); + + await vi.waitFor(() => expect(requestsOf(context)).toContain('PUT /applications/app-1')); + const put = context.fetcher.mock.calls.find((call) => call[1].method === 'PUT')!; + const {id, ...rest} = application; + void id; + expect(JSON.parse(put[1].body as string)).toEqual({ + ...rest, + description: undefined, + inboundAuthConfig: [ + { + ...rest.inboundAuthConfig[0], + config: {...rest.inboundAuthConfig[0].config, grantTypes: ['authorization_code']}, + }, + ], + name: 'Portal 2', + url: undefined, + }); + await vi.waitFor(() => + expect(invalidatedKeys(invalidate)).toEqual([ + [ApplicationQueryKeys.APPLICATION, 'app-1'], + [ApplicationQueryKeys.APPLICATIONS], + ]), + ); + }); + + it('ApplicationDetails regenerates the client secret and shows the new one once', async () => { + const context = createManagementContext(); + context.fetcher.mockImplementation(routeApplications()); + const {wrapper} = mountWith(ApplicationDetails, {applicationId: 'app-1', secretRegenerable: true}, context); + + await vi.waitFor(() => expect(wrapper.find('h2').text()).toBe('Portal')); + await buttonNamed(wrapper, translate('admin.applications.details.secret.action')).trigger('click'); + await wrapper.find('[role="group"]').findAll('button')[1].trigger('click'); + + await vi.waitFor(() => expect(requestsOf(context)).toContain('PUT /applications/app-1')); + const put = context.fetcher.mock.calls.find((call) => call[1].method === 'PUT')!; + const saved = JSON.parse(put[1].body as string) as Application; + const secret: string = saved.inboundAuthConfig![0].config.clientSecret!; + expect(secret).not.toBe('stored-secret'); + await vi.waitFor(() => + expect(wrapper.findAll('input').some((input) => (input.element as HTMLInputElement).value === secret)).toBe( + true, + ), + ); + + await buttonNamed(wrapper, translate('admin.applications.secret.done')).trigger('click'); + expect(wrapper.findAll('input').some((input) => (input.element as HTMLInputElement).value === secret)).toBe( + false, + ); + }); + + it('ApplicationDetails deletes through the API and shows translated server errors', async () => { + const context = createManagementContext(); + const invalidate = vi.spyOn(context.thunderID.invalidator, 'invalidate'); + context.fetcher.mockImplementation(routeApplications()); + const {wrapper} = mountWith(ApplicationDetails, {applicationId: 'app-1', deletable: true}, context); + + await vi.waitFor(() => expect(wrapper.find('h2').text()).toBe('Portal')); + await buttonNamed(wrapper, translate('admin.applications.details.delete.action')).trigger('click'); + await wrapper.find('[role="group"]').findAll('button')[1].trigger('click'); + + await vi.waitFor(() => expect(wrapper.emitted('deleted')).toHaveLength(1)); + expect(requestsOf(context)).toContain('DELETE /applications/app-1'); + expect(invalidatedKeys(invalidate)).toEqual([[ApplicationQueryKeys.APPLICATIONS]]); + + const refused = createManagementContext(); + refused.fetcher.mockResolvedValue(jsonResponse({code: 'APP-1', message: 'raw server detail'}, 403)); + const {wrapper: denied} = mountWith(ApplicationDetails, {applicationId: 'app-1'}, refused); + + await vi.waitFor(() => expect(denied.find('[role="alert"]').exists()).toBe(true)); + expect(denied.find('[role="alert"]').text()).toBe(translate('admin.common.errors.forbidden')); + expect(denied.text()).not.toContain('raw server detail'); + }); + }); }); diff --git a/packages/vue/src/__tests__/composables/management/management-composables.test.ts b/packages/vue/src/__tests__/composables/management/resource-query.test.ts similarity index 52% rename from packages/vue/src/__tests__/composables/management/management-composables.test.ts rename to packages/vue/src/__tests__/composables/management/resource-query.test.ts index f841e3b2..976569d3 100644 --- a/packages/vue/src/__tests__/composables/management/management-composables.test.ts +++ b/packages/vue/src/__tests__/composables/management/resource-query.test.ts @@ -1,21 +1,33 @@ // Copyright 2026 The ThunderID Authors // SPDX-License-Identifier: Apache-2.0 +import { + GroupQueryKeys, + RoleQueryKeys, + UserQueryKeys, + addGroupMembers, + createGroup, + deleteRole, + getGroups, + getSecret, + getUser, + getUsers, +} from '@thunderid/browser'; +import type { + CreateGroupRequest, + Group, + GroupListResponse, + ManagedUser, + ManagedUserListResponse, +} from '@thunderid/browser'; import {mount} from '@vue/test-utils'; import {afterEach, describe, expect, it, vi} from 'vitest'; import {defineComponent, h, nextTick, ref} from 'vue'; -import useGetAgentRoles from '../../../composables/management/agents/useGetAgentRoles'; -import useGetAgentTypes from '../../../composables/management/agents/useGetAgentTypes'; -import useAddGroupMembers from '../../../composables/management/groups/useAddGroupMembers'; -import useCreateGroup from '../../../composables/management/groups/useCreateGroup'; -import useGetGroupMembers from '../../../composables/management/groups/useGetGroupMembers'; -import useGetGroups from '../../../composables/management/groups/useGetGroups'; -import useDeleteRole from '../../../composables/management/roles/useDeleteRole'; -import useGetRoleAssignments from '../../../composables/management/roles/useGetRoleAssignments'; import useGrantedPermissions from '../../../composables/management/useGrantedPermissions'; -import useGetUser from '../../../composables/management/users/useGetUser'; -import useGetUserGroups from '../../../composables/management/users/useGetUserGroups'; -import useGetUsers from '../../../composables/management/users/useGetUsers'; +import useManagementEndpoint from '../../../composables/management/useManagementEndpoint'; +import useManagementRequestConfig from '../../../composables/management/useManagementRequestConfig'; +import useResourceMutation from '../../../composables/management/useResourceMutation'; +import useResourceQuery from '../../../composables/management/useResourceQuery'; import {type MockFetcher, createManagementContext, jsonResponse} from '../../helpers/managementContext'; const mounted: {unmount: () => void}[] = []; @@ -42,7 +54,22 @@ const calledUrls = (fetcher: MockFetcher): string[] => fetcher.mock.calls.map((c const page = {count: 0, startIndex: 1, totalResults: 0}; -describe('management composables (vue)', () => { +interface GroupMembersVariables { + groupId: string; + members: {id: string; type: 'user'}[]; +} + +/** A groups list query, as the connected admin components build one. */ +const useGroupsQuery = (params: () => {limit?: number; offset?: number} = () => ({})) => { + const endpoint = useManagementEndpoint('groups'); + + return useResourceQuery( + () => [GroupQueryKeys.GROUPS, params()], + (fetcher) => getGroups({...endpoint, fetcher, ...params()}), + ); +}; + +describe('management query layer (vue)', () => { afterEach(() => { mounted.splice(0).forEach((wrapper) => wrapper.unmount()); }); @@ -50,7 +77,15 @@ describe('management composables (vue)', () => { it('lists users from {baseUrl}/users through the provider fetcher', async () => { const context = createManagementContext(); context.fetcher.mockResolvedValue(jsonResponse({...page, users: [{id: 'u1'}]})); - const {result} = withSetup(() => useGetUsers({filter: 'username sw "a"', limit: 5, offset: 10}), context); + const {result} = withSetup(() => { + const endpoint = useManagementEndpoint('users'); + const params = {filter: 'username sw "a"', limit: 5, offset: 10}; + + return useResourceQuery( + () => [UserQueryKeys.USERS, params], + (fetcher) => getUsers({...endpoint, fetcher, ...params}), + ); + }, context); await vi.waitFor(() => expect(result.data.value?.users).toHaveLength(1)); const url = new URL(calledUrls(context.fetcher)[0]); @@ -61,11 +96,11 @@ describe('management composables (vue)', () => { expect(context.fetcher.mock.calls[0][1]).toMatchObject({method: 'GET'}); }); - it('honors an endpoints override for groups and refetches when the page changes', async () => { + it('honors an endpoints override for groups and refetches when the key changes', async () => { const context = createManagementContext({endpoints: {groups: 'https://rs.example/v2/groups'}}); context.fetcher.mockResolvedValue(jsonResponse({...page, groups: []})); const offset = ref(0); - withSetup(() => useGetGroups(() => ({limit: 10, offset: offset.value})), context); + withSetup(() => useGroupsQuery(() => ({limit: 10, offset: offset.value})), context); await vi.waitFor(() => expect(context.fetcher).toHaveBeenCalledTimes(1)); expect(calledUrls(context.fetcher)[0]).toMatch(/^https:\/\/rs\.example\/v2\/groups\?/); @@ -75,11 +110,19 @@ describe('management composables (vue)', () => { expect(new URL(calledUrls(context.fetcher)[1]).searchParams.get('offset')).toBe('10'); }); - it('waits for an identifier before fetching a single resource', async () => { + it('waits until enabled before fetching', async () => { const context = createManagementContext(); context.fetcher.mockResolvedValue(jsonResponse({id: 'u1', ouId: 'ou-1', type: 'person'})); const userId = ref(undefined); - const {result} = withSetup(() => useGetUser(userId), context); + const {result} = withSetup(() => { + const endpoint = useManagementEndpoint('users'); + + return useResourceQuery( + () => [UserQueryKeys.USER, userId.value], + (fetcher) => getUser({...endpoint, fetcher, userId: userId.value!}), + {enabled: () => Boolean(userId.value)}, + ); + }, context); await nextTick(); expect(context.fetcher).not.toHaveBeenCalled(); @@ -90,40 +133,9 @@ describe('management composables (vue)', () => { expect(new URL(calledUrls(context.fetcher)[0]).pathname).toBe('/users/u1'); }); - it('addresses the sub-resources of users, groups, roles and agents', async () => { - const context = createManagementContext(); - context.fetcher.mockImplementation((url: string) => - Promise.resolve( - jsonResponse({...page, assignments: [], groups: [], members: [], roles: [], types: []}, url ? 200 : 500), - ), - ); - withSetup(() => { - useGetUserGroups('u1', {limit: 100}); - useGetGroupMembers('g1'); - useGetRoleAssignments('r1', {type: 'agent'}); - useGetAgentRoles('a1'); - useGetAgentTypes(); - }, context); - - await vi.waitFor(() => expect(context.fetcher).toHaveBeenCalledTimes(5)); - const paths = calledUrls(context.fetcher).map((url) => { - const parsed = new URL(url); - - return `${parsed.pathname}${parsed.searchParams.get('type') ? `?type=${parsed.searchParams.get('type')}` : ''}`; - }); - expect(paths).toEqual( - expect.arrayContaining([ - '/users/u1/groups', - '/groups/g1/members', - '/roles/r1/assignments?type=agent', - '/agents/a1/roles', - '/agent-types', - ]), - ); - }); - - it('creates through POST and refetches the matching mounted lists', async () => { + it('mutates through POST, invalidates the returned key prefixes and refetches the matching queries', async () => { const context = createManagementContext(); + const invalidate = vi.spyOn(context.thunderID.invalidator, 'invalidate'); context.fetcher.mockImplementation((_url: string, init: RequestInit) => Promise.resolve( init.method === 'POST' @@ -132,7 +144,18 @@ describe('management composables (vue)', () => { ), ); const onSuccess = vi.fn(); - const {result} = withSetup(() => ({create: useCreateGroup({onSuccess}), list: useGetGroups()}), context); + const {result} = withSetup(() => { + const endpoint = useManagementEndpoint('groups'); + + return { + create: useResourceMutation( + (payload, fetcher) => createGroup({...endpoint, fetcher, payload}), + () => [[GroupQueryKeys.GROUPS], [GroupQueryKeys.MEMBERSHIPS]], + {onSuccess}, + ), + list: useGroupsQuery(() => ({limit: 10})), + }; + }, context); await vi.waitFor(() => expect(context.fetcher).toHaveBeenCalledTimes(1)); await result.create.mutate({name: 'Ops', ouId: 'ou-1'}); @@ -140,31 +163,90 @@ describe('management composables (vue)', () => { expect(context.fetcher.mock.calls[1][0]).toBe('https://thunder.example/groups'); expect(context.fetcher.mock.calls[1][1]).toMatchObject({method: 'POST'}); expect(JSON.parse(context.fetcher.mock.calls[1][1].body as string)).toEqual({name: 'Ops', ouId: 'ou-1'}); + expect(invalidate.mock.calls.map((call) => call[0])).toEqual([ + [GroupQueryKeys.GROUPS], + [GroupQueryKeys.MEMBERSHIPS], + ]); expect(onSuccess).toHaveBeenCalledWith(expect.objectContaining({id: 'g2'}), {name: 'Ops', ouId: 'ou-1'}); await vi.waitFor(() => expect(context.fetcher).toHaveBeenCalledTimes(3)); }); - it('adds group members through the members/add sub-resource', async () => { + it('passes the mutation variables to the request and to the invalidated keys', async () => { const context = createManagementContext(); + const invalidate = vi.spyOn(context.thunderID.invalidator, 'invalidate'); context.fetcher.mockResolvedValue(jsonResponse({id: 'g1', name: 'Ops', ouId: 'ou-1'})); - const {result} = withSetup(() => useAddGroupMembers(), context); + const {result} = withSetup(() => { + const endpoint = useManagementEndpoint('groups'); + + return useResourceMutation( + ({groupId, members}, fetcher) => addGroupMembers({...endpoint, fetcher, groupId, members}), + ({groupId}) => [[GroupQueryKeys.GROUP, groupId]], + ); + }, context); await result.mutateAsync({groupId: 'g1', members: [{id: 'u1', type: 'user'}]}); expect(context.fetcher.mock.calls[0][0]).toBe('https://thunder.example/groups/g1/members/add'); expect(context.fetcher.mock.calls[0][1]).toMatchObject({method: 'POST'}); + expect(invalidate).toHaveBeenCalledWith([GroupQueryKeys.GROUP, 'g1']); }); - it('uses a per-composable fetcher over the provider one, and reports a 403 as a typed error', async () => { + it('uses its own fetcher over the provider one, and reports a 403 as a typed error without invalidating', async () => { const context = createManagementContext(); + const invalidate = vi.spyOn(context.thunderID.invalidator, 'invalidate'); const own = vi.fn().mockResolvedValue(jsonResponse({code: 'ROL-1', message: 'nope'}, 403)); - const {result} = withSetup(() => useDeleteRole({fetcher: own}), context); + const onError = vi.fn(); + const {result} = withSetup(() => { + const endpoint = useManagementEndpoint('roles'); + + return useResourceMutation( + (roleId: string, fetcher) => deleteRole({...endpoint, fetcher, roleId}), + () => [[RoleQueryKeys.ROLES]], + {fetcher: own, onError}, + ); + }, context); await expect(result.mutateAsync('r1')).rejects.toMatchObject({statusCode: 403}); expect(context.fetcher).not.toHaveBeenCalled(); expect(own.mock.calls[0][0]).toBe('https://thunder.example/roles/r1'); expect(result.error.value).toMatchObject({statusCode: 403}); + expect(onError).toHaveBeenCalledWith(expect.objectContaining({statusCode: 403}), 'r1'); expect(await result.mutate('r1')).toBeUndefined(); + expect(invalidate).not.toHaveBeenCalled(); + }); + + it('subscribes a query under its key and stops listening once unmounted', async () => { + const context = createManagementContext(); + const subscribe = vi.spyOn(context.thunderID.invalidator, 'subscribe'); + context.fetcher.mockResolvedValue(jsonResponse({...page, groups: []})); + withSetup(() => useGroupsQuery(() => ({limit: 10})), context); + + await vi.waitFor(() => expect(context.fetcher).toHaveBeenCalledTimes(1)); + expect(subscribe.mock.calls[0][0]).toEqual([GroupQueryKeys.GROUPS, {limit: 10}]); + + mounted.splice(0).forEach((wrapper) => wrapper.unmount()); + context.thunderID.invalidator.invalidate([GroupQueryKeys.GROUPS]); + await nextTick(); + expect(context.fetcher).toHaveBeenCalledTimes(1); + }); + + it('builds a request config with the resolved endpoint and the provider fetcher', async () => { + const context = createManagementContext({endpoints: {users: 'https://rs.example/v2/users'}}); + context.fetcher.mockResolvedValue(jsonResponse({...page, users: []})); + const {result} = withSetup(() => useManagementRequestConfig('users'), context); + + expect(result).toMatchObject({baseUrl: 'https://thunder.example', url: 'https://rs.example/v2/users'}); + await getUsers({...result, limit: 1}); + expect(calledUrls(context.fetcher)[0]).toMatch(/^https:\/\/rs\.example\/v2\/users\?/); + }); + + it('addresses the newer management collections through their endpoints overrides', async () => { + const context = createManagementContext({endpoints: {secrets: 'https://vault.example/secrets'}}); + context.fetcher.mockResolvedValue(jsonResponse({})); + const {result} = withSetup(() => useManagementRequestConfig('secrets'), context); + + await getSecret({...result, name: 'SMTP_PASSWORD'}); + expect(calledUrls(context.fetcher)[0]).toBe('https://vault.example/secrets/SMTP_PASSWORD'); }); }); diff --git a/packages/vue/src/__tests__/helpers/managementContext.ts b/packages/vue/src/__tests__/helpers/managementContext.ts index 8ab52391..ed2524bc 100644 --- a/packages/vue/src/__tests__/helpers/managementContext.ts +++ b/packages/vue/src/__tests__/helpers/managementContext.ts @@ -1,7 +1,7 @@ // Copyright 2026 The ThunderID Authors // SPDX-License-Identifier: Apache-2.0 -import {createResourceInvalidator} from '@thunderid/browser'; +import {type ResourceInvalidator, createResourceInvalidator} from '@thunderid/browser'; import {getDefaultI18nBundles, substituteTranslationParams} from '@thunderid/browser'; import {type Mock, vi} from 'vitest'; import {ref} from 'vue'; @@ -17,7 +17,7 @@ export const jsonResponse = (data: unknown, status = 200): Response => text: () => Promise.resolve(JSON.stringify(data)), }) as Response; -/** The transport the management composables call, mocked. */ +/** The transport the management API functions call, mocked. */ export type MockFetcher = Mock<(url: string, init: RequestInit) => Promise>; export const translate = (key: string, params?: Record): string => { @@ -31,11 +31,11 @@ export const translate = (key: string, params?: Record) export interface ManagementTestContext { fetcher: MockFetcher; provide: Record; - thunderID: {getAccessToken: Mock<() => Promise>}; + thunderID: {getAccessToken: Mock<() => Promise>; invalidator: ResourceInvalidator}; } /** - * Builds the injected contexts the management composables and admin components read, with a mock + * Builds the injected contexts the management query layer and admin components read, with a mock * `fetcher` standing in for the network. */ export const createManagementContext = ({ diff --git a/packages/vue/src/components/admin/AgentCreate/AgentCreate.ts b/packages/vue/src/components/admin/AgentCreate/AgentCreate.ts index df786857..22bf3b58 100644 --- a/packages/vue/src/components/admin/AgentCreate/AgentCreate.ts +++ b/packages/vue/src/components/admin/AgentCreate/AgentCreate.ts @@ -1,14 +1,13 @@ // Copyright 2026 The ThunderID Authors // SPDX-License-Identifier: Apache-2.0 -import {toManagementErrorMessage} from '@thunderid/browser'; +import {AgentQueryKeys, createAgent, getAgentTypes, toManagementErrorMessage} from '@thunderid/browser'; import type {AgentType, AgentTypeListResponse, SelectOption, Agent, CreateAgentRequest} from '@thunderid/browser'; import {type Component, type ComputedRef, type SetupContext, type VNode, computed, defineComponent, h} from 'vue'; import BaseAgentCreate, {type AgentCreateSubmission} from './BaseAgentCreate'; -import useCreateAgent from '../../../composables/management/agents/useCreateAgent'; -import useGetAgentTypes from '../../../composables/management/agents/useGetAgentTypes'; -import type {ResourceMutationResult} from '../../../composables/management/useResourceMutation'; -import type {ResourceQueryResult} from '../../../composables/management/useResourceQuery'; +import useManagementEndpoint, {type ManagementEndpoint} from '../../../composables/management/useManagementEndpoint'; +import useResourceMutation, {type ResourceMutationResult} from '../../../composables/management/useResourceMutation'; +import useResourceQuery, {type ResourceQueryResult} from '../../../composables/management/useResourceQuery'; import {type AdminFormProps, adminFormProps} from '../shared/adminFormProps'; import useAdminTranslation, {type AdminTranslate} from '../shared/useAdminTranslation'; import useDefaultOuId from '../shared/useDefaultOuId'; @@ -45,16 +44,23 @@ const AgentCreate: Component = defineComponent({ setup(props: AgentCreateProps, {emit, slots}: SetupContext): () => VNode { const t: AdminTranslate = useAdminTranslation(() => props.preferences); const resolvedOuId: ComputedRef = useDefaultOuId(() => props.ouId); - const agentTypes: ResourceQueryResult = useGetAgentTypes({limit: 100}); + const agentTypesEndpoint: ManagementEndpoint = useManagementEndpoint('agentTypes'); + const agentsEndpoint: ManagementEndpoint = useManagementEndpoint('agents'); + const agentTypes: ResourceQueryResult = useResourceQuery( + () => [AgentQueryKeys.AGENT_TYPES, {limit: 100}], + (fetcher) => getAgentTypes({...agentTypesEndpoint, fetcher, limit: 100}), + ); const options: ComputedRef = computed(() => (agentTypes.data.value?.types ?? []).map((agentType: AgentType) => ({ label: agentType.displayName, value: agentType.handle, })), ); - const creation: ResourceMutationResult = useCreateAgent({ - onSuccess: (created: Agent): void => emit('created', created), - }); + const creation: ResourceMutationResult = useResourceMutation( + (payload: CreateAgentRequest, fetcher) => createAgent({...agentsEndpoint, fetcher, payload}), + () => [[AgentQueryKeys.AGENTS]], + {onSuccess: (created: Agent): void => emit('created', created)}, + ); const handleSubmit = ({description, name, type}: AgentCreateSubmission): void => { if (resolvedOuId.value) { diff --git a/packages/vue/src/components/admin/AgentDetails/AgentDetails.ts b/packages/vue/src/components/admin/AgentDetails/AgentDetails.ts index 1a9e15f3..a3a5d6b8 100644 --- a/packages/vue/src/components/admin/AgentDetails/AgentDetails.ts +++ b/packages/vue/src/components/admin/AgentDetails/AgentDetails.ts @@ -1,16 +1,22 @@ // Copyright 2026 The ThunderID Authors // SPDX-License-Identifier: Apache-2.0 -import {toManagementErrorMessage} from '@thunderid/browser'; +import { + AgentQueryKeys, + GroupQueryKeys, + RoleQueryKeys, + deleteAgent, + getAgent, + getAgentGroups, + getAgentRoles, + toManagementErrorMessage, +} from '@thunderid/browser'; import type {Agent, AgentRoleListResponse, MembershipGroupListResponse, Preferences} from '@thunderid/browser'; import {type Component, type PropType, type SetupContext, type VNode, defineComponent, h} from 'vue'; import BaseAgentDetails from './BaseAgentDetails'; -import useDeleteAgent from '../../../composables/management/agents/useDeleteAgent'; -import useGetAgent from '../../../composables/management/agents/useGetAgent'; -import useGetAgentGroups from '../../../composables/management/agents/useGetAgentGroups'; -import useGetAgentRoles from '../../../composables/management/agents/useGetAgentRoles'; -import type {ResourceMutationResult} from '../../../composables/management/useResourceMutation'; -import type {ResourceQueryResult} from '../../../composables/management/useResourceQuery'; +import useManagementEndpoint, {type ManagementEndpoint} from '../../../composables/management/useManagementEndpoint'; +import useResourceMutation, {type ResourceMutationResult} from '../../../composables/management/useResourceMutation'; +import useResourceQuery, {type ResourceQueryResult} from '../../../composables/management/useResourceQuery'; import useAdminTranslation, {type AdminTranslate} from '../shared/useAdminTranslation'; type AgentDetailsProps = Readonly<{ @@ -46,12 +52,28 @@ const AgentDetails: Component = defineComponent({ emits: ['deleted'], setup(props: AgentDetailsProps, {emit, slots}: SetupContext): () => VNode { const t: AdminTranslate = useAdminTranslation(() => props.preferences); - const agent: ResourceQueryResult = useGetAgent(() => props.agentId); - const groups: ResourceQueryResult = useGetAgentGroups(() => props.agentId, { - limit: 100, - }); - const roles: ResourceQueryResult = useGetAgentRoles(() => props.agentId, {limit: 100}); - const deletion: ResourceMutationResult = useDeleteAgent({onSuccess: (): void => emit('deleted')}); + const endpoint: ManagementEndpoint = useManagementEndpoint('agents'); + const hasAgentId = (): boolean => Boolean(props.agentId); + const agent: ResourceQueryResult = useResourceQuery( + () => [AgentQueryKeys.AGENT, props.agentId], + (fetcher) => getAgent({...endpoint, agentId: props.agentId, fetcher}), + {enabled: hasAgentId}, + ); + const groups: ResourceQueryResult = useResourceQuery( + () => [GroupQueryKeys.MEMBERSHIPS, 'agent', props.agentId, {limit: 100}], + (fetcher) => getAgentGroups({...endpoint, agentId: props.agentId, fetcher, limit: 100}), + {enabled: hasAgentId}, + ); + const roles: ResourceQueryResult = useResourceQuery( + () => [RoleQueryKeys.ROLE_ASSIGNMENTS, 'agent', props.agentId, {limit: 100}], + (fetcher) => getAgentRoles({...endpoint, agentId: props.agentId, fetcher, limit: 100}), + {enabled: hasAgentId}, + ); + const deletion: ResourceMutationResult = useResourceMutation( + (agentId: string, fetcher) => deleteAgent({...endpoint, agentId, fetcher}), + () => [[AgentQueryKeys.AGENTS]], + {onSuccess: (): void => emit('deleted')}, + ); return (): VNode => h( diff --git a/packages/vue/src/components/admin/AgentList/AgentList.ts b/packages/vue/src/components/admin/AgentList/AgentList.ts index c3588946..6bda45c5 100644 --- a/packages/vue/src/components/admin/AgentList/AgentList.ts +++ b/packages/vue/src/components/admin/AgentList/AgentList.ts @@ -1,10 +1,12 @@ // Copyright 2026 The ThunderID Authors // SPDX-License-Identifier: Apache-2.0 -import type {AgentListResponse, BasicAgent} from '@thunderid/browser'; +import {AgentQueryKeys, getAgents} from '@thunderid/browser'; +import type {AgentListResponse, ApiFilteringParams, BasicAgent} from '@thunderid/browser'; import type {Component} from 'vue'; import BaseAgentList from './BaseAgentList'; -import useGetAgents from '../../../composables/management/agents/useGetAgents'; +import useManagementEndpoint, {type ManagementEndpoint} from '../../../composables/management/useManagementEndpoint'; +import useResourceQuery, {type ResourceQueryResult} from '../../../composables/management/useResourceQuery'; import createConnectedAdminList from '../shared/createConnectedAdminList'; import type {AdminTranslate} from '../shared/useAdminTranslation'; @@ -24,8 +26,22 @@ const AgentList: Component = createConnectedAdminList Record) => - useGetAgents(() => params() as {filter?: string; limit?: number; offset?: number}), + query: (params: () => Record): ResourceQueryResult => { + const endpoint: ManagementEndpoint = useManagementEndpoint('agents'); + + return useResourceQuery( + () => { + const {filter, limit, offset} = params() as ApiFilteringParams; + + return [AgentQueryKeys.AGENTS, {filter, limit, offset}]; + }, + (fetcher) => { + const {filter, limit, offset} = params() as ApiFilteringParams; + + return getAgents({...endpoint, fetcher, filter, limit, offset}); + }, + ); + }, rows: (data: AgentListResponse | undefined) => data?.agents ?? [], rowsProp: 'agents', searchAttributes: (t: AdminTranslate) => [{label: t('admin.agents.list.search.name'), value: 'name'}], diff --git a/packages/vue/src/components/admin/ApplicationCreate/ApplicationCreate.ts b/packages/vue/src/components/admin/ApplicationCreate/ApplicationCreate.ts new file mode 100644 index 00000000..17436030 --- /dev/null +++ b/packages/vue/src/components/admin/ApplicationCreate/ApplicationCreate.ts @@ -0,0 +1,111 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import { + ApplicationQueryKeys, + createApplication, + getApplicationClientSecret, + toCreateApplicationRequest, + toManagementErrorMessage, +} from '@thunderid/browser'; +import type {Application, CreateApplicationRequest} from '@thunderid/browser'; +import {type Component, type ComputedRef, type Ref, type SetupContext, type VNode, defineComponent, h, ref} from 'vue'; +import BaseApplicationCreate, {type ApplicationCreateSubmission} from './BaseApplicationCreate'; +import useManagementEndpoint, {type ManagementEndpoint} from '../../../composables/management/useManagementEndpoint'; +import useResourceMutation, {type ResourceMutationResult} from '../../../composables/management/useResourceMutation'; +import {type AdminFormProps, adminFormProps} from '../shared/adminFormProps'; +import useAdminTranslation, {type AdminTranslate} from '../shared/useAdminTranslation'; +import useDefaultOuId from '../shared/useDefaultOuId'; + +type ApplicationCreateProps = Omit & Readonly<{ouId: string | undefined}>; + +/** + * Creates an application with an OAuth client through the management API in the given organization + * unit, or the signed-in user's own. When the server returns a client secret, shows it once and emits + * `created` with the application after the user has copied it; otherwise emits `created` right away. + * + * The server guards applications with the root `system` permission, for reading and for changes alike; + * it defines no application-specific permission. Gate the view and its actions on it with + * `useGrantedPermissions().can('system')`. + * + * Slots: `default`, a scoped slot that replaces the form with custom UI and receives + * `BaseApplicationCreateRenderProps`. + * + * @example + * ```vue + * + * ``` + */ +const ApplicationCreate: Component = defineComponent({ + name: 'ApplicationCreate', + props: { + className: adminFormProps.className, + description: adminFormProps.description, + onCancel: adminFormProps.onCancel, + /** Organization unit the application is created in. Defaults to the signed-in user's organization unit. */ + ouId: {default: undefined, type: String}, + preferences: adminFormProps.preferences, + title: adminFormProps.title, + }, + emits: ['created'], + setup(props: ApplicationCreateProps, {emit, slots}: SetupContext): () => VNode { + const t: AdminTranslate = useAdminTranslation(() => props.preferences); + const resolvedOuId: ComputedRef = useDefaultOuId(() => props.ouId); + const endpoint: ManagementEndpoint = useManagementEndpoint('applications'); + const created: Ref<{application: Application; secret: string} | null> = ref(null); + const formKey: Ref = ref(0); + const creation: ResourceMutationResult = useResourceMutation( + (payload: CreateApplicationRequest, fetcher) => createApplication({...endpoint, fetcher, payload}), + () => [[ApplicationQueryKeys.APPLICATIONS]], + { + onSuccess: (application: Application): void => { + const secret: string | undefined = getApplicationClientSecret(application); + + if (secret) { + created.value = {application, secret}; + } else { + emit('created', application); + } + }, + }, + ); + + const handleSubmit = (submission: ApplicationCreateSubmission): void => { + if (resolvedOuId.value) { + void creation.mutate(toCreateApplicationRequest(submission, resolvedOuId.value)); + } + }; + + const handleSecretDismiss = (): void => { + if (created.value) { + const {application} = created.value; + created.value = null; + formKey.value += 1; + emit('created', application); + } + }; + + return (): VNode => + h( + BaseApplicationCreate, + { + className: props.className, + clientSecret: created.value?.secret ?? null, + description: props.description, + error: resolvedOuId.value + ? toManagementErrorMessage(t, creation.error.value, 'admin.applications.create.error') + : t('admin.common.errors.no_ou'), + isSubmitting: creation.isLoading.value, + key: formKey.value, + onCancel: props.onCancel, + onSecretDismiss: handleSecretDismiss, + onSubmit: handleSubmit, + preferences: props.preferences, + title: props.title, + }, + {...slots}, + ); + }, +}); + +export default ApplicationCreate; diff --git a/packages/vue/src/components/admin/ApplicationCreate/BaseApplicationCreate.ts b/packages/vue/src/components/admin/ApplicationCreate/BaseApplicationCreate.ts new file mode 100644 index 00000000..d513044e --- /dev/null +++ b/packages/vue/src/components/admin/ApplicationCreate/BaseApplicationCreate.ts @@ -0,0 +1,167 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import { + APPLICATION_GRANT_TYPES, + type ApplicationSubmission, + DEFAULT_APPLICATION_GRANT_TYPES, + isApplicationSubmissionValid, + parseRedirectUris, + type SelectOption, +} from '@thunderid/browser'; +import { + type Component, + type PropType, + type Ref, + type SetupContext, + type VNode, + defineComponent, + h, + ref, + useId, +} from 'vue'; +import {type AdminFormProps, type AdminFormRenderProps, adminFormProps} from '../shared/adminFormProps'; +import AdminSection from '../shared/AdminSection'; +import ClientSecretNotice from '../shared/ClientSecretNotice'; +import renderAdminForm from '../shared/renderAdminForm'; +import renderApplicationFields, { + type ApplicationFormValues, + type ApplicationTextField, +} from '../shared/renderApplicationFields'; +import useAdminTranslation, {type AdminTranslate} from '../shared/useAdminTranslation'; + +/** + * The application `BaseApplicationCreate` emits on `submit`. The organization unit is added by the caller. + */ +export type ApplicationCreateSubmission = ApplicationSubmission; + +type BaseApplicationCreateProps = AdminFormProps & + Readonly<{clientSecret: string | null; onSecretDismiss: (() => void) | undefined}>; + +/** + * What `BaseApplicationCreate` hands its default scoped slot, so the form can be built with any UI. + */ +export interface BaseApplicationCreateRenderProps extends AdminFormRenderProps { + /** The client secret of the application just created, to show once. `null` while there is none. */ + clientSecret: string | null; + /** The grant types to choose from, with translated labels. */ + grantTypeOptions: SelectOption[]; + /** Sets the chosen grant types. */ + handleGrantTypesChange: (grantTypes: string[]) => void; + /** Sets one text field of the form. `redirectUris` takes URIs separated by commas. */ + handleInputChange: (field: ApplicationTextField, value: string) => void; + /** Sets whether the client is public. */ + handlePublicClientChange: (publicClient: boolean) => void; + /** Hides the client secret once it has been copied. Undefined while there is no secret. */ + onSecretDismiss?: () => void; + values: ApplicationFormValues; +} + +/** + * A form that collects a new application's name, description, links and OAuth basics: redirect URIs, + * grant types and whether the client is public. Holds no data of its own: handle the `submit` event, + * which carries an {@link ApplicationCreateSubmission}, and pass back the client secret the server + * returned as `clientSecret` to show it once. Slots: `default`, a scoped slot that replaces the form + * with custom UI and receives {@link BaseApplicationCreateRenderProps}. + */ +const BaseApplicationCreate: Component = defineComponent({ + name: 'BaseApplicationCreate', + props: { + ...adminFormProps, + /** + * The client secret of the application just created. When set, the form gives way to the secret, + * which can be read only this once, until `onSecretDismiss` clears it. + */ + clientSecret: {default: null, type: String as PropType}, + /** Called when the user has copied the client secret. Also available as `@secret-dismiss`. */ + onSecretDismiss: {default: undefined, type: Function as PropType<() => void>}, + }, + emits: ['submit'], + setup(props: BaseApplicationCreateProps, {emit, slots}: SetupContext): () => VNode { + const t: AdminTranslate = useAdminTranslation(() => props.preferences); + const uid: string = useId(); + const values: Ref = ref({ + description: '', + grantTypes: [...DEFAULT_APPLICATION_GRANT_TYPES], + logoUrl: '', + name: '', + publicClient: false, + redirectUris: '', + url: '', + }); + + const setInput = (field: ApplicationTextField, value: string): void => { + values.value = {...values.value, [field]: value}; + }; + const setGrantTypes = (grantTypes: string[]): void => { + values.value = {...values.value, grantTypes}; + }; + const setPublicClient = (publicClient: boolean): void => { + values.value = {...values.value, publicClient}; + }; + + return (): VNode => { + const current: ApplicationFormValues = values.value; + const submission: ApplicationCreateSubmission = { + description: current.description.trim() || undefined, + grantTypes: current.grantTypes, + logoUrl: current.logoUrl.trim() || undefined, + name: current.name.trim(), + publicClient: current.publicClient, + redirectUris: parseRedirectUris(current.redirectUris), + url: current.url.trim() || undefined, + }; + + if (props.clientSecret && !slots['default']) { + return h( + AdminSection, + { + block: 'application-create', + className: props.className, + description: props.description, + title: props.title ?? t('admin.applications.create.title'), + }, + { + default: (): VNode => + h(ClientSecretNotice, {onDismiss: props.onSecretDismiss, secret: props.clientSecret!, t}), + }, + ); + } + + return renderAdminForm({ + block: 'application-create', + canSubmit: isApplicationSubmissionValid(submission), + fields: renderApplicationFields({ + onGrantTypesChange: setGrantTypes, + onInputChange: setInput, + onPublicClientChange: setPublicClient, + t, + uid, + values: current, + }), + onSubmit: (): void => { + emit('submit', submission); + }, + props, + renderProps: { + clientSecret: props.clientSecret, + grantTypeOptions: APPLICATION_GRANT_TYPES.map((grantType: string) => ({ + label: t(`admin.applications.grant_types.${grantType}`), + value: grantType, + })), + handleGrantTypesChange: setGrantTypes, + handleInputChange: setInput, + handlePublicClientChange: setPublicClient, + onSecretDismiss: props.clientSecret ? props.onSecretDismiss : undefined, + values: current, + } satisfies Omit, + slot: slots['default'], + submitLabel: t('admin.applications.create.submit'), + t, + title: t('admin.applications.create.title'), + }); + }; + }, +}); + +export default BaseApplicationCreate; diff --git a/packages/vue/src/components/admin/ApplicationDetails/ApplicationDetails.ts b/packages/vue/src/components/admin/ApplicationDetails/ApplicationDetails.ts new file mode 100644 index 00000000..37a88061 --- /dev/null +++ b/packages/vue/src/components/admin/ApplicationDetails/ApplicationDetails.ts @@ -0,0 +1,144 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import { + ApplicationQueryKeys, + deleteApplication, + getApplication, + regenerateClientSecret, + toManagementErrorMessage, + toUpdateApplicationRequest, + updateApplication, +} from '@thunderid/browser'; +import type {Application, Preferences, RegenerateClientSecretResult} from '@thunderid/browser'; +import {type Component, type PropType, type SetupContext, type VNode, defineComponent, h} from 'vue'; +import BaseApplicationDetails, {type ApplicationUpdateSubmission} from './BaseApplicationDetails'; +import useManagementEndpoint, {type ManagementEndpoint} from '../../../composables/management/useManagementEndpoint'; +import useResourceMutation, {type ResourceMutationResult} from '../../../composables/management/useResourceMutation'; +import useResourceQuery, {type ResourceQueryResult} from '../../../composables/management/useResourceQuery'; +import useAdminTranslation, {type AdminTranslate} from '../shared/useAdminTranslation'; + +type ApplicationDetailsProps = Readonly<{ + applicationId: string; + className: string; + deletable: boolean; + editable: boolean; + preferences: Preferences | undefined; + secretRegenerable: boolean; +}>; + +interface UpdateApplicationVariables { + application: Application; + changes: ApplicationUpdateSubmission; +} + +/** + * Shows an application and its OAuth configuration, and optionally lets the signed-in user edit it, + * regenerate its client secret (shown once) or delete it. Emits `deleted` after the application has + * been deleted. + * + * The server guards applications with the root `system` permission, for reading and for changes alike; + * it defines no application-specific permission. Gate the view and its actions on it with + * `useGrantedPermissions().can('system')`. + * + * Slots: `actions`, rendered next to the application's name; `default`, a scoped slot that replaces the + * view with custom UI and receives `BaseApplicationDetailsRenderProps`. + * + * @example + * ```vue + * + * ``` + */ +const ApplicationDetails: Component = defineComponent({ + name: 'ApplicationDetails', + props: { + /** Identifier of the application to show. */ + applicationId: {required: true, type: String}, + /** Extra CSS class added to the root element. */ + className: {default: '', type: String}, + /** Whether the application can be deleted from this view. */ + deletable: {default: false, type: Boolean}, + /** Whether the application's general fields, redirect URIs and grant types can be edited from this view. */ + editable: {default: false, type: Boolean}, + /** Component-level preferences, for example `i18n.bundles` overrides. */ + preferences: {default: undefined, type: Object as PropType}, + /** Whether the application's client secret can be regenerated from this view. The new secret is shown once. */ + secretRegenerable: {default: false, type: Boolean}, + }, + emits: ['deleted'], + setup(props: ApplicationDetailsProps, {emit, slots}: SetupContext): () => VNode { + const t: AdminTranslate = useAdminTranslation(() => props.preferences); + const endpoint: ManagementEndpoint = useManagementEndpoint('applications'); + const application: ResourceQueryResult = useResourceQuery( + () => [ApplicationQueryKeys.APPLICATION, props.applicationId], + (fetcher) => getApplication({...endpoint, applicationId: props.applicationId, fetcher}), + {enabled: (): boolean => Boolean(props.applicationId)}, + ); + const deletion: ResourceMutationResult = useResourceMutation( + (applicationId: string, fetcher) => deleteApplication({...endpoint, applicationId, fetcher}), + () => [[ApplicationQueryKeys.APPLICATIONS]], + {onSuccess: (): void => emit('deleted')}, + ); + const update: ResourceMutationResult = useResourceMutation( + ({application: current, changes}: UpdateApplicationVariables, fetcher) => + updateApplication({ + ...endpoint, + applicationId: current.id, + fetcher, + payload: toUpdateApplicationRequest(current, changes), + }), + ({application: current}: UpdateApplicationVariables) => [ + [ApplicationQueryKeys.APPLICATION, current.id], + [ApplicationQueryKeys.APPLICATIONS], + ], + ); + const regeneration: ResourceMutationResult = useResourceMutation( + (applicationId: string, fetcher) => regenerateClientSecret({...endpoint, applicationId, fetcher}), + (applicationId: string) => [ + [ApplicationQueryKeys.APPLICATION, applicationId], + [ApplicationQueryKeys.APPLICATIONS], + ], + ); + + return (): VNode => { + const current: Application | undefined = application.data.value; + + return h( + BaseApplicationDetails, + { + application: current, + className: props.className, + clientSecret: regeneration.data.value?.clientSecret ?? null, + error: + toManagementErrorMessage(t, application.error.value, 'admin.applications.details.error') ?? + toManagementErrorMessage(t, deletion.error.value, 'admin.applications.details.delete.error') ?? + toManagementErrorMessage(t, update.error.value, 'admin.applications.details.edit.error') ?? + toManagementErrorMessage(t, regeneration.error.value, 'admin.applications.details.secret.error'), + isDeleting: deletion.isLoading.value, + isLoading: application.isLoading.value, + isRegeneratingSecret: regeneration.isLoading.value, + isUpdating: update.isLoading.value, + onDelete: props.deletable ? (): void => void deletion.mutate(props.applicationId) : undefined, + onRegenerateSecret: props.secretRegenerable + ? (): void => void regeneration.mutate(props.applicationId) + : undefined, + onSecretDismiss: regeneration.reset, + onUpdate: + props.editable && current + ? (changes: ApplicationUpdateSubmission): void => void update.mutate({application: current, changes}) + : undefined, + preferences: props.preferences, + }, + {...slots}, + ); + }; + }, +}); + +export default ApplicationDetails; diff --git a/packages/vue/src/components/admin/ApplicationDetails/BaseApplicationDetails.ts b/packages/vue/src/components/admin/ApplicationDetails/BaseApplicationDetails.ts new file mode 100644 index 00000000..ece0ed01 --- /dev/null +++ b/packages/vue/src/components/admin/ApplicationDetails/BaseApplicationDetails.ts @@ -0,0 +1,406 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import { + type Application, + APPLICATION_GRANT_TYPES, + type ApplicationSubmission, + getApplicationOAuthConfig, + isApplicationSubmissionValid, + type OAuth2Config, + parseRedirectUris, + type Preferences, + withVendorCSSClassPrefix, +} from '@thunderid/browser'; +import { + type Component, + type PropType, + type Ref, + type SetupContext, + type VNode, + defineComponent, + h, + ref, + useId, +} from 'vue'; +import Alert from '../../primitives/Alert/Alert'; +import Button from '../../primitives/Button/Button'; +import CopyableText from '../../primitives/CopyableText/CopyableText'; +import Spinner from '../../primitives/Spinner/Spinner'; +import AdminSection from '../shared/AdminSection'; +import ChipList from '../shared/ChipList'; +import ClientSecretNotice from '../shared/ClientSecretNotice'; +import ConfirmButton from '../shared/ConfirmButton'; +import DetailList, {type DetailListItem} from '../shared/DetailList'; +import Panel from '../shared/Panel'; +import renderApplicationFields, { + type ApplicationFormValues, + type ApplicationTextField, +} from '../shared/renderApplicationFields'; +import useAdminTranslation, {type AdminTranslate} from '../shared/useAdminTranslation'; + +/** + * The edited fields `BaseApplicationDetails` hands `onUpdate`. Whether the client is public does not change. + */ +export type ApplicationUpdateSubmission = Omit; + +type BaseApplicationDetailsProps = Readonly<{ + application: Application | undefined; + className: string; + clientSecret: string | null; + error: string | null; + isDeleting: boolean; + isLoading: boolean; + isRegeneratingSecret: boolean; + isUpdating: boolean; + onDelete: (() => void) | undefined; + onRegenerateSecret: (() => void) | undefined; + onSecretDismiss: (() => void) | undefined; + onUpdate: ((application: ApplicationUpdateSubmission) => void) | undefined; + preferences: Preferences | undefined; +}>; + +/** + * What `BaseApplicationDetails` hands its default scoped slot, so the application can be shown with any UI. + */ +export interface BaseApplicationDetailsRenderProps { + application?: Application; + /** The new client secret, to show once. `null` while there is none. */ + clientSecret: string | null; + error: string | null; + isDeleting: boolean; + isLoading: boolean; + isRegeneratingSecret: boolean; + isUpdating: boolean; + /** The application's OAuth configuration, without its client secret. Undefined when it has no OAuth client. */ + oauth?: Omit; + /** Deletes the application. Undefined when deletion is not allowed, including for read-only applications. */ + onDelete?: () => void; + /** + * Gives the application a new client secret. Undefined when not allowed, for read-only applications + * and for applications without a confidential OAuth client. + */ + onRegenerateSecret?: () => void; + /** Hides the new client secret once it has been copied. Undefined while there is no secret. */ + onSecretDismiss?: () => void; + /** Saves edited fields. Undefined when editing is not allowed, including for read-only applications. */ + onUpdate?: (application: ApplicationUpdateSubmission) => void; +} + +const cls = (element: string): string => withVendorCSSClassPrefix(`admin__${element}`); + +const toFormValues = (application: Application, oauth?: OAuth2Config): ApplicationFormValues => ({ + description: application.description ?? '', + grantTypes: oauth?.grantTypes ?? [], + logoUrl: application.logoUrl ?? '', + name: application.name, + publicClient: Boolean(oauth?.publicClient), + redirectUris: (oauth?.redirectUris ?? []).join(', '), + url: application.url ?? '', +}); + +const toSubmission = (values: ApplicationFormValues): ApplicationSubmission => ({ + description: values.description.trim() || undefined, + grantTypes: values.grantTypes, + logoUrl: values.logoUrl.trim() || undefined, + name: values.name.trim(), + publicClient: values.publicClient, + redirectUris: parseRedirectUris(values.redirectUris), + url: values.url.trim() || undefined, +}); + +/** + * Shows an application's general fields and OAuth configuration. Holds no data of its own: pass the + * application and handle editing, deletion and client secret regeneration, each offered only when its + * handler is set. + * + * Slots: `actions`, rendered next to the application's name; `default`, a scoped slot that replaces the + * view with custom UI and receives {@link BaseApplicationDetailsRenderProps}. + */ +const BaseApplicationDetails: Component = defineComponent({ + name: 'BaseApplicationDetails', + props: { + /** The application to show. */ + application: {default: undefined, type: Object as PropType}, + /** Extra CSS class added to the root element. */ + className: {default: '', type: String}, + /** A client secret just generated by `onRegenerateSecret`, shown once until `onSecretDismiss` clears it. */ + clientSecret: {default: null, type: String as PropType}, + /** An error message, already translated. */ + error: {default: null, type: String as PropType}, + isDeleting: {default: false, type: Boolean}, + isLoading: {default: false, type: Boolean}, + isRegeneratingSecret: {default: false, type: Boolean}, + isUpdating: {default: false, type: Boolean}, + /** Deletes the application. The delete action is hidden when omitted. Also available as `@delete`. */ + onDelete: {default: undefined, type: Function as PropType<() => void>}, + /** + * Gives the application a new client secret. The regenerate action is hidden when omitted, and for + * public clients, which have no secret. Also available as `@regenerate-secret`. + */ + onRegenerateSecret: {default: undefined, type: Function as PropType<() => void>}, + /** Called when the user has copied the new client secret. Also available as `@secret-dismiss`. */ + onSecretDismiss: {default: undefined, type: Function as PropType<() => void>}, + /** + * Saves the edited general fields, redirect URIs and grant types. Editing is offered only when set. + * Also available as `@update`. + */ + onUpdate: {default: undefined, type: Function as PropType<(application: ApplicationUpdateSubmission) => void>}, + /** Component-level preferences, for example `i18n.bundles` overrides. */ + preferences: {default: undefined, type: Object as PropType}, + }, + setup(props: BaseApplicationDetailsProps, {slots}: SetupContext): () => VNode { + const t: AdminTranslate = useAdminTranslation(() => props.preferences); + const uid: string = useId(); + const draft: Ref = ref(null); + + return (): VNode => { + const {application} = props; + const oauthConfig: OAuth2Config | undefined = getApplicationOAuthConfig(application); + const isWritable: boolean = Boolean(application) && !application?.isReadOnly; + const canRegenerate: boolean = isWritable && Boolean(oauthConfig) && !oauthConfig?.publicClient; + + if (slots['default']) { + let oauth: Omit | undefined; + + if (oauthConfig) { + const {clientSecret: storedSecret, ...rest}: OAuth2Config = oauthConfig; + void storedSecret; + oauth = rest; + } + + const renderProps: BaseApplicationDetailsRenderProps = { + application, + clientSecret: props.clientSecret, + error: props.error, + isDeleting: props.isDeleting, + isLoading: props.isLoading, + isRegeneratingSecret: props.isRegeneratingSecret, + isUpdating: props.isUpdating, + oauth, + onDelete: isWritable ? props.onDelete : undefined, + onRegenerateSecret: canRegenerate ? props.onRegenerateSecret : undefined, + onSecretDismiss: props.clientSecret ? props.onSecretDismiss : undefined, + onUpdate: isWritable ? props.onUpdate : undefined, + }; + + return h('div', {class: props.className}, slots['default'](renderProps)); + } + + if (props.isLoading && !application) { + return h( + AdminSection, + {block: 'application-details', className: props.className}, + { + default: (): VNode => + h('span', {class: cls('muted'), role: 'status'}, [ + h(Spinner, {'aria-hidden': 'true', size: 'small'}), + t('admin.common.loading'), + ]), + }, + ); + } + + if (!application) { + return h( + AdminSection, + {block: 'application-details', className: props.className}, + { + default: (): VNode => + h( + Alert, + {severity: 'error'}, + {default: (): string[] => [props.error ?? t('admin.common.errors.not_found')]}, + ), + }, + ); + } + + const notSet: string = t('admin.applications.details.not_set'); + const overview: DetailListItem[] = [ + { + label: t('admin.applications.list.columns.client_id'), + value: oauthConfig?.clientId ? h(CopyableText, {value: oauthConfig.clientId}) : notSet, + }, + {label: t('admin.applications.details.type'), value: application.type ?? application.template ?? notSet}, + {label: t('admin.common.columns.organization_unit'), value: application.ouId ?? notSet}, + {label: t('admin.applications.fields.url'), value: application.url ?? notSet}, + {label: t('admin.applications.fields.logo_url'), value: application.logoUrl ?? notSet}, + ]; + + const renderEditForm = (current: ApplicationFormValues): VNode => + h( + Panel, + {title: t('admin.applications.details.edit.action')}, + { + default: (): VNode => + h( + 'form', + { + class: cls('edit-form'), + novalidate: true, + onSubmit: (event: Event): void => { + event.preventDefault(); + const {publicClient, ...submission}: ApplicationSubmission = toSubmission(current); + void publicClient; + props.onUpdate?.(submission); + draft.value = null; + }, + }, + [ + ...renderApplicationFields({ + onGrantTypesChange: (grantTypes: string[]): void => { + draft.value = {...current, grantTypes}; + }, + onInputChange: (field: ApplicationTextField, value: string): void => { + draft.value = {...current, [field]: value}; + }, + t, + uid, + values: current, + }), + h('div', {class: cls('form-actions')}, [ + h( + Button, + { + disabled: props.isUpdating, + onClick: (): void => { + draft.value = null; + }, + type: 'button', + variant: 'text', + }, + {default: (): string => t('admin.common.actions.cancel')}, + ), + h( + Button, + { + disabled: props.isUpdating || !isApplicationSubmissionValid(toSubmission(current)), + loading: props.isUpdating, + type: 'submit', + }, + {default: (): string => t('admin.applications.details.edit.save')}, + ), + ]), + ], + ), + }, + ); + + const renderOAuth = (oauth: OAuth2Config): VNode => + h( + Panel, + {title: t('admin.applications.details.oauth')}, + { + actions: (): VNode | null => + props.onRegenerateSecret && canRegenerate && !props.clientSecret + ? h( + ConfirmButton, + { + confirmText: t('admin.applications.details.secret.confirm', {name: application.name}), + isLoading: props.isRegeneratingSecret, + onConfirm: props.onRegenerateSecret, + t, + }, + {default: (): string => t('admin.applications.details.secret.action')}, + ) + : null, + default: (): (VNode | null)[] => [ + props.clientSecret + ? h(ClientSecretNotice, {onDismiss: props.onSecretDismiss, secret: props.clientSecret, t}) + : null, + h(DetailList, { + items: [ + { + label: t('admin.applications.fields.grant_types'), + value: h(ChipList, { + emptyText: t('admin.applications.details.no_grant_types'), + items: (oauth.grantTypes ?? []).map((grantType: string) => ({ + id: grantType, + label: APPLICATION_GRANT_TYPES.includes(grantType) + ? t(`admin.applications.grant_types.${grantType}`) + : grantType, + })), + label: t('admin.applications.fields.grant_types'), + }), + }, + { + label: t('admin.applications.fields.redirect_uris'), + value: h(ChipList, { + emptyText: t('admin.applications.details.no_redirect_uris'), + items: (oauth.redirectUris ?? []).map((uri: string) => ({id: uri, label: uri})), + label: t('admin.applications.fields.redirect_uris'), + }), + }, + { + label: t('admin.applications.details.public_client'), + value: oauth.publicClient + ? t('admin.applications.details.yes') + : t('admin.applications.details.no'), + }, + ] satisfies DetailListItem[], + }), + ], + }, + ); + + return h( + AdminSection, + { + block: 'application-details', + className: props.className, + description: application.description, + title: application.name, + }, + { + actions: (): (VNode | VNode[] | null | undefined)[] => [ + slots['actions']?.(), + props.onDelete && isWritable + ? h( + ConfirmButton, + { + confirmText: t('admin.applications.details.delete.confirm', {name: application.name}), + isLoading: props.isDeleting, + onConfirm: props.onDelete, + t, + }, + {default: (): string => t('admin.applications.details.delete.action')}, + ) + : null, + ], + default: (): (VNode | null)[] => [ + props.error ? h(Alert, {severity: 'error'}, {default: (): string[] => [props.error!]}) : null, + ...(draft.value + ? [renderEditForm(draft.value)] + : [ + h( + Panel, + {title: t('admin.applications.details.overview')}, + { + actions: (): VNode | null => + props.onUpdate && isWritable + ? h( + Button, + { + onClick: (): void => { + draft.value = toFormValues(application, oauthConfig); + }, + size: 'small', + variant: 'outline', + }, + {default: (): string => t('admin.applications.details.edit.action')}, + ) + : null, + default: (): VNode => h(DetailList, {items: overview}), + }, + ), + oauthConfig ? renderOAuth(oauthConfig) : null, + ]), + ], + }, + ); + }; + }, +}); + +export default BaseApplicationDetails; diff --git a/packages/vue/src/components/admin/ApplicationList/ApplicationList.ts b/packages/vue/src/components/admin/ApplicationList/ApplicationList.ts new file mode 100644 index 00000000..fbe3c2e2 --- /dev/null +++ b/packages/vue/src/components/admin/ApplicationList/ApplicationList.ts @@ -0,0 +1,54 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ApplicationQueryKeys, getApplications} from '@thunderid/browser'; +import type {ApplicationListResponse, BasicApplication} from '@thunderid/browser'; +import type {Component} from 'vue'; +import BaseApplicationList from './BaseApplicationList'; +import useManagementEndpoint, {type ManagementEndpoint} from '../../../composables/management/useManagementEndpoint'; +import useResourceQuery, {type ResourceQueryResult} from '../../../composables/management/useResourceQuery'; +import createConnectedAdminList from '../shared/createConnectedAdminList'; + +/** + * Lists the applications the signed-in user is allowed to see, a page at a time. + * + * The server guards applications with the root `system` permission, for reading and for changes alike; + * it defines no application-specific permission. Gate the view and its actions on it with + * `useGrantedPermissions().can('system')`. + * + * Props: `limit`, `selectedApplicationId`, `onSelect` (`@select`), `title`, `description`, `className`, + * `preferences`. Slots: `actions`; `default`, a scoped slot that replaces the table with custom UI and + * receives `AdminListRenderProps` (rows, default columns, loading and error state, pagination and selection). + * + * @example + * ```vue + * + * ``` + */ +const ApplicationList: Component = createConnectedAdminList({ + base: BaseApplicationList, + errorKey: 'admin.applications.list.error', + name: 'ApplicationList', + query: (params: () => Record): ResourceQueryResult => { + const endpoint: ManagementEndpoint = useManagementEndpoint('applications'); + + return useResourceQuery( + () => { + const {limit, offset} = params() as {limit?: number; offset?: number}; + + return [ApplicationQueryKeys.APPLICATIONS, {limit, offset}]; + }, + (fetcher) => { + const {limit, offset} = params() as {limit?: number; offset?: number}; + + return getApplications({...endpoint, fetcher, limit, offset}); + }, + ); + }, + rows: (data: ApplicationListResponse | undefined) => data?.applications ?? [], + rowsProp: 'applications', + selectedProp: 'selectedApplicationId', + total: (data: ApplicationListResponse | undefined) => data?.totalResults, +}); + +export default ApplicationList; diff --git a/packages/vue/src/components/admin/ApplicationList/BaseApplicationList.ts b/packages/vue/src/components/admin/ApplicationList/BaseApplicationList.ts new file mode 100644 index 00000000..528027de --- /dev/null +++ b/packages/vue/src/components/admin/ApplicationList/BaseApplicationList.ts @@ -0,0 +1,73 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import type {BasicApplication} from '@thunderid/browser'; +import {type Component, type VNodeChild, h} from 'vue'; +import createAdminList from '../shared/createAdminList'; +import type {AdminTranslate} from '../shared/useAdminTranslation'; + +/** + * The translated access status of an application, or its raw value when the status is one this SDK + * does not know. + */ +const getStatusLabel = (application: BasicApplication, t: AdminTranslate): string => { + const value: string | undefined = application.status?.value; + + switch (value?.toUpperCase()) { + case 'ACTIVE': + return t('admin.applications.status.active'); + case 'SUSPENDED': + return t('admin.applications.status.suspended'); + default: + return value ?? ''; + } +}; + +/** + * Lists applications with their client identifier, type and, when the server reports it, access status. Holds no data of its own: pass the page + * of applications and handle selection and pagination. + * + * Props: `applications` (required), `selectedApplicationId`, `isLoading`, `error`, `limit`, `offset`, + * `totalResults`, `onPageChange`, `onSelect` (`@select`), `title`, `description`, `className`, `preferences`. + * Slots: `actions`, rendered next to the title; `default`, a scoped slot that replaces the table with custom UI and + * receives `AdminListRenderProps` (rows, default columns, loading and error state, pagination and selection). + */ +const BaseApplicationList: Component = createAdminList({ + block: 'application-list', + columns: (t: AdminTranslate, applications: BasicApplication[]) => [ + { + header: t('admin.applications.list.columns.name'), + id: 'name', + render: (application: BasicApplication): VNodeChild => application.name, + }, + { + header: t('admin.applications.list.columns.client_id'), + id: 'clientId', + render: (application: BasicApplication): VNodeChild => + application.clientId ? h('code', {}, application.clientId) : null, + }, + { + header: t('admin.applications.list.columns.type'), + id: 'type', + render: (application: BasicApplication): VNodeChild => application.type ?? application.template ?? '', + }, + // A server that does not report the access status gets no empty status column. + ...(applications.some((application: BasicApplication) => application.status?.value) + ? [ + { + header: t('admin.applications.list.columns.status'), + id: 'status', + render: (application: BasicApplication): VNodeChild => getStatusLabel(application, t), + }, + ] + : []), + ], + emptyKey: 'admin.applications.list.empty', + getRowId: (row: BasicApplication): string => row.id, + name: 'BaseApplicationList', + rowsProp: 'applications', + selectedProp: 'selectedApplicationId', + titleKey: 'admin.applications.list.title', +}); + +export default BaseApplicationList; diff --git a/packages/vue/src/components/admin/GroupCreate/GroupCreate.ts b/packages/vue/src/components/admin/GroupCreate/GroupCreate.ts index b7cf9284..4abc0055 100644 --- a/packages/vue/src/components/admin/GroupCreate/GroupCreate.ts +++ b/packages/vue/src/components/admin/GroupCreate/GroupCreate.ts @@ -1,12 +1,12 @@ // Copyright 2026 The ThunderID Authors // SPDX-License-Identifier: Apache-2.0 -import {toManagementErrorMessage} from '@thunderid/browser'; +import {GroupQueryKeys, createGroup, toManagementErrorMessage} from '@thunderid/browser'; import type {Group, CreateGroupRequest} from '@thunderid/browser'; import {type Component, type ComputedRef, type SetupContext, type VNode, defineComponent, h} from 'vue'; import BaseGroupCreate, {type GroupCreateSubmission} from './BaseGroupCreate'; -import useCreateGroup from '../../../composables/management/groups/useCreateGroup'; -import type {ResourceMutationResult} from '../../../composables/management/useResourceMutation'; +import useManagementEndpoint, {type ManagementEndpoint} from '../../../composables/management/useManagementEndpoint'; +import useResourceMutation, {type ResourceMutationResult} from '../../../composables/management/useResourceMutation'; import {type AdminFormProps, adminFormProps} from '../shared/adminFormProps'; import useAdminTranslation, {type AdminTranslate} from '../shared/useAdminTranslation'; import useDefaultOuId from '../shared/useDefaultOuId'; @@ -40,9 +40,12 @@ const GroupCreate: Component = defineComponent({ setup(props: GroupCreateProps, {emit, slots}: SetupContext): () => VNode { const t: AdminTranslate = useAdminTranslation(() => props.preferences); const resolvedOuId: ComputedRef = useDefaultOuId(() => props.ouId); - const creation: ResourceMutationResult = useCreateGroup({ - onSuccess: (created: Group): void => emit('created', created), - }); + const endpoint: ManagementEndpoint = useManagementEndpoint('groups'); + const creation: ResourceMutationResult = useResourceMutation( + (payload: CreateGroupRequest, fetcher) => createGroup({...endpoint, fetcher, payload}), + () => [[GroupQueryKeys.GROUPS], [GroupQueryKeys.MEMBERSHIPS]], + {onSuccess: (created: Group): void => emit('created', created)}, + ); const handleSubmit = ({description, name}: GroupCreateSubmission): void => { if (resolvedOuId.value) { diff --git a/packages/vue/src/components/admin/GroupList/GroupList.ts b/packages/vue/src/components/admin/GroupList/GroupList.ts index 98cc3c74..08e187fa 100644 --- a/packages/vue/src/components/admin/GroupList/GroupList.ts +++ b/packages/vue/src/components/admin/GroupList/GroupList.ts @@ -1,10 +1,12 @@ // Copyright 2026 The ThunderID Authors // SPDX-License-Identifier: Apache-2.0 +import {GroupQueryKeys, getGroups} from '@thunderid/browser'; import type {GroupListResponse, Group} from '@thunderid/browser'; import type {Component} from 'vue'; import BaseGroupList from './BaseGroupList'; -import useGetGroups from '../../../composables/management/groups/useGetGroups'; +import useManagementEndpoint, {type ManagementEndpoint} from '../../../composables/management/useManagementEndpoint'; +import useResourceQuery, {type ResourceQueryResult} from '../../../composables/management/useResourceQuery'; import createConnectedAdminList from '../shared/createConnectedAdminList'; /** @@ -23,7 +25,22 @@ const GroupList: Component = createConnectedAdminList( base: BaseGroupList, errorKey: 'admin.groups.list.error', name: 'GroupList', - query: (params: () => Record) => useGetGroups(() => params() as {limit?: number; offset?: number}), + query: (params: () => Record): ResourceQueryResult => { + const endpoint: ManagementEndpoint = useManagementEndpoint('groups'); + + return useResourceQuery( + () => { + const {limit, offset} = params() as {limit?: number; offset?: number}; + + return [GroupQueryKeys.GROUPS, {limit, offset}]; + }, + (fetcher) => { + const {limit, offset} = params() as {limit?: number; offset?: number}; + + return getGroups({...endpoint, fetcher, limit, offset}); + }, + ); + }, rows: (data: GroupListResponse | undefined) => data?.groups ?? [], rowsProp: 'groups', selectedProp: 'selectedGroupId', diff --git a/packages/vue/src/components/admin/GroupMembers/GroupMembers.ts b/packages/vue/src/components/admin/GroupMembers/GroupMembers.ts index a5ff45a9..9145167d 100644 --- a/packages/vue/src/components/admin/GroupMembers/GroupMembers.ts +++ b/packages/vue/src/components/admin/GroupMembers/GroupMembers.ts @@ -1,7 +1,19 @@ // Copyright 2026 The ThunderID Authors // SPDX-License-Identifier: Apache-2.0 -import {resolveManagedIdentityName, toManagementErrorMessage} from '@thunderid/browser'; +import { + AgentQueryKeys, + GroupQueryKeys, + RoleQueryKeys, + UserQueryKeys, + addGroupMembers, + getAgents, + getGroupMembers, + getUsers, + removeGroupMembers, + resolveManagedIdentityName, + toManagementErrorMessage, +} from '@thunderid/browser'; import type { AgentListResponse, BasicAgent, @@ -11,18 +23,13 @@ import type { ManagedUser, ManagedUserListResponse, Preferences, + ResourceQueryKey, } from '@thunderid/browser'; import {type Component, type PropType, type SetupContext, type VNode, defineComponent, h} from 'vue'; import BaseGroupMembers from './BaseGroupMembers'; -import useGetAgents from '../../../composables/management/agents/useGetAgents'; -import useAddGroupMembers, { - type GroupMembersVariables, -} from '../../../composables/management/groups/useAddGroupMembers'; -import useGetGroupMembers from '../../../composables/management/groups/useGetGroupMembers'; -import useRemoveGroupMembers from '../../../composables/management/groups/useRemoveGroupMembers'; -import type {ResourceMutationResult} from '../../../composables/management/useResourceMutation'; -import type {ResourceQueryResult} from '../../../composables/management/useResourceQuery'; -import useGetUsers from '../../../composables/management/users/useGetUsers'; +import useManagementEndpoint, {type ManagementEndpoint} from '../../../composables/management/useManagementEndpoint'; +import useResourceMutation, {type ResourceMutationResult} from '../../../composables/management/useResourceMutation'; +import useResourceQuery, {type ResourceQueryResult} from '../../../composables/management/useResourceQuery'; import type {IdentityCandidate} from '../shared/IdentityPicker'; import useAdminTranslation, {type AdminTranslate} from '../shared/useAdminTranslation'; @@ -35,6 +42,11 @@ type GroupMembersProps = Readonly<{ title: string | undefined; }>; +interface GroupMembersVariables { + groupId: string; + members: Pick[]; +} + /** * Lists a group's members and, unless `editable` is `false`, lets the signed-in user add users and * agents to it or remove members. @@ -62,16 +74,42 @@ const GroupMembers: Component = defineComponent({ }, setup(props: GroupMembersProps, {slots}: SetupContext): () => VNode { const t: AdminTranslate = useAdminTranslation(() => props.preferences); - const members: ResourceQueryResult = useGetGroupMembers(() => props.groupId, { - limit: 100, - }); - const users: ResourceQueryResult = useGetUsers( - {limit: 100}, - {enabled: () => props.editable}, + const groupsEndpoint: ManagementEndpoint = useManagementEndpoint('groups'); + const usersEndpoint: ManagementEndpoint = useManagementEndpoint('users'); + const agentsEndpoint: ManagementEndpoint = useManagementEndpoint('agents'); + const editable = (): boolean => props.editable; + // Membership changes the roles a member inherits, so role assignment lists are refetched too. + const invalidatedByMembership = ({groupId}: GroupMembersVariables): ResourceQueryKey[] => [ + [GroupQueryKeys.GROUP, groupId], + [GroupQueryKeys.GROUP_MEMBERS, groupId], + [GroupQueryKeys.MEMBERSHIPS], + [RoleQueryKeys.ROLE_ASSIGNMENTS], + ]; + const members: ResourceQueryResult = useResourceQuery( + () => [GroupQueryKeys.GROUP_MEMBERS, props.groupId, {limit: 100}], + (fetcher) => getGroupMembers({...groupsEndpoint, fetcher, groupId: props.groupId, limit: 100}), + {enabled: () => Boolean(props.groupId)}, + ); + const users: ResourceQueryResult = useResourceQuery( + () => [UserQueryKeys.USERS, {limit: 100}], + (fetcher) => getUsers({...usersEndpoint, fetcher, limit: 100}), + {enabled: editable}, + ); + const agents: ResourceQueryResult = useResourceQuery( + () => [AgentQueryKeys.AGENTS, {limit: 100}], + (fetcher) => getAgents({...agentsEndpoint, fetcher, limit: 100}), + {enabled: editable}, + ); + const add: ResourceMutationResult = useResourceMutation( + ({groupId, members: added}: GroupMembersVariables, fetcher) => + addGroupMembers({...groupsEndpoint, fetcher, groupId, members: added}), + invalidatedByMembership, + ); + const remove: ResourceMutationResult = useResourceMutation( + ({groupId, members: removed}: GroupMembersVariables, fetcher) => + removeGroupMembers({...groupsEndpoint, fetcher, groupId, members: removed}), + invalidatedByMembership, ); - const agents: ResourceQueryResult = useGetAgents({limit: 100}, {enabled: () => props.editable}); - const add: ResourceMutationResult = useAddGroupMembers(); - const remove: ResourceMutationResult = useRemoveGroupMembers(); return (): VNode => { const candidates: Partial> = { diff --git a/packages/vue/src/components/admin/RoleAssignments/RoleAssignments.ts b/packages/vue/src/components/admin/RoleAssignments/RoleAssignments.ts index af04c63b..45124e10 100644 --- a/packages/vue/src/components/admin/RoleAssignments/RoleAssignments.ts +++ b/packages/vue/src/components/admin/RoleAssignments/RoleAssignments.ts @@ -1,7 +1,20 @@ // Copyright 2026 The ThunderID Authors // SPDX-License-Identifier: Apache-2.0 -import {resolveManagedIdentityName, toManagementErrorMessage} from '@thunderid/browser'; +import { + AgentQueryKeys, + GroupQueryKeys, + RoleQueryKeys, + UserQueryKeys, + addRoleAssignments, + getAgents, + getGroups, + getRoleAssignments, + getUsers, + removeRoleAssignments, + resolveManagedIdentityName, + toManagementErrorMessage, +} from '@thunderid/browser'; import type { AgentListResponse, BasicAgent, @@ -17,16 +30,9 @@ import type { } from '@thunderid/browser'; import {type Component, type PropType, type Ref, type SetupContext, type VNode, defineComponent, h, ref} from 'vue'; import BaseRoleAssignments from './BaseRoleAssignments'; -import useGetAgents from '../../../composables/management/agents/useGetAgents'; -import useGetGroups from '../../../composables/management/groups/useGetGroups'; -import useAddRoleAssignments, { - type RoleAssignmentsVariables, -} from '../../../composables/management/roles/useAddRoleAssignments'; -import useGetRoleAssignments from '../../../composables/management/roles/useGetRoleAssignments'; -import useRemoveRoleAssignments from '../../../composables/management/roles/useRemoveRoleAssignments'; -import type {ResourceMutationResult} from '../../../composables/management/useResourceMutation'; -import type {ResourceQueryResult} from '../../../composables/management/useResourceQuery'; -import useGetUsers from '../../../composables/management/users/useGetUsers'; +import useManagementEndpoint, {type ManagementEndpoint} from '../../../composables/management/useManagementEndpoint'; +import useResourceMutation, {type ResourceMutationResult} from '../../../composables/management/useResourceMutation'; +import useResourceQuery, {type ResourceQueryResult} from '../../../composables/management/useResourceQuery'; import type {IdentityCandidate} from '../shared/IdentityPicker'; import useAdminTranslation, {type AdminTranslate} from '../shared/useAdminTranslation'; @@ -40,6 +46,11 @@ type RoleAssignmentsProps = Readonly<{ title: string | undefined; }>; +interface RoleAssignmentsVariables { + assignments: RoleAssignmentInput[]; + roleId: string; +} + /** * Lists who a role is assigned to and, unless `editable` is `false`, lets the signed-in user assign it * to users, groups and agents or remove assignments. @@ -70,16 +81,49 @@ const RoleAssignments: Component = defineComponent({ setup(props: RoleAssignmentsProps, {slots}: SetupContext): () => VNode { const t: AdminTranslate = useAdminTranslation(() => props.preferences); const typeFilter: Ref = ref(null); - const assignments: ResourceQueryResult = useGetRoleAssignments( - () => props.roleId, - () => ({limit: 100, type: typeFilter.value ?? undefined}), + const rolesEndpoint: ManagementEndpoint = useManagementEndpoint('roles'); + const usersEndpoint: ManagementEndpoint = useManagementEndpoint('users'); + const groupsEndpoint: ManagementEndpoint = useManagementEndpoint('groups'); + const agentsEndpoint: ManagementEndpoint = useManagementEndpoint('agents'); + const assignments: ResourceQueryResult = useResourceQuery( + () => [RoleQueryKeys.ROLE_ASSIGNMENTS, props.roleId, {limit: 100, type: typeFilter.value ?? undefined}], + (fetcher) => + getRoleAssignments({ + ...rolesEndpoint, + fetcher, + limit: 100, + roleId: props.roleId, + type: typeFilter.value ?? undefined, + }), + {enabled: () => Boolean(props.roleId)}, ); const editable = (): boolean => props.editable; - const users: ResourceQueryResult = useGetUsers({limit: 100}, {enabled: editable}); - const groups: ResourceQueryResult = useGetGroups({limit: 100}, {enabled: editable}); - const agents: ResourceQueryResult = useGetAgents({limit: 100}, {enabled: editable}); - const assign: ResourceMutationResult = useAddRoleAssignments(); - const unassign: ResourceMutationResult = useRemoveRoleAssignments(); + const users: ResourceQueryResult = useResourceQuery( + () => [UserQueryKeys.USERS, {limit: 100}], + (fetcher) => getUsers({...usersEndpoint, fetcher, limit: 100}), + {enabled: editable}, + ); + const groups: ResourceQueryResult = useResourceQuery( + () => [GroupQueryKeys.GROUPS, {limit: 100}], + (fetcher) => getGroups({...groupsEndpoint, fetcher, limit: 100}), + {enabled: editable}, + ); + const agents: ResourceQueryResult = useResourceQuery( + () => [AgentQueryKeys.AGENTS, {limit: 100}], + (fetcher) => getAgents({...agentsEndpoint, fetcher, limit: 100}), + {enabled: editable}, + ); + // Every mounted assignment list is refetched, including the roles listed for an agent. + const assign: ResourceMutationResult = useResourceMutation( + ({roleId, assignments: added}: RoleAssignmentsVariables, fetcher) => + addRoleAssignments({...rolesEndpoint, assignments: added, fetcher, roleId}), + () => [[RoleQueryKeys.ROLE_ASSIGNMENTS]], + ); + const unassign: ResourceMutationResult = useResourceMutation( + ({roleId, assignments: removed}: RoleAssignmentsVariables, fetcher) => + removeRoleAssignments({...rolesEndpoint, assignments: removed, fetcher, roleId}), + () => [[RoleQueryKeys.ROLE_ASSIGNMENTS]], + ); return (): VNode => { const candidates: Partial> = { diff --git a/packages/vue/src/components/admin/RoleCreate/RoleCreate.ts b/packages/vue/src/components/admin/RoleCreate/RoleCreate.ts index 50c3df84..f7b9c1d6 100644 --- a/packages/vue/src/components/admin/RoleCreate/RoleCreate.ts +++ b/packages/vue/src/components/admin/RoleCreate/RoleCreate.ts @@ -1,12 +1,12 @@ // Copyright 2026 The ThunderID Authors // SPDX-License-Identifier: Apache-2.0 -import {toManagementErrorMessage} from '@thunderid/browser'; +import {RoleQueryKeys, createRole, toManagementErrorMessage} from '@thunderid/browser'; import type {Role, CreateRoleRequest} from '@thunderid/browser'; import {type Component, type ComputedRef, type SetupContext, type VNode, defineComponent, h} from 'vue'; import BaseRoleCreate, {type RoleCreateSubmission} from './BaseRoleCreate'; -import useCreateRole from '../../../composables/management/roles/useCreateRole'; -import type {ResourceMutationResult} from '../../../composables/management/useResourceMutation'; +import useManagementEndpoint, {type ManagementEndpoint} from '../../../composables/management/useManagementEndpoint'; +import useResourceMutation, {type ResourceMutationResult} from '../../../composables/management/useResourceMutation'; import {type AdminFormProps, adminFormProps} from '../shared/adminFormProps'; import useAdminTranslation, {type AdminTranslate} from '../shared/useAdminTranslation'; import useDefaultOuId from '../shared/useDefaultOuId'; @@ -40,9 +40,12 @@ const RoleCreate: Component = defineComponent({ setup(props: RoleCreateProps, {emit, slots}: SetupContext): () => VNode { const t: AdminTranslate = useAdminTranslation(() => props.preferences); const resolvedOuId: ComputedRef = useDefaultOuId(() => props.ouId); - const creation: ResourceMutationResult = useCreateRole({ - onSuccess: (created: Role): void => emit('created', created), - }); + const endpoint: ManagementEndpoint = useManagementEndpoint('roles'); + const creation: ResourceMutationResult = useResourceMutation( + (payload: CreateRoleRequest, fetcher) => createRole({...endpoint, fetcher, payload}), + () => [[RoleQueryKeys.ROLES], [RoleQueryKeys.ROLE_ASSIGNMENTS]], + {onSuccess: (created: Role): void => emit('created', created)}, + ); const handleSubmit = ({description, name}: RoleCreateSubmission): void => { if (resolvedOuId.value) { diff --git a/packages/vue/src/components/admin/RoleList/RoleList.ts b/packages/vue/src/components/admin/RoleList/RoleList.ts index e99afdf3..0f099306 100644 --- a/packages/vue/src/components/admin/RoleList/RoleList.ts +++ b/packages/vue/src/components/admin/RoleList/RoleList.ts @@ -1,10 +1,12 @@ // Copyright 2026 The ThunderID Authors // SPDX-License-Identifier: Apache-2.0 +import {RoleQueryKeys, getRoles} from '@thunderid/browser'; import type {RoleListResponse, RoleSummary} from '@thunderid/browser'; import type {Component} from 'vue'; import BaseRoleList from './BaseRoleList'; -import useGetRoles from '../../../composables/management/roles/useGetRoles'; +import useManagementEndpoint, {type ManagementEndpoint} from '../../../composables/management/useManagementEndpoint'; +import useResourceQuery, {type ResourceQueryResult} from '../../../composables/management/useResourceQuery'; import createConnectedAdminList from '../shared/createConnectedAdminList'; /** @@ -23,7 +25,22 @@ const RoleList: Component = createConnectedAdminList Record) => useGetRoles(() => params() as {limit?: number; offset?: number}), + query: (params: () => Record): ResourceQueryResult => { + const endpoint: ManagementEndpoint = useManagementEndpoint('roles'); + + return useResourceQuery( + () => { + const {limit, offset} = params() as {limit?: number; offset?: number}; + + return [RoleQueryKeys.ROLES, {limit, offset}]; + }, + (fetcher) => { + const {limit, offset} = params() as {limit?: number; offset?: number}; + + return getRoles({...endpoint, fetcher, limit, offset}); + }, + ); + }, rows: (data: RoleListResponse | undefined) => data?.roles ?? [], rowsProp: 'roles', selectedProp: 'selectedRoleId', diff --git a/packages/vue/src/components/admin/RolePermissions/RolePermissions.ts b/packages/vue/src/components/admin/RolePermissions/RolePermissions.ts index 6ab6c667..c24c064f 100644 --- a/packages/vue/src/components/admin/RolePermissions/RolePermissions.ts +++ b/packages/vue/src/components/admin/RolePermissions/RolePermissions.ts @@ -1,12 +1,12 @@ // Copyright 2026 The ThunderID Authors // SPDX-License-Identifier: Apache-2.0 -import {toManagementErrorMessage} from '@thunderid/browser'; +import {RoleQueryKeys, getRole, toManagementErrorMessage} from '@thunderid/browser'; import type {Preferences, Role} from '@thunderid/browser'; import {type Component, type PropType, type SetupContext, type VNode, defineComponent, h} from 'vue'; import BaseRolePermissions from './BaseRolePermissions'; -import useGetRole from '../../../composables/management/roles/useGetRole'; -import type {ResourceQueryResult} from '../../../composables/management/useResourceQuery'; +import useManagementEndpoint, {type ManagementEndpoint} from '../../../composables/management/useManagementEndpoint'; +import useResourceQuery, {type ResourceQueryResult} from '../../../composables/management/useResourceQuery'; import useAdminTranslation, {type AdminTranslate} from '../shared/useAdminTranslation'; type RolePermissionsProps = Readonly<{ @@ -44,7 +44,12 @@ const RolePermissions: Component = defineComponent({ }, setup(props: RolePermissionsProps, {slots}: SetupContext): () => VNode { const t: AdminTranslate = useAdminTranslation(() => props.preferences); - const role: ResourceQueryResult = useGetRole(() => props.roleId); + const endpoint: ManagementEndpoint = useManagementEndpoint('roles'); + const role: ResourceQueryResult = useResourceQuery( + () => [RoleQueryKeys.ROLE, props.roleId], + (fetcher) => getRole({...endpoint, fetcher, roleId: props.roleId}), + {enabled: () => Boolean(props.roleId)}, + ); return (): VNode => h( diff --git a/packages/vue/src/components/admin/UserCreate/UserCreate.ts b/packages/vue/src/components/admin/UserCreate/UserCreate.ts index bd137b17..d75d06db 100644 --- a/packages/vue/src/components/admin/UserCreate/UserCreate.ts +++ b/packages/vue/src/components/admin/UserCreate/UserCreate.ts @@ -1,7 +1,7 @@ // Copyright 2026 The ThunderID Authors // SPDX-License-Identifier: Apache-2.0 -import {toManagementErrorMessage} from '@thunderid/browser'; +import {UserQueryKeys, createUser, getUserTypes, toManagementErrorMessage} from '@thunderid/browser'; import type { CreateManagedUserRequest, ManagedUser, @@ -21,10 +21,9 @@ import { } from 'vue'; import BaseUserCreate, {type UserCreateSubmission} from './BaseUserCreate'; import {DEFAULT_USER_CREATE_FIELDS, type UserCreateField} from './userCreateFields'; -import type {ResourceMutationResult} from '../../../composables/management/useResourceMutation'; -import type {ResourceQueryResult} from '../../../composables/management/useResourceQuery'; -import useCreateUser from '../../../composables/management/users/useCreateUser'; -import useGetUserTypes from '../../../composables/management/users/useGetUserTypes'; +import useManagementEndpoint, {type ManagementEndpoint} from '../../../composables/management/useManagementEndpoint'; +import useResourceMutation, {type ResourceMutationResult} from '../../../composables/management/useResourceMutation'; +import useResourceQuery, {type ResourceQueryResult} from '../../../composables/management/useResourceQuery'; import InviteUser from '../../auth/invite-user/InviteUser'; import {type AdminFormProps, adminFormProps} from '../shared/adminFormProps'; import useAdminTranslation, {type AdminTranslate} from '../shared/useAdminTranslation'; @@ -75,13 +74,18 @@ const UserCreate: Component = defineComponent({ setup(props: UserCreateProps, {emit, slots}: SetupContext): () => VNode { const t: AdminTranslate = useAdminTranslation(() => props.preferences); const resolvedOuId: ComputedRef = useDefaultOuId(() => props.ouId); - const userTypes: ResourceQueryResult = useGetUserTypes( - {limit: 100}, + const userTypesEndpoint: ManagementEndpoint = useManagementEndpoint('userTypes'); + const usersEndpoint: ManagementEndpoint = useManagementEndpoint('users'); + const userTypes: ResourceQueryResult = useResourceQuery( + () => [UserQueryKeys.USER_TYPES, {limit: 100}], + (fetcher) => getUserTypes({...userTypesEndpoint, fetcher, limit: 100}), {enabled: () => props.mode === 'api'}, ); - const creation: ResourceMutationResult = useCreateUser({ - onSuccess: (user: ManagedUser): void => emit('created', user), - }); + const creation: ResourceMutationResult = useResourceMutation( + (payload: CreateManagedUserRequest, fetcher) => createUser({...usersEndpoint, fetcher, payload}), + () => [[UserQueryKeys.USERS]], + {onSuccess: (user: ManagedUser): void => emit('created', user)}, + ); const options: ComputedRef = computed(() => (userTypes.data.value?.types ?? []).map((userType: ManagedUserType) => ({ diff --git a/packages/vue/src/components/admin/UserDetails/UserDetails.ts b/packages/vue/src/components/admin/UserDetails/UserDetails.ts index 0c93e3d4..60b8dc9a 100644 --- a/packages/vue/src/components/admin/UserDetails/UserDetails.ts +++ b/packages/vue/src/components/admin/UserDetails/UserDetails.ts @@ -1,16 +1,21 @@ // Copyright 2026 The ThunderID Authors // SPDX-License-Identifier: Apache-2.0 -import {toManagementErrorMessage} from '@thunderid/browser'; -import type {ManagedUser, MembershipGroupListResponse, Preferences} from '@thunderid/browser'; +import { + GroupQueryKeys, + UserQueryKeys, + deleteUser, + getUser, + getUserGroups, + toManagementErrorMessage, + updateUser, +} from '@thunderid/browser'; +import type {ManagedUser, MembershipGroupListResponse, Preferences, UpdateManagedUserRequest} from '@thunderid/browser'; import {type Component, type PropType, type SetupContext, type VNode, defineComponent, h} from 'vue'; import BaseUserDetails from './BaseUserDetails'; -import type {ResourceMutationResult} from '../../../composables/management/useResourceMutation'; -import type {ResourceQueryResult} from '../../../composables/management/useResourceQuery'; -import useDeleteUser from '../../../composables/management/users/useDeleteUser'; -import useGetUser from '../../../composables/management/users/useGetUser'; -import useGetUserGroups from '../../../composables/management/users/useGetUserGroups'; -import useUpdateUser, {type UpdateUserVariables} from '../../../composables/management/users/useUpdateUser'; +import useManagementEndpoint, {type ManagementEndpoint} from '../../../composables/management/useManagementEndpoint'; +import useResourceMutation, {type ResourceMutationResult} from '../../../composables/management/useResourceMutation'; +import useResourceQuery, {type ResourceQueryResult} from '../../../composables/management/useResourceQuery'; import useAdminTranslation, {type AdminTranslate} from '../shared/useAdminTranslation'; type UserDetailsProps = Readonly<{ @@ -22,6 +27,11 @@ type UserDetailsProps = Readonly<{ userId: string; }>; +interface UpdateUserVariables { + data: UpdateManagedUserRequest; + userId: string; +} + /** * Shows a user and the groups they belong to, and optionally lets the signed-in user edit or delete * them. Emits `deleted` after the user has been deleted. @@ -52,12 +62,27 @@ const UserDetails: Component = defineComponent({ emits: ['deleted'], setup(props: UserDetailsProps, {emit, slots}: SetupContext): () => VNode { const t: AdminTranslate = useAdminTranslation(() => props.preferences); - const user: ResourceQueryResult = useGetUser(() => props.userId); - const groups: ResourceQueryResult = useGetUserGroups(() => props.userId, { - limit: 100, - }); - const deletion: ResourceMutationResult = useDeleteUser({onSuccess: (): void => emit('deleted')}); - const update: ResourceMutationResult = useUpdateUser(); + const endpoint: ManagementEndpoint = useManagementEndpoint('users'); + const hasUserId = (): boolean => Boolean(props.userId); + const user: ResourceQueryResult = useResourceQuery( + () => [UserQueryKeys.USER, props.userId], + (fetcher) => getUser({...endpoint, fetcher, userId: props.userId}), + {enabled: hasUserId}, + ); + const groups: ResourceQueryResult = useResourceQuery( + () => [GroupQueryKeys.MEMBERSHIPS, 'user', props.userId, {limit: 100}], + (fetcher) => getUserGroups({...endpoint, fetcher, limit: 100, userId: props.userId}), + {enabled: hasUserId}, + ); + const deletion: ResourceMutationResult = useResourceMutation( + (userId: string, fetcher) => deleteUser({...endpoint, fetcher, userId}), + () => [[UserQueryKeys.USERS]], + {onSuccess: (): void => emit('deleted')}, + ); + const update: ResourceMutationResult = useResourceMutation( + ({userId, data}: UpdateUserVariables, fetcher) => updateUser({...endpoint, fetcher, payload: data, userId}), + ({userId}: UpdateUserVariables) => [[UserQueryKeys.USER, userId], [UserQueryKeys.USERS]], + ); return (): VNode => { const current: ManagedUser | undefined = user.data.value; diff --git a/packages/vue/src/components/admin/UserList/UserList.ts b/packages/vue/src/components/admin/UserList/UserList.ts index 643665a0..3a1ec708 100644 --- a/packages/vue/src/components/admin/UserList/UserList.ts +++ b/packages/vue/src/components/admin/UserList/UserList.ts @@ -1,10 +1,12 @@ // Copyright 2026 The ThunderID Authors // SPDX-License-Identifier: Apache-2.0 +import {UserQueryKeys, getUsers} from '@thunderid/browser'; import type {ApiFilteringParams, ManagedUser, ManagedUserListResponse} from '@thunderid/browser'; import type {Component} from 'vue'; import BaseUserList from './BaseUserList'; -import useGetUsers from '../../../composables/management/users/useGetUsers'; +import useManagementEndpoint, {type ManagementEndpoint} from '../../../composables/management/useManagementEndpoint'; +import useResourceQuery, {type ResourceQueryResult} from '../../../composables/management/useResourceQuery'; import createConnectedAdminList from '../shared/createConnectedAdminList'; import type {AdminTranslate} from '../shared/useAdminTranslation'; @@ -26,7 +28,22 @@ const UserList: Component = createConnectedAdminList Record) => useGetUsers(() => params() as ApiFilteringParams), + query: (params: () => Record): ResourceQueryResult => { + const endpoint: ManagementEndpoint = useManagementEndpoint('users'); + + return useResourceQuery( + () => { + const {filter, limit, offset} = params() as ApiFilteringParams; + + return [UserQueryKeys.USERS, {filter, limit, offset}]; + }, + (fetcher) => { + const {filter, limit, offset} = params() as ApiFilteringParams; + + return getUsers({...endpoint, fetcher, filter, limit, offset}); + }, + ); + }, rows: (data: ManagedUserListResponse | undefined) => data?.users ?? [], rowsProp: 'users', searchAttributes: (t: AdminTranslate) => [ diff --git a/packages/vue/src/components/admin/shared/ClientSecretNotice.ts b/packages/vue/src/components/admin/shared/ClientSecretNotice.ts new file mode 100644 index 00000000..5f8a71cf --- /dev/null +++ b/packages/vue/src/components/admin/shared/ClientSecretNotice.ts @@ -0,0 +1,48 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {withVendorCSSClassPrefix} from '@thunderid/browser'; +import {type Component, type PropType, type VNode, defineComponent, h} from 'vue'; +import type {AdminTranslate} from './useAdminTranslation'; +import Alert from '../../primitives/Alert/Alert'; +import Button from '../../primitives/Button/Button'; +import CopyableText from '../../primitives/CopyableText/CopyableText'; + +const cls = (element: string): string => withVendorCSSClassPrefix(`admin__${element}`); + +type ClientSecretNoticeProps = Readonly<{ + onDismiss: (() => void) | undefined; + secret: string; + t: AdminTranslate; +}>; + +/** + * Shows a client secret the one time it can be read, masked until revealed, with a copy button. + */ +const ClientSecretNotice: Component = defineComponent({ + name: 'AdminClientSecretNotice', + props: { + /** Called once the secret has been copied, after which it cannot be read again. */ + onDismiss: {default: undefined, type: Function as PropType<() => void>}, + secret: {required: true, type: String}, + t: {required: true, type: Function as PropType}, + }, + setup(props: ClientSecretNoticeProps): () => VNode { + return (): VNode => + h('div', {class: cls('edit-form')}, [ + h( + Alert, + {role: 'status', severity: 'warning'}, + {default: (): string[] => [props.t('admin.applications.secret.notice')]}, + ), + h(CopyableText, {label: props.t('admin.applications.secret.title'), masked: true, value: props.secret}), + props.onDismiss + ? h('div', {class: cls('form-actions')}, [ + h(Button, {onClick: props.onDismiss}, {default: (): string => props.t('admin.applications.secret.done')}), + ]) + : null, + ]); + }, +}); + +export default ClientSecretNotice; diff --git a/packages/vue/src/components/admin/shared/createAdminList.ts b/packages/vue/src/components/admin/shared/createAdminList.ts index 194866f5..e7ff7942 100644 --- a/packages/vue/src/components/admin/shared/createAdminList.ts +++ b/packages/vue/src/components/admin/shared/createAdminList.ts @@ -14,8 +14,8 @@ import DataTable, {type DataTableColumn} from '../../primitives/DataTable/DataTa export interface AdminListDefinition { /** BEM block of the component. */ block: string; - /** Builds the columns with the active translation function. */ - columns: (t: AdminTranslate) => DataTableColumn[]; + /** Builds the columns with the active translation function, for the rows being shown. */ + columns: (t: AdminTranslate, rows: T[]) => DataTableColumn[]; /** Translation key of the message shown when there are no rows. */ emptyKey: string; /** Returns a row's identifier. */ @@ -122,7 +122,7 @@ const createAdminList = (definition: AdminListDefinition): Component => const onPageChange: ((offset: number) => void) | undefined = props['onPageChange'] as | ((offset: number) => void) | undefined; - const columns: DataTableColumn[] = definition.columns(t); + const columns: DataTableColumn[] = definition.columns(t, rows); const search: AdminSearchValue | null = props['search'] as AdminSearchValue | null; const searchAttributes: SelectOption[] | undefined = props['searchAttributes'] as SelectOption[] | undefined; const onSearch: ((next: AdminSearchValue | null) => void) | undefined = props['onSearch'] as diff --git a/packages/vue/src/components/admin/shared/createConnectedAdminList.ts b/packages/vue/src/components/admin/shared/createConnectedAdminList.ts index ca6ae689..34902d61 100644 --- a/packages/vue/src/components/admin/shared/createConnectedAdminList.ts +++ b/packages/vue/src/components/admin/shared/createConnectedAdminList.ts @@ -48,7 +48,7 @@ export interface ConnectedAdminListDefinition { } /** - * Creates a list that fetches its own pages through a management composable and renders the given + * Creates a list that fetches its own pages through `useResourceQuery` and renders the given * presentational list. Server errors are mapped to translated messages; raw server text is never shown. * * @param definition - What the list fetches and renders. diff --git a/packages/vue/src/components/admin/shared/renderApplicationFields.ts b/packages/vue/src/components/admin/shared/renderApplicationFields.ts new file mode 100644 index 00000000..fd524ef8 --- /dev/null +++ b/packages/vue/src/components/admin/shared/renderApplicationFields.ts @@ -0,0 +1,99 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {APPLICATION_GRANT_TYPES, withVendorCSSClassPrefix} from '@thunderid/browser'; +import {type VNode, h} from 'vue'; +import type {AdminTranslate} from './useAdminTranslation'; +import Checkbox from '../../primitives/Checkbox/Checkbox'; +import TextField from '../../primitives/TextField/TextField'; + +const cls = (element: string): string => withVendorCSSClassPrefix(`admin__${element}`); + +/** + * The text fields of the application forms. + */ +export type ApplicationTextField = 'description' | 'logoUrl' | 'name' | 'redirectUris' | 'url'; + +/** + * The values of the application forms. `redirectUris` is the text of the field, URIs separated by + * commas. + */ +export interface ApplicationFormValues { + description: string; + grantTypes: string[]; + logoUrl: string; + name: string; + publicClient: boolean; + redirectUris: string; + url: string; +} + +/** + * Renders the general fields and OAuth basics of an application, shared by the create form and the + * edit form. The public client choice shows only when `onPublicClientChange` is given. + */ +const renderApplicationFields = ({ + onGrantTypesChange, + onInputChange, + onPublicClientChange, + t, + uid, + values, +}: { + onGrantTypesChange: (grantTypes: string[]) => void; + onInputChange: (field: ApplicationTextField, value: string) => void; + onPublicClientChange?: (publicClient: boolean) => void; + t: AdminTranslate; + /** A unique prefix for the field names. */ + uid: string; + values: ApplicationFormValues; +}): VNode[] => { + const text = (field: ApplicationTextField, label: string, extra: Record = {}): VNode => + h(TextField, { + autoComplete: 'off', + label, + modelValue: values[field], + name: `${uid}-${field}`, + 'onUpdate:modelValue': (value: string): void => onInputChange(field, value), + ...extra, + }); + + return [ + text('name', t('admin.applications.fields.name'), {required: true}), + text('description', t('admin.applications.fields.description')), + text('url', t('admin.applications.fields.url')), + text('logoUrl', t('admin.applications.fields.logo_url')), + text('redirectUris', t('admin.applications.fields.redirect_uris'), { + helperText: t('admin.applications.fields.redirect_uris_hint'), + }), + h('fieldset', {class: cls('row'), style: {border: 0, flexWrap: 'wrap', margin: 0, padding: 0}}, [ + h('legend', {class: cls('muted')}, t('admin.applications.fields.grant_types')), + ...APPLICATION_GRANT_TYPES.map((grantType: string) => + h(Checkbox, { + key: grantType, + label: t(`admin.applications.grant_types.${grantType}`), + modelValue: values.grantTypes.includes(grantType), + name: `${uid}-grant-type-${grantType}`, + 'onUpdate:modelValue': (checked: boolean): void => + onGrantTypesChange( + checked + ? [...values.grantTypes, grantType] + : values.grantTypes.filter((value: string) => value !== grantType), + ), + }), + ), + ]), + ...(onPublicClientChange + ? [ + h(Checkbox, { + label: t('admin.applications.fields.public_client'), + modelValue: values.publicClient, + name: `${uid}-public-client`, + 'onUpdate:modelValue': onPublicClientChange, + }), + ] + : []), + ]; +}; + +export default renderApplicationFields; diff --git a/packages/vue/src/composables/management/agents/useCreateAgent.ts b/packages/vue/src/composables/management/agents/useCreateAgent.ts deleted file mode 100644 index 4f854db7..00000000 --- a/packages/vue/src/composables/management/agents/useCreateAgent.ts +++ /dev/null @@ -1,26 +0,0 @@ -// Copyright 2026 The ThunderID Authors -// SPDX-License-Identifier: Apache-2.0 - -import {AgentQueryKeys, createAgent, type Agent, type CreateAgentRequest} from '@thunderid/browser'; -import useManagementEndpoint, {type ManagementEndpoint} from '../useManagementEndpoint'; -import useResourceMutation, {type ResourceMutationOptions, type ResourceMutationResult} from '../useResourceMutation'; - -/** - * Creates an agent, then refetches any mounted agent lists. Call `mutate(payload)`. - * - * @param options - Mutation options, including `onSuccess`, `onError`, and a per-composable `fetcher`. - * @returns The mutation state. - */ -const useCreateAgent = ( - options?: ResourceMutationOptions, -): ResourceMutationResult => { - const endpoint: ManagementEndpoint = useManagementEndpoint('agents'); - - return useResourceMutation( - (payload: CreateAgentRequest, fetcher) => createAgent({...endpoint, fetcher, payload}), - () => [[AgentQueryKeys.AGENTS]], - options, - ); -}; - -export default useCreateAgent; diff --git a/packages/vue/src/composables/management/agents/useDeleteAgent.ts b/packages/vue/src/composables/management/agents/useDeleteAgent.ts deleted file mode 100644 index 95c56a11..00000000 --- a/packages/vue/src/composables/management/agents/useDeleteAgent.ts +++ /dev/null @@ -1,24 +0,0 @@ -// Copyright 2026 The ThunderID Authors -// SPDX-License-Identifier: Apache-2.0 - -import {AgentQueryKeys, deleteAgent} from '@thunderid/browser'; -import useManagementEndpoint, {type ManagementEndpoint} from '../useManagementEndpoint'; -import useResourceMutation, {type ResourceMutationOptions, type ResourceMutationResult} from '../useResourceMutation'; - -/** - * Deletes an agent, then refetches any mounted agent lists. Call `mutate(agentId)` to delete. - * - * @param options - Mutation options, including `onSuccess`, `onError`, and a per-composable `fetcher`. - * @returns The mutation state. - */ -const useDeleteAgent = (options?: ResourceMutationOptions): ResourceMutationResult => { - const endpoint: ManagementEndpoint = useManagementEndpoint('agents'); - - return useResourceMutation( - (agentId: string, fetcher) => deleteAgent({...endpoint, agentId, fetcher}), - () => [[AgentQueryKeys.AGENTS]], - options, - ); -}; - -export default useDeleteAgent; diff --git a/packages/vue/src/composables/management/agents/useGetAgent.ts b/packages/vue/src/composables/management/agents/useGetAgent.ts deleted file mode 100644 index a5ced76e..00000000 --- a/packages/vue/src/composables/management/agents/useGetAgent.ts +++ /dev/null @@ -1,29 +0,0 @@ -// Copyright 2026 The ThunderID Authors -// SPDX-License-Identifier: Apache-2.0 - -import {AgentQueryKeys, getAgent, type Agent} from '@thunderid/browser'; -import {type MaybeRefOrGetter, toValue} from 'vue'; -import useManagementEndpoint, {type ManagementEndpoint} from '../useManagementEndpoint'; -import useResourceQuery, {type ResourceQueryOptions, type ResourceQueryResult} from '../useResourceQuery'; - -/** - * Fetches a single agent. Does nothing until `agentId` is set. - * - * @param agentId - Identifier of the resource. A ref or getter refetches when it changes. - * @param options - Query options, including a per-composable `fetcher`. - * @returns The query state. - */ -const useGetAgent = ( - agentId: MaybeRefOrGetter, - options?: ResourceQueryOptions, -): ResourceQueryResult => { - const endpoint: ManagementEndpoint = useManagementEndpoint('agents'); - - return useResourceQuery( - () => [AgentQueryKeys.AGENT, toValue(agentId)], - (fetcher) => getAgent({...endpoint, fetcher, agentId: toValue(agentId)!}), - {...options, enabled: () => Boolean(toValue(agentId)) && toValue(options?.enabled) !== false}, - ); -}; - -export default useGetAgent; diff --git a/packages/vue/src/composables/management/agents/useGetAgentGroups.ts b/packages/vue/src/composables/management/agents/useGetAgentGroups.ts deleted file mode 100644 index 465c4baa..00000000 --- a/packages/vue/src/composables/management/agents/useGetAgentGroups.ts +++ /dev/null @@ -1,40 +0,0 @@ -// Copyright 2026 The ThunderID Authors -// SPDX-License-Identifier: Apache-2.0 - -import {GroupQueryKeys, getAgentGroups, type MembershipGroupListResponse} from '@thunderid/browser'; -import {type MaybeRefOrGetter, toValue} from 'vue'; -import useManagementEndpoint, {type ManagementEndpoint} from '../useManagementEndpoint'; -import useResourceQuery, {type ResourceQueryOptions, type ResourceQueryResult} from '../useResourceQuery'; - -/** - * Fetches a page of the groups an agent belongs to. - * - * @param agentId - Identifier of the parent resource. Fetching waits until it is defined. A ref or - * getter refetches when it changes. - * @param params - Pagination parameters. A ref or getter refetches when it changes. - * @param options - Query options, including a per-composable `fetcher`. - * @returns The query state. - */ -const useGetAgentGroups = ( - agentId: MaybeRefOrGetter, - params: MaybeRefOrGetter<{limit?: number; offset?: number}> = {}, - options?: ResourceQueryOptions, -): ResourceQueryResult => { - const endpoint: ManagementEndpoint = useManagementEndpoint('agents'); - - return useResourceQuery( - () => { - const {limit, offset} = toValue(params); - - return [GroupQueryKeys.MEMBERSHIPS, 'agent', toValue(agentId), {limit, offset}]; - }, - (fetcher) => { - const {limit, offset} = toValue(params); - - return getAgentGroups({...endpoint, fetcher, limit, offset, agentId: toValue(agentId)!}); - }, - {...options, enabled: () => Boolean(toValue(agentId)) && toValue(options?.enabled) !== false}, - ); -}; - -export default useGetAgentGroups; diff --git a/packages/vue/src/composables/management/agents/useGetAgentRoles.ts b/packages/vue/src/composables/management/agents/useGetAgentRoles.ts deleted file mode 100644 index 5ea9ec16..00000000 --- a/packages/vue/src/composables/management/agents/useGetAgentRoles.ts +++ /dev/null @@ -1,41 +0,0 @@ -// Copyright 2026 The ThunderID Authors -// SPDX-License-Identifier: Apache-2.0 - -import {RoleQueryKeys, getAgentRoles, type AgentRoleListResponse} from '@thunderid/browser'; -import {type MaybeRefOrGetter, toValue} from 'vue'; -import useManagementEndpoint, {type ManagementEndpoint} from '../useManagementEndpoint'; -import useResourceQuery, {type ResourceQueryOptions, type ResourceQueryResult} from '../useResourceQuery'; - -/** - * Fetches a page of the role names assigned to an agent, directly or through its groups. Refetches - * whenever a role assignment or a group membership changes. - * - * @param agentId - Identifier of the parent resource. Fetching waits until it is defined. A ref or - * getter refetches when it changes. - * @param params - Pagination parameters. A ref or getter refetches when it changes. - * @param options - Query options, including a per-composable `fetcher`. - * @returns The query state. - */ -const useGetAgentRoles = ( - agentId: MaybeRefOrGetter, - params: MaybeRefOrGetter<{limit?: number; offset?: number}> = {}, - options?: ResourceQueryOptions, -): ResourceQueryResult => { - const endpoint: ManagementEndpoint = useManagementEndpoint('agents'); - - return useResourceQuery( - () => { - const {limit, offset} = toValue(params); - - return [RoleQueryKeys.ROLE_ASSIGNMENTS, 'agent', toValue(agentId), {limit, offset}]; - }, - (fetcher) => { - const {limit, offset} = toValue(params); - - return getAgentRoles({...endpoint, fetcher, limit, offset, agentId: toValue(agentId)!}); - }, - {...options, enabled: () => Boolean(toValue(agentId)) && toValue(options?.enabled) !== false}, - ); -}; - -export default useGetAgentRoles; diff --git a/packages/vue/src/composables/management/agents/useGetAgentTypes.ts b/packages/vue/src/composables/management/agents/useGetAgentTypes.ts deleted file mode 100644 index 954a0e08..00000000 --- a/packages/vue/src/composables/management/agents/useGetAgentTypes.ts +++ /dev/null @@ -1,37 +0,0 @@ -// Copyright 2026 The ThunderID Authors -// SPDX-License-Identifier: Apache-2.0 - -import {AgentQueryKeys, getAgentTypes, type AgentTypeListResponse} from '@thunderid/browser'; -import {type MaybeRefOrGetter, toValue} from 'vue'; -import useManagementEndpoint, {type ManagementEndpoint} from '../useManagementEndpoint'; -import useResourceQuery, {type ResourceQueryOptions, type ResourceQueryResult} from '../useResourceQuery'; - -/** - * Fetches a page of agent types. - * - * @param params - Pagination parameters. A ref or getter refetches when it changes. - * @param options - Query options, including a per-composable `fetcher`. - * @returns The query state. - */ -const useGetAgentTypes = ( - params: MaybeRefOrGetter<{limit?: number; offset?: number}> = {}, - options?: ResourceQueryOptions, -): ResourceQueryResult => { - const endpoint: ManagementEndpoint = useManagementEndpoint('agentTypes'); - - return useResourceQuery( - () => { - const {limit, offset} = toValue(params); - - return [AgentQueryKeys.AGENT_TYPES, {limit, offset}]; - }, - (fetcher) => { - const {limit, offset} = toValue(params); - - return getAgentTypes({...endpoint, fetcher, limit, offset}); - }, - options, - ); -}; - -export default useGetAgentTypes; diff --git a/packages/vue/src/composables/management/agents/useGetAgents.ts b/packages/vue/src/composables/management/agents/useGetAgents.ts deleted file mode 100644 index e42b6744..00000000 --- a/packages/vue/src/composables/management/agents/useGetAgents.ts +++ /dev/null @@ -1,49 +0,0 @@ -// Copyright 2026 The ThunderID Authors -// SPDX-License-Identifier: Apache-2.0 - -import {AgentQueryKeys, getAgents, type AgentListResponse} from '@thunderid/browser'; -import {type MaybeRefOrGetter, toValue} from 'vue'; -import useManagementEndpoint, {type ManagementEndpoint} from '../useManagementEndpoint'; -import useResourceQuery, {type ResourceQueryOptions, type ResourceQueryResult} from '../useResourceQuery'; - -/** - * Parameters for {@link useGetAgents}. - */ -export interface UseGetAgentsParams { - /** - * Server-side filter on an agent attribute, for example `name eq "Calendar Agent"`. - */ - filter?: string; - limit?: number; - offset?: number; -} - -/** - * Fetches a page of agents. - * - * @param params - Pagination and filter parameters. A ref or getter refetches when it changes. - * @param options - Query options, including a per-composable `fetcher`. - * @returns The query state. - */ -const useGetAgents = ( - params: MaybeRefOrGetter = {}, - options?: ResourceQueryOptions, -): ResourceQueryResult => { - const endpoint: ManagementEndpoint = useManagementEndpoint('agents'); - - return useResourceQuery( - () => { - const {filter, limit, offset} = toValue(params); - - return [AgentQueryKeys.AGENTS, {filter, limit, offset}]; - }, - (fetcher) => { - const {filter, limit, offset} = toValue(params); - - return getAgents({...endpoint, fetcher, filter, limit, offset}); - }, - options, - ); -}; - -export default useGetAgents; diff --git a/packages/vue/src/composables/management/agents/useUpdateAgent.ts b/packages/vue/src/composables/management/agents/useUpdateAgent.ts deleted file mode 100644 index 147b3af5..00000000 --- a/packages/vue/src/composables/management/agents/useUpdateAgent.ts +++ /dev/null @@ -1,34 +0,0 @@ -// Copyright 2026 The ThunderID Authors -// SPDX-License-Identifier: Apache-2.0 - -import {AgentQueryKeys, type Agent, type UpdateAgentRequest, updateAgent} from '@thunderid/browser'; -import useManagementEndpoint, {type ManagementEndpoint} from '../useManagementEndpoint'; -import useResourceMutation, {type ResourceMutationOptions, type ResourceMutationResult} from '../useResourceMutation'; - -/** - * Variables accepted by {@link useUpdateAgent}'s `mutate`. - */ -export interface UpdateAgentVariables { - agentId: string; - data: UpdateAgentRequest; -} - -/** - * Updates an agent, then refetches it and any mounted agent lists. Call `mutate({agentId, data})`. - * - * @param options - Mutation options, including `onSuccess`, `onError`, and a per-composable `fetcher`. - * @returns The mutation state. - */ -const useUpdateAgent = ( - options?: ResourceMutationOptions, -): ResourceMutationResult => { - const endpoint: ManagementEndpoint = useManagementEndpoint('agents'); - - return useResourceMutation( - ({agentId, data}: UpdateAgentVariables, fetcher) => updateAgent({...endpoint, agentId, fetcher, payload: data}), - ({agentId}: UpdateAgentVariables) => [[AgentQueryKeys.AGENT, agentId], [AgentQueryKeys.AGENTS]], - options, - ); -}; - -export default useUpdateAgent; diff --git a/packages/vue/src/composables/management/applications/useCreateApplication.ts b/packages/vue/src/composables/management/applications/useCreateApplication.ts deleted file mode 100644 index 04f13584..00000000 --- a/packages/vue/src/composables/management/applications/useCreateApplication.ts +++ /dev/null @@ -1,31 +0,0 @@ -// Copyright 2026 The ThunderID Authors -// SPDX-License-Identifier: Apache-2.0 - -import { - ApplicationQueryKeys, - createApplication, - type Application, - type CreateApplicationRequest, -} from '@thunderid/browser'; -import useManagementEndpoint, {type ManagementEndpoint} from '../useManagementEndpoint'; -import useResourceMutation, {type ResourceMutationOptions, type ResourceMutationResult} from '../useResourceMutation'; - -/** - * Creates an application, then refetches any mounted application lists. Call `mutate(payload)`. - * - * @param options - Mutation options, including `onSuccess`, `onError`, and a per-composable `fetcher`. - * @returns The mutation state. - */ -const useCreateApplication = ( - options?: ResourceMutationOptions, -): ResourceMutationResult => { - const endpoint: ManagementEndpoint = useManagementEndpoint('applications'); - - return useResourceMutation( - (payload: CreateApplicationRequest, fetcher) => createApplication({...endpoint, fetcher, payload}), - () => [[ApplicationQueryKeys.APPLICATIONS]], - options, - ); -}; - -export default useCreateApplication; diff --git a/packages/vue/src/composables/management/applications/useDeleteApplication.ts b/packages/vue/src/composables/management/applications/useDeleteApplication.ts deleted file mode 100644 index 1ebec27e..00000000 --- a/packages/vue/src/composables/management/applications/useDeleteApplication.ts +++ /dev/null @@ -1,26 +0,0 @@ -// Copyright 2026 The ThunderID Authors -// SPDX-License-Identifier: Apache-2.0 - -import {ApplicationQueryKeys, deleteApplication} from '@thunderid/browser'; -import useManagementEndpoint, {type ManagementEndpoint} from '../useManagementEndpoint'; -import useResourceMutation, {type ResourceMutationOptions, type ResourceMutationResult} from '../useResourceMutation'; - -/** - * Deletes an application, then refetches any mounted application lists. Call `mutate(applicationId)`. - * - * @param options - Mutation options, including `onSuccess`, `onError`, and a per-composable `fetcher`. - * @returns The mutation state. - */ -const useDeleteApplication = ( - options?: ResourceMutationOptions, -): ResourceMutationResult => { - const endpoint: ManagementEndpoint = useManagementEndpoint('applications'); - - return useResourceMutation( - (applicationId: string, fetcher) => deleteApplication({...endpoint, applicationId, fetcher}), - () => [[ApplicationQueryKeys.APPLICATIONS]], - options, - ); -}; - -export default useDeleteApplication; diff --git a/packages/vue/src/composables/management/applications/useGetApplication.ts b/packages/vue/src/composables/management/applications/useGetApplication.ts deleted file mode 100644 index c42db6c5..00000000 --- a/packages/vue/src/composables/management/applications/useGetApplication.ts +++ /dev/null @@ -1,29 +0,0 @@ -// Copyright 2026 The ThunderID Authors -// SPDX-License-Identifier: Apache-2.0 - -import {ApplicationQueryKeys, getApplication, type Application} from '@thunderid/browser'; -import {type MaybeRefOrGetter, toValue} from 'vue'; -import useManagementEndpoint, {type ManagementEndpoint} from '../useManagementEndpoint'; -import useResourceQuery, {type ResourceQueryOptions, type ResourceQueryResult} from '../useResourceQuery'; - -/** - * Fetches a single application. Does nothing until `applicationId` is set. - * - * @param applicationId - Identifier of the resource. A ref or getter refetches when it changes. - * @param options - Query options, including a per-composable `fetcher`. - * @returns The query state. - */ -const useGetApplication = ( - applicationId: MaybeRefOrGetter, - options?: ResourceQueryOptions, -): ResourceQueryResult => { - const endpoint: ManagementEndpoint = useManagementEndpoint('applications'); - - return useResourceQuery( - () => [ApplicationQueryKeys.APPLICATION, toValue(applicationId)], - (fetcher) => getApplication({...endpoint, fetcher, applicationId: toValue(applicationId)!}), - {...options, enabled: () => Boolean(toValue(applicationId)) && toValue(options?.enabled) !== false}, - ); -}; - -export default useGetApplication; diff --git a/packages/vue/src/composables/management/applications/useGetApplications.ts b/packages/vue/src/composables/management/applications/useGetApplications.ts deleted file mode 100644 index 75665662..00000000 --- a/packages/vue/src/composables/management/applications/useGetApplications.ts +++ /dev/null @@ -1,45 +0,0 @@ -// Copyright 2026 The ThunderID Authors -// SPDX-License-Identifier: Apache-2.0 - -import {ApplicationQueryKeys, getApplications, type ApplicationListResponse} from '@thunderid/browser'; -import {type MaybeRefOrGetter, toValue} from 'vue'; -import useManagementEndpoint, {type ManagementEndpoint} from '../useManagementEndpoint'; -import useResourceQuery, {type ResourceQueryOptions, type ResourceQueryResult} from '../useResourceQuery'; - -/** - * Parameters for {@link useGetApplications}. - */ -export interface UseGetApplicationsParams { - limit?: number; - offset?: number; -} - -/** - * Fetches a page of applications. - * - * @param params - Pagination parameters. A ref or getter refetches when it changes. - * @param options - Query options, including a per-composable `fetcher`. - * @returns The query state. - */ -const useGetApplications = ( - params: MaybeRefOrGetter = {}, - options?: ResourceQueryOptions, -): ResourceQueryResult => { - const endpoint: ManagementEndpoint = useManagementEndpoint('applications'); - - return useResourceQuery( - () => { - const {limit, offset} = toValue(params); - - return [ApplicationQueryKeys.APPLICATIONS, {limit, offset}]; - }, - (fetcher) => { - const {limit, offset} = toValue(params); - - return getApplications({...endpoint, fetcher, limit, offset}); - }, - options, - ); -}; - -export default useGetApplications; diff --git a/packages/vue/src/composables/management/applications/useUpdateApplication.ts b/packages/vue/src/composables/management/applications/useUpdateApplication.ts deleted file mode 100644 index 8cfe4a05..00000000 --- a/packages/vue/src/composables/management/applications/useUpdateApplication.ts +++ /dev/null @@ -1,44 +0,0 @@ -// Copyright 2026 The ThunderID Authors -// SPDX-License-Identifier: Apache-2.0 - -import { - ApplicationQueryKeys, - type Application, - type UpdateApplicationRequest, - updateApplication, -} from '@thunderid/browser'; -import useManagementEndpoint, {type ManagementEndpoint} from '../useManagementEndpoint'; -import useResourceMutation, {type ResourceMutationOptions, type ResourceMutationResult} from '../useResourceMutation'; - -/** - * Variables accepted by {@link useUpdateApplication}'s `mutate`. - */ -export interface UpdateApplicationVariables { - applicationId: string; - data: UpdateApplicationRequest; -} - -/** - * Updates an application, then refetches it and any mounted application lists. Call - * `mutate({applicationId, data})`. - * - * @param options - Mutation options, including `onSuccess`, `onError`, and a per-composable `fetcher`. - * @returns The mutation state. - */ -const useUpdateApplication = ( - options?: ResourceMutationOptions, -): ResourceMutationResult => { - const endpoint: ManagementEndpoint = useManagementEndpoint('applications'); - - return useResourceMutation( - ({applicationId, data}: UpdateApplicationVariables, fetcher) => - updateApplication({...endpoint, applicationId, fetcher, payload: data}), - ({applicationId}: UpdateApplicationVariables) => [ - [ApplicationQueryKeys.APPLICATION, applicationId], - [ApplicationQueryKeys.APPLICATIONS], - ], - options, - ); -}; - -export default useUpdateApplication; diff --git a/packages/vue/src/composables/management/groups/useAddGroupMembers.ts b/packages/vue/src/composables/management/groups/useAddGroupMembers.ts deleted file mode 100644 index a0bda0c2..00000000 --- a/packages/vue/src/composables/management/groups/useAddGroupMembers.ts +++ /dev/null @@ -1,46 +0,0 @@ -// Copyright 2026 The ThunderID Authors -// SPDX-License-Identifier: Apache-2.0 - -import {GroupQueryKeys, RoleQueryKeys, addGroupMembers, type Group, type GroupMember} from '@thunderid/browser'; -import useManagementEndpoint, {type ManagementEndpoint} from '../useManagementEndpoint'; -import useResourceMutation, {type ResourceMutationOptions, type ResourceMutationResult} from '../useResourceMutation'; - -/** - * Variables for {@link useAddGroupMembers} and {@link useRemoveGroupMembers}. - */ -export interface GroupMembersVariables { - /** - * Identifier of the group. - */ - groupId: string; - /** - * The identities to change. - */ - members: Pick[]; -} - -/** - * Adds users, agents or groups to a group, then refetches the group, its members, membership lists and - * role assignments, since membership changes the roles a member inherits. Call `mutate({groupId, members})`. - * - * @param options - Mutation options, including `onSuccess`, `onError`, and a per-composable `fetcher`. - * @returns The mutation state. - */ -const useAddGroupMembers = ( - options?: ResourceMutationOptions, -): ResourceMutationResult => { - const endpoint: ManagementEndpoint = useManagementEndpoint('groups'); - - return useResourceMutation( - ({groupId, members}: GroupMembersVariables, fetcher) => addGroupMembers({...endpoint, fetcher, groupId, members}), - ({groupId}: GroupMembersVariables) => [ - [GroupQueryKeys.GROUP, groupId], - [GroupQueryKeys.GROUP_MEMBERS, groupId], - [GroupQueryKeys.MEMBERSHIPS], - [RoleQueryKeys.ROLE_ASSIGNMENTS], - ], - options, - ); -}; - -export default useAddGroupMembers; diff --git a/packages/vue/src/composables/management/groups/useCreateGroup.ts b/packages/vue/src/composables/management/groups/useCreateGroup.ts deleted file mode 100644 index 905e1c85..00000000 --- a/packages/vue/src/composables/management/groups/useCreateGroup.ts +++ /dev/null @@ -1,26 +0,0 @@ -// Copyright 2026 The ThunderID Authors -// SPDX-License-Identifier: Apache-2.0 - -import {GroupQueryKeys, createGroup, type CreateGroupRequest, type Group} from '@thunderid/browser'; -import useManagementEndpoint, {type ManagementEndpoint} from '../useManagementEndpoint'; -import useResourceMutation, {type ResourceMutationOptions, type ResourceMutationResult} from '../useResourceMutation'; - -/** - * Creates a group, then refetches any mounted group lists and membership lists. Call `mutate(payload)`. - * - * @param options - Mutation options, including `onSuccess`, `onError`, and a per-composable `fetcher`. - * @returns The mutation state. - */ -const useCreateGroup = ( - options?: ResourceMutationOptions, -): ResourceMutationResult => { - const endpoint: ManagementEndpoint = useManagementEndpoint('groups'); - - return useResourceMutation( - (payload: CreateGroupRequest, fetcher) => createGroup({...endpoint, fetcher, payload}), - () => [[GroupQueryKeys.GROUPS], [GroupQueryKeys.MEMBERSHIPS]], - options, - ); -}; - -export default useCreateGroup; diff --git a/packages/vue/src/composables/management/groups/useDeleteGroup.ts b/packages/vue/src/composables/management/groups/useDeleteGroup.ts deleted file mode 100644 index e637d68a..00000000 --- a/packages/vue/src/composables/management/groups/useDeleteGroup.ts +++ /dev/null @@ -1,25 +0,0 @@ -// Copyright 2026 The ThunderID Authors -// SPDX-License-Identifier: Apache-2.0 - -import {GroupQueryKeys, RoleQueryKeys, deleteGroup} from '@thunderid/browser'; -import useManagementEndpoint, {type ManagementEndpoint} from '../useManagementEndpoint'; -import useResourceMutation, {type ResourceMutationOptions, type ResourceMutationResult} from '../useResourceMutation'; - -/** - * Deletes a group, then refetches group lists, membership lists and role assignments, since members - * lose the roles the group carried. Call `mutate(groupId)`. - * - * @param options - Mutation options, including `onSuccess`, `onError`, and a per-composable `fetcher`. - * @returns The mutation state. - */ -const useDeleteGroup = (options?: ResourceMutationOptions): ResourceMutationResult => { - const endpoint: ManagementEndpoint = useManagementEndpoint('groups'); - - return useResourceMutation( - (groupId: string, fetcher) => deleteGroup({...endpoint, fetcher, groupId}), - () => [[GroupQueryKeys.GROUPS], [GroupQueryKeys.MEMBERSHIPS], [RoleQueryKeys.ROLE_ASSIGNMENTS]], - options, - ); -}; - -export default useDeleteGroup; diff --git a/packages/vue/src/composables/management/groups/useGetGroup.ts b/packages/vue/src/composables/management/groups/useGetGroup.ts deleted file mode 100644 index 73e6071d..00000000 --- a/packages/vue/src/composables/management/groups/useGetGroup.ts +++ /dev/null @@ -1,29 +0,0 @@ -// Copyright 2026 The ThunderID Authors -// SPDX-License-Identifier: Apache-2.0 - -import {GroupQueryKeys, getGroup, type Group} from '@thunderid/browser'; -import {type MaybeRefOrGetter, toValue} from 'vue'; -import useManagementEndpoint, {type ManagementEndpoint} from '../useManagementEndpoint'; -import useResourceQuery, {type ResourceQueryOptions, type ResourceQueryResult} from '../useResourceQuery'; - -/** - * Fetches a group. Does nothing until `groupId` is set. - * - * @param groupId - Identifier of the resource. A ref or getter refetches when it changes. - * @param options - Query options, including a per-composable `fetcher`. - * @returns The query state. - */ -const useGetGroup = ( - groupId: MaybeRefOrGetter, - options?: ResourceQueryOptions, -): ResourceQueryResult => { - const endpoint: ManagementEndpoint = useManagementEndpoint('groups'); - - return useResourceQuery( - () => [GroupQueryKeys.GROUP, toValue(groupId)], - (fetcher) => getGroup({...endpoint, fetcher, groupId: toValue(groupId)!}), - {...options, enabled: () => Boolean(toValue(groupId)) && toValue(options?.enabled) !== false}, - ); -}; - -export default useGetGroup; diff --git a/packages/vue/src/composables/management/groups/useGetGroupMembers.ts b/packages/vue/src/composables/management/groups/useGetGroupMembers.ts deleted file mode 100644 index 956838cc..00000000 --- a/packages/vue/src/composables/management/groups/useGetGroupMembers.ts +++ /dev/null @@ -1,48 +0,0 @@ -// Copyright 2026 The ThunderID Authors -// SPDX-License-Identifier: Apache-2.0 - -import {GroupQueryKeys, getGroupMembers, type GroupMemberListResponse} from '@thunderid/browser'; -import {type MaybeRefOrGetter, toValue} from 'vue'; -import useManagementEndpoint, {type ManagementEndpoint} from '../useManagementEndpoint'; -import useResourceQuery, {type ResourceQueryOptions, type ResourceQueryResult} from '../useResourceQuery'; - -/** - * Pagination parameters for {@link useGetGroupMembers}. - */ -export interface UseGetGroupMembersParams { - limit?: number; - offset?: number; -} - -/** - * Fetches a page of the users, agents and groups in a group. - * - * @param groupId - Identifier of the parent resource. Fetching waits until it is defined. A ref or - * getter refetches when it changes. - * @param params - Pagination parameters. A ref or getter refetches when it changes. - * @param options - Query options, including a per-composable `fetcher`. - * @returns The query state. - */ -const useGetGroupMembers = ( - groupId: MaybeRefOrGetter, - params: MaybeRefOrGetter = {}, - options?: ResourceQueryOptions, -): ResourceQueryResult => { - const endpoint: ManagementEndpoint = useManagementEndpoint('groups'); - - return useResourceQuery( - () => { - const {limit, offset} = toValue(params); - - return [GroupQueryKeys.GROUP_MEMBERS, toValue(groupId), {limit, offset}]; - }, - (fetcher) => { - const {limit, offset} = toValue(params); - - return getGroupMembers({...endpoint, fetcher, limit, offset, groupId: toValue(groupId)!}); - }, - {...options, enabled: () => Boolean(toValue(groupId)) && toValue(options?.enabled) !== false}, - ); -}; - -export default useGetGroupMembers; diff --git a/packages/vue/src/composables/management/groups/useGetGroups.ts b/packages/vue/src/composables/management/groups/useGetGroups.ts deleted file mode 100644 index fca4a585..00000000 --- a/packages/vue/src/composables/management/groups/useGetGroups.ts +++ /dev/null @@ -1,45 +0,0 @@ -// Copyright 2026 The ThunderID Authors -// SPDX-License-Identifier: Apache-2.0 - -import {GroupQueryKeys, getGroups, type GroupListResponse} from '@thunderid/browser'; -import {type MaybeRefOrGetter, toValue} from 'vue'; -import useManagementEndpoint, {type ManagementEndpoint} from '../useManagementEndpoint'; -import useResourceQuery, {type ResourceQueryOptions, type ResourceQueryResult} from '../useResourceQuery'; - -/** - * Parameters for {@link useGetGroups}. - */ -export interface UseGetGroupsParams { - limit?: number; - offset?: number; -} - -/** - * Fetches a page of groups. - * - * @param params - Pagination parameters. A ref or getter refetches when it changes. - * @param options - Query options, including a per-composable `fetcher`. - * @returns The query state. - */ -const useGetGroups = ( - params: MaybeRefOrGetter = {}, - options?: ResourceQueryOptions, -): ResourceQueryResult => { - const endpoint: ManagementEndpoint = useManagementEndpoint('groups'); - - return useResourceQuery( - () => { - const {limit, offset} = toValue(params); - - return [GroupQueryKeys.GROUPS, {limit, offset}]; - }, - (fetcher) => { - const {limit, offset} = toValue(params); - - return getGroups({...endpoint, fetcher, limit, offset}); - }, - options, - ); -}; - -export default useGetGroups; diff --git a/packages/vue/src/composables/management/groups/useRemoveGroupMembers.ts b/packages/vue/src/composables/management/groups/useRemoveGroupMembers.ts deleted file mode 100644 index 1fda0d48..00000000 --- a/packages/vue/src/composables/management/groups/useRemoveGroupMembers.ts +++ /dev/null @@ -1,34 +0,0 @@ -// Copyright 2026 The ThunderID Authors -// SPDX-License-Identifier: Apache-2.0 - -import {GroupQueryKeys, RoleQueryKeys, removeGroupMembers, type Group} from '@thunderid/browser'; -import type {GroupMembersVariables} from './useAddGroupMembers'; -import useManagementEndpoint, {type ManagementEndpoint} from '../useManagementEndpoint'; -import useResourceMutation, {type ResourceMutationOptions, type ResourceMutationResult} from '../useResourceMutation'; - -/** - * Removes users, agents or groups from a group, then refetches the group, its members, membership lists - * and role assignments. Call `mutate({groupId, members})`. - * - * @param options - Mutation options, including `onSuccess`, `onError`, and a per-composable `fetcher`. - * @returns The mutation state. - */ -const useRemoveGroupMembers = ( - options?: ResourceMutationOptions, -): ResourceMutationResult => { - const endpoint: ManagementEndpoint = useManagementEndpoint('groups'); - - return useResourceMutation( - ({groupId, members}: GroupMembersVariables, fetcher) => - removeGroupMembers({...endpoint, fetcher, groupId, members}), - ({groupId}: GroupMembersVariables) => [ - [GroupQueryKeys.GROUP, groupId], - [GroupQueryKeys.GROUP_MEMBERS, groupId], - [GroupQueryKeys.MEMBERSHIPS], - [RoleQueryKeys.ROLE_ASSIGNMENTS], - ], - options, - ); -}; - -export default useRemoveGroupMembers; diff --git a/packages/vue/src/composables/management/groups/useUpdateGroup.ts b/packages/vue/src/composables/management/groups/useUpdateGroup.ts deleted file mode 100644 index aa61932b..00000000 --- a/packages/vue/src/composables/management/groups/useUpdateGroup.ts +++ /dev/null @@ -1,45 +0,0 @@ -// Copyright 2026 The ThunderID Authors -// SPDX-License-Identifier: Apache-2.0 - -import {GroupQueryKeys, type Group, type UpdateGroupRequest, updateGroup} from '@thunderid/browser'; -import useManagementEndpoint, {type ManagementEndpoint} from '../useManagementEndpoint'; -import useResourceMutation, {type ResourceMutationOptions, type ResourceMutationResult} from '../useResourceMutation'; - -/** - * Variables for {@link useUpdateGroup}. - */ -export interface UpdateGroupVariables { - /** - * The group's new state. - */ - data: UpdateGroupRequest; - /** - * Identifier of the group to update. - */ - groupId: string; -} - -/** - * Updates a group, then refetches the group, any mounted group lists and membership lists. Call - * `mutate({groupId, data})`. - * - * @param options - Mutation options, including `onSuccess`, `onError`, and a per-composable `fetcher`. - * @returns The mutation state. - */ -const useUpdateGroup = ( - options?: ResourceMutationOptions, -): ResourceMutationResult => { - const endpoint: ManagementEndpoint = useManagementEndpoint('groups'); - - return useResourceMutation( - ({groupId, data}: UpdateGroupVariables, fetcher) => updateGroup({...endpoint, fetcher, groupId, payload: data}), - ({groupId}: UpdateGroupVariables) => [ - [GroupQueryKeys.GROUP, groupId], - [GroupQueryKeys.GROUPS], - [GroupQueryKeys.MEMBERSHIPS], - ], - options, - ); -}; - -export default useUpdateGroup; diff --git a/packages/vue/src/composables/management/roles/useAddRoleAssignments.ts b/packages/vue/src/composables/management/roles/useAddRoleAssignments.ts deleted file mode 100644 index e946c2b3..00000000 --- a/packages/vue/src/composables/management/roles/useAddRoleAssignments.ts +++ /dev/null @@ -1,42 +0,0 @@ -// Copyright 2026 The ThunderID Authors -// SPDX-License-Identifier: Apache-2.0 - -import {RoleQueryKeys, addRoleAssignments, type RoleAssignmentInput} from '@thunderid/browser'; -import useManagementEndpoint, {type ManagementEndpoint} from '../useManagementEndpoint'; -import useResourceMutation, {type ResourceMutationOptions, type ResourceMutationResult} from '../useResourceMutation'; - -/** - * Variables for {@link useAddRoleAssignments} and {@link useRemoveRoleAssignments}. - */ -export interface RoleAssignmentsVariables { - /** - * The identities to change. - */ - assignments: RoleAssignmentInput[]; - /** - * Identifier of the role. - */ - roleId: string; -} - -/** - * Assigns a role to users, groups, apps or agents, then refetches every mounted assignment list, - * including the roles listed for an agent. Call `mutate({roleId, assignments})`. - * - * @param options - Mutation options, including `onSuccess`, `onError`, and a per-composable `fetcher`. - * @returns The mutation state. - */ -const useAddRoleAssignments = ( - options?: ResourceMutationOptions, -): ResourceMutationResult => { - const endpoint: ManagementEndpoint = useManagementEndpoint('roles'); - - return useResourceMutation( - ({roleId, assignments}: RoleAssignmentsVariables, fetcher) => - addRoleAssignments({...endpoint, assignments, fetcher, roleId}), - () => [[RoleQueryKeys.ROLE_ASSIGNMENTS]], - options, - ); -}; - -export default useAddRoleAssignments; diff --git a/packages/vue/src/composables/management/roles/useCreateRole.ts b/packages/vue/src/composables/management/roles/useCreateRole.ts deleted file mode 100644 index 0bbe7e49..00000000 --- a/packages/vue/src/composables/management/roles/useCreateRole.ts +++ /dev/null @@ -1,26 +0,0 @@ -// Copyright 2026 The ThunderID Authors -// SPDX-License-Identifier: Apache-2.0 - -import {RoleQueryKeys, createRole, type CreateRoleRequest, type Role} from '@thunderid/browser'; -import useManagementEndpoint, {type ManagementEndpoint} from '../useManagementEndpoint'; -import useResourceMutation, {type ResourceMutationOptions, type ResourceMutationResult} from '../useResourceMutation'; - -/** - * Creates a role, then refetches any mounted role lists and assignment lists. Call `mutate(payload)`. - * - * @param options - Mutation options, including `onSuccess`, `onError`, and a per-composable `fetcher`. - * @returns The mutation state. - */ -const useCreateRole = ( - options?: ResourceMutationOptions, -): ResourceMutationResult => { - const endpoint: ManagementEndpoint = useManagementEndpoint('roles'); - - return useResourceMutation( - (payload: CreateRoleRequest, fetcher) => createRole({...endpoint, fetcher, payload}), - () => [[RoleQueryKeys.ROLES], [RoleQueryKeys.ROLE_ASSIGNMENTS]], - options, - ); -}; - -export default useCreateRole; diff --git a/packages/vue/src/composables/management/roles/useDeleteRole.ts b/packages/vue/src/composables/management/roles/useDeleteRole.ts deleted file mode 100644 index 4a62a2d9..00000000 --- a/packages/vue/src/composables/management/roles/useDeleteRole.ts +++ /dev/null @@ -1,24 +0,0 @@ -// Copyright 2026 The ThunderID Authors -// SPDX-License-Identifier: Apache-2.0 - -import {RoleQueryKeys, deleteRole} from '@thunderid/browser'; -import useManagementEndpoint, {type ManagementEndpoint} from '../useManagementEndpoint'; -import useResourceMutation, {type ResourceMutationOptions, type ResourceMutationResult} from '../useResourceMutation'; - -/** - * Deletes a role, then refetches any mounted role lists and assignment lists. Call `mutate(roleId)`. - * - * @param options - Mutation options, including `onSuccess`, `onError`, and a per-composable `fetcher`. - * @returns The mutation state. - */ -const useDeleteRole = (options?: ResourceMutationOptions): ResourceMutationResult => { - const endpoint: ManagementEndpoint = useManagementEndpoint('roles'); - - return useResourceMutation( - (roleId: string, fetcher) => deleteRole({...endpoint, fetcher, roleId}), - () => [[RoleQueryKeys.ROLES], [RoleQueryKeys.ROLE_ASSIGNMENTS]], - options, - ); -}; - -export default useDeleteRole; diff --git a/packages/vue/src/composables/management/roles/useGetRole.ts b/packages/vue/src/composables/management/roles/useGetRole.ts deleted file mode 100644 index bdfcaa7d..00000000 --- a/packages/vue/src/composables/management/roles/useGetRole.ts +++ /dev/null @@ -1,29 +0,0 @@ -// Copyright 2026 The ThunderID Authors -// SPDX-License-Identifier: Apache-2.0 - -import {RoleQueryKeys, getRole, type Role} from '@thunderid/browser'; -import {type MaybeRefOrGetter, toValue} from 'vue'; -import useManagementEndpoint, {type ManagementEndpoint} from '../useManagementEndpoint'; -import useResourceQuery, {type ResourceQueryOptions, type ResourceQueryResult} from '../useResourceQuery'; - -/** - * Fetches a role with the permissions it grants. Does nothing until `roleId` is set. - * - * @param roleId - Identifier of the resource. A ref or getter refetches when it changes. - * @param options - Query options, including a per-composable `fetcher`. - * @returns The query state. - */ -const useGetRole = ( - roleId: MaybeRefOrGetter, - options?: ResourceQueryOptions, -): ResourceQueryResult => { - const endpoint: ManagementEndpoint = useManagementEndpoint('roles'); - - return useResourceQuery( - () => [RoleQueryKeys.ROLE, toValue(roleId)], - (fetcher) => getRole({...endpoint, fetcher, roleId: toValue(roleId)!}), - {...options, enabled: () => Boolean(toValue(roleId)) && toValue(options?.enabled) !== false}, - ); -}; - -export default useGetRole; diff --git a/packages/vue/src/composables/management/roles/useGetRoleAssignments.ts b/packages/vue/src/composables/management/roles/useGetRoleAssignments.ts deleted file mode 100644 index 0a533758..00000000 --- a/packages/vue/src/composables/management/roles/useGetRoleAssignments.ts +++ /dev/null @@ -1,57 +0,0 @@ -// Copyright 2026 The ThunderID Authors -// SPDX-License-Identifier: Apache-2.0 - -import { - RoleQueryKeys, - getRoleAssignments, - type RoleAssigneeType, - type RoleAssignmentListResponse, -} from '@thunderid/browser'; -import {type MaybeRefOrGetter, toValue} from 'vue'; -import useManagementEndpoint, {type ManagementEndpoint} from '../useManagementEndpoint'; -import useResourceQuery, {type ResourceQueryOptions, type ResourceQueryResult} from '../useResourceQuery'; - -/** - * Parameters for {@link useGetRoleAssignments}. - */ -export interface UseGetRoleAssignmentsParams { - limit?: number; - offset?: number; - /** - * Only return assignees of this type. Returns every type when omitted. - */ - type?: RoleAssigneeType; -} - -/** - * Fetches a page of the users, groups, apps and agents a role is assigned to. - * - * @param roleId - Identifier of the parent resource. Fetching waits until it is defined. A ref or - * getter refetches when it changes. - * @param params - Pagination and assignee type parameters. A ref or getter refetches when it changes. - * @param options - Query options, including a per-composable `fetcher`. - * @returns The query state. - */ -const useGetRoleAssignments = ( - roleId: MaybeRefOrGetter, - params: MaybeRefOrGetter = {}, - options?: ResourceQueryOptions, -): ResourceQueryResult => { - const endpoint: ManagementEndpoint = useManagementEndpoint('roles'); - - return useResourceQuery( - () => { - const {limit, offset, type} = toValue(params); - - return [RoleQueryKeys.ROLE_ASSIGNMENTS, toValue(roleId), {limit, offset, type}]; - }, - (fetcher) => { - const {limit, offset, type} = toValue(params); - - return getRoleAssignments({...endpoint, fetcher, limit, offset, type, roleId: toValue(roleId)!}); - }, - {...options, enabled: () => Boolean(toValue(roleId)) && toValue(options?.enabled) !== false}, - ); -}; - -export default useGetRoleAssignments; diff --git a/packages/vue/src/composables/management/roles/useGetRoles.ts b/packages/vue/src/composables/management/roles/useGetRoles.ts deleted file mode 100644 index 401dc2b2..00000000 --- a/packages/vue/src/composables/management/roles/useGetRoles.ts +++ /dev/null @@ -1,45 +0,0 @@ -// Copyright 2026 The ThunderID Authors -// SPDX-License-Identifier: Apache-2.0 - -import {RoleQueryKeys, getRoles, type RoleListResponse} from '@thunderid/browser'; -import {type MaybeRefOrGetter, toValue} from 'vue'; -import useManagementEndpoint, {type ManagementEndpoint} from '../useManagementEndpoint'; -import useResourceQuery, {type ResourceQueryOptions, type ResourceQueryResult} from '../useResourceQuery'; - -/** - * Parameters for {@link useGetRoles}. - */ -export interface UseGetRolesParams { - limit?: number; - offset?: number; -} - -/** - * Fetches a page of roles. - * - * @param params - Pagination parameters. A ref or getter refetches when it changes. - * @param options - Query options, including a per-composable `fetcher`. - * @returns The query state. - */ -const useGetRoles = ( - params: MaybeRefOrGetter = {}, - options?: ResourceQueryOptions, -): ResourceQueryResult => { - const endpoint: ManagementEndpoint = useManagementEndpoint('roles'); - - return useResourceQuery( - () => { - const {limit, offset} = toValue(params); - - return [RoleQueryKeys.ROLES, {limit, offset}]; - }, - (fetcher) => { - const {limit, offset} = toValue(params); - - return getRoles({...endpoint, fetcher, limit, offset}); - }, - options, - ); -}; - -export default useGetRoles; diff --git a/packages/vue/src/composables/management/roles/useRemoveRoleAssignments.ts b/packages/vue/src/composables/management/roles/useRemoveRoleAssignments.ts deleted file mode 100644 index 7cd3aae6..00000000 --- a/packages/vue/src/composables/management/roles/useRemoveRoleAssignments.ts +++ /dev/null @@ -1,29 +0,0 @@ -// Copyright 2026 The ThunderID Authors -// SPDX-License-Identifier: Apache-2.0 - -import {RoleQueryKeys, removeRoleAssignments} from '@thunderid/browser'; -import type {RoleAssignmentsVariables} from './useAddRoleAssignments'; -import useManagementEndpoint, {type ManagementEndpoint} from '../useManagementEndpoint'; -import useResourceMutation, {type ResourceMutationOptions, type ResourceMutationResult} from '../useResourceMutation'; - -/** - * Removes a role from users, groups, apps or agents, then refetches every mounted assignment list. - * Call `mutate({roleId, assignments})`. - * - * @param options - Mutation options, including `onSuccess`, `onError`, and a per-composable `fetcher`. - * @returns The mutation state. - */ -const useRemoveRoleAssignments = ( - options?: ResourceMutationOptions, -): ResourceMutationResult => { - const endpoint: ManagementEndpoint = useManagementEndpoint('roles'); - - return useResourceMutation( - ({roleId, assignments}: RoleAssignmentsVariables, fetcher) => - removeRoleAssignments({...endpoint, assignments, fetcher, roleId}), - () => [[RoleQueryKeys.ROLE_ASSIGNMENTS]], - options, - ); -}; - -export default useRemoveRoleAssignments; diff --git a/packages/vue/src/composables/management/roles/useUpdateRole.ts b/packages/vue/src/composables/management/roles/useUpdateRole.ts deleted file mode 100644 index 3cd0217c..00000000 --- a/packages/vue/src/composables/management/roles/useUpdateRole.ts +++ /dev/null @@ -1,41 +0,0 @@ -// Copyright 2026 The ThunderID Authors -// SPDX-License-Identifier: Apache-2.0 - -import {RoleQueryKeys, type Role, type UpdateRoleRequest, updateRole} from '@thunderid/browser'; -import useManagementEndpoint, {type ManagementEndpoint} from '../useManagementEndpoint'; -import useResourceMutation, {type ResourceMutationOptions, type ResourceMutationResult} from '../useResourceMutation'; - -/** - * Variables for {@link useUpdateRole}. - */ -export interface UpdateRoleVariables { - /** - * The role's new state. - */ - data: UpdateRoleRequest; - /** - * Identifier of the role to update. - */ - roleId: string; -} - -/** - * Replaces a role's name, description and permissions, then refetches the role and any mounted role - * lists. Call `mutate({roleId, data})`. - * - * @param options - Mutation options, including `onSuccess`, `onError`, and a per-composable `fetcher`. - * @returns The mutation state. - */ -const useUpdateRole = ( - options?: ResourceMutationOptions, -): ResourceMutationResult => { - const endpoint: ManagementEndpoint = useManagementEndpoint('roles'); - - return useResourceMutation( - ({roleId, data}: UpdateRoleVariables, fetcher) => updateRole({...endpoint, fetcher, payload: data, roleId}), - ({roleId}: UpdateRoleVariables) => [[RoleQueryKeys.ROLE, roleId], [RoleQueryKeys.ROLES]], - options, - ); -}; - -export default useUpdateRole; diff --git a/packages/vue/src/composables/management/useManagementEndpoint.ts b/packages/vue/src/composables/management/useManagementEndpoint.ts index fcc6a9e3..7e1188a7 100644 --- a/packages/vue/src/composables/management/useManagementEndpoint.ts +++ b/packages/vue/src/composables/management/useManagementEndpoint.ts @@ -21,7 +21,27 @@ export interface ManagementEndpoint { */ export type ManagementEndpointKey = Extract< ResourceEndpointKey, - 'agents' | 'agentTypes' | 'applications' | 'groups' | 'roles' | 'users' | 'userTypes' + | 'agents' + | 'agentTypes' + | 'applications' + | 'configurationVersions' + | 'connections' + | 'design' + | 'export' + | 'flows' + | 'gateways' + | 'groups' + | 'i18n' + | 'import' + | 'notificationTemplates' + | 'organizationUnits' + | 'resourceServers' + | 'roles' + | 'secrets' + | 'serverConfig' + | 'users' + | 'userTypes' + | 'variables' >; /** diff --git a/packages/vue/src/composables/management/useManagementRequestConfig.ts b/packages/vue/src/composables/management/useManagementRequestConfig.ts new file mode 100644 index 00000000..11a561ee --- /dev/null +++ b/packages/vue/src/composables/management/useManagementRequestConfig.ts @@ -0,0 +1,30 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import type {ManagementRequestConfig} from '@thunderid/browser'; +import useManagementEndpoint, {type ManagementEndpointKey} from './useManagementEndpoint'; +import {resolveManagementFetcher} from './useResourceQuery'; +import useThunderID from '../useThunderID'; + +/** + * Returns the request config the SDK's management API functions take for a collection: the server URL, + * the collection URL when `endpoints` overrides it, and a fetcher that sends the signed-in user's access + * token (`ThunderIDProvider`'s `http.fetcher` when set). + * + * Use it to call the API functions from an app's own data layer, such as TanStack Query: + * + * @example + * ```ts + * const config = useManagementRequestConfig('groups'); + * const groups = useQuery({ + * queryKey: [GroupQueryKeys.GROUPS, {limit, offset}], + * queryFn: () => getGroups({...config, limit, offset}), + * }); + * ``` + */ +const useManagementRequestConfig = (collection: ManagementEndpointKey): ManagementRequestConfig => ({ + ...useManagementEndpoint(collection), + fetcher: resolveManagementFetcher(useThunderID()), +}); + +export default useManagementRequestConfig; diff --git a/packages/vue/src/composables/management/users/useCreateUser.ts b/packages/vue/src/composables/management/users/useCreateUser.ts deleted file mode 100644 index f58857a8..00000000 --- a/packages/vue/src/composables/management/users/useCreateUser.ts +++ /dev/null @@ -1,26 +0,0 @@ -// Copyright 2026 The ThunderID Authors -// SPDX-License-Identifier: Apache-2.0 - -import {UserQueryKeys, createUser, type CreateManagedUserRequest, type ManagedUser} from '@thunderid/browser'; -import useManagementEndpoint, {type ManagementEndpoint} from '../useManagementEndpoint'; -import useResourceMutation, {type ResourceMutationOptions, type ResourceMutationResult} from '../useResourceMutation'; - -/** - * Creates a user, then refetches any mounted user lists. Call `mutate(payload)` to create. - * - * @param options - Mutation options, including `onSuccess`, `onError`, and a per-composable `fetcher`. - * @returns The mutation state. - */ -const useCreateUser = ( - options?: ResourceMutationOptions, -): ResourceMutationResult => { - const endpoint: ManagementEndpoint = useManagementEndpoint('users'); - - return useResourceMutation( - (payload: CreateManagedUserRequest, fetcher) => createUser({...endpoint, fetcher, payload}), - () => [[UserQueryKeys.USERS]], - options, - ); -}; - -export default useCreateUser; diff --git a/packages/vue/src/composables/management/users/useDeleteUser.ts b/packages/vue/src/composables/management/users/useDeleteUser.ts deleted file mode 100644 index 27dc9581..00000000 --- a/packages/vue/src/composables/management/users/useDeleteUser.ts +++ /dev/null @@ -1,24 +0,0 @@ -// Copyright 2026 The ThunderID Authors -// SPDX-License-Identifier: Apache-2.0 - -import {UserQueryKeys, deleteUser} from '@thunderid/browser'; -import useManagementEndpoint, {type ManagementEndpoint} from '../useManagementEndpoint'; -import useResourceMutation, {type ResourceMutationOptions, type ResourceMutationResult} from '../useResourceMutation'; - -/** - * Deletes a user, then refetches any mounted user lists. Call `mutate(userId)` to delete. - * - * @param options - Mutation options, including `onSuccess`, `onError`, and a per-composable `fetcher`. - * @returns The mutation state. - */ -const useDeleteUser = (options?: ResourceMutationOptions): ResourceMutationResult => { - const endpoint: ManagementEndpoint = useManagementEndpoint('users'); - - return useResourceMutation( - (userId: string, fetcher) => deleteUser({...endpoint, fetcher, userId}), - () => [[UserQueryKeys.USERS]], - options, - ); -}; - -export default useDeleteUser; diff --git a/packages/vue/src/composables/management/users/useGetUser.ts b/packages/vue/src/composables/management/users/useGetUser.ts deleted file mode 100644 index 15dac096..00000000 --- a/packages/vue/src/composables/management/users/useGetUser.ts +++ /dev/null @@ -1,29 +0,0 @@ -// Copyright 2026 The ThunderID Authors -// SPDX-License-Identifier: Apache-2.0 - -import {UserQueryKeys, getUser, type ManagedUser} from '@thunderid/browser'; -import {type MaybeRefOrGetter, toValue} from 'vue'; -import useManagementEndpoint, {type ManagementEndpoint} from '../useManagementEndpoint'; -import useResourceQuery, {type ResourceQueryOptions, type ResourceQueryResult} from '../useResourceQuery'; - -/** - * Fetches a single user. Does nothing until `userId` is set. - * - * @param userId - Identifier of the resource. A ref or getter refetches when it changes. - * @param options - Query options, including a per-composable `fetcher`. - * @returns The query state. - */ -const useGetUser = ( - userId: MaybeRefOrGetter, - options?: ResourceQueryOptions, -): ResourceQueryResult => { - const endpoint: ManagementEndpoint = useManagementEndpoint('users'); - - return useResourceQuery( - () => [UserQueryKeys.USER, toValue(userId)], - (fetcher) => getUser({...endpoint, fetcher, userId: toValue(userId)!}), - {...options, enabled: () => Boolean(toValue(userId)) && toValue(options?.enabled) !== false}, - ); -}; - -export default useGetUser; diff --git a/packages/vue/src/composables/management/users/useGetUserGroups.ts b/packages/vue/src/composables/management/users/useGetUserGroups.ts deleted file mode 100644 index f0f12100..00000000 --- a/packages/vue/src/composables/management/users/useGetUserGroups.ts +++ /dev/null @@ -1,40 +0,0 @@ -// Copyright 2026 The ThunderID Authors -// SPDX-License-Identifier: Apache-2.0 - -import {GroupQueryKeys, getUserGroups, type MembershipGroupListResponse} from '@thunderid/browser'; -import {type MaybeRefOrGetter, toValue} from 'vue'; -import useManagementEndpoint, {type ManagementEndpoint} from '../useManagementEndpoint'; -import useResourceQuery, {type ResourceQueryOptions, type ResourceQueryResult} from '../useResourceQuery'; - -/** - * Fetches a page of the groups a user belongs to. - * - * @param userId - Identifier of the parent resource. Fetching waits until it is defined. A ref or - * getter refetches when it changes. - * @param params - Pagination parameters. A ref or getter refetches when it changes. - * @param options - Query options, including a per-composable `fetcher`. - * @returns The query state. - */ -const useGetUserGroups = ( - userId: MaybeRefOrGetter, - params: MaybeRefOrGetter<{limit?: number; offset?: number}> = {}, - options?: ResourceQueryOptions, -): ResourceQueryResult => { - const endpoint: ManagementEndpoint = useManagementEndpoint('users'); - - return useResourceQuery( - () => { - const {limit, offset} = toValue(params); - - return [GroupQueryKeys.MEMBERSHIPS, 'user', toValue(userId), {limit, offset}]; - }, - (fetcher) => { - const {limit, offset} = toValue(params); - - return getUserGroups({...endpoint, fetcher, limit, offset, userId: toValue(userId)!}); - }, - {...options, enabled: () => Boolean(toValue(userId)) && toValue(options?.enabled) !== false}, - ); -}; - -export default useGetUserGroups; diff --git a/packages/vue/src/composables/management/users/useGetUserTypes.ts b/packages/vue/src/composables/management/users/useGetUserTypes.ts deleted file mode 100644 index c858192c..00000000 --- a/packages/vue/src/composables/management/users/useGetUserTypes.ts +++ /dev/null @@ -1,37 +0,0 @@ -// Copyright 2026 The ThunderID Authors -// SPDX-License-Identifier: Apache-2.0 - -import {UserQueryKeys, getUserTypes, type ManagedUserTypeListResponse} from '@thunderid/browser'; -import {type MaybeRefOrGetter, toValue} from 'vue'; -import useManagementEndpoint, {type ManagementEndpoint} from '../useManagementEndpoint'; -import useResourceQuery, {type ResourceQueryOptions, type ResourceQueryResult} from '../useResourceQuery'; - -/** - * Fetches a page of user types. - * - * @param params - Pagination parameters. A ref or getter refetches when it changes. - * @param options - Query options, including a per-composable `fetcher`. - * @returns The query state. - */ -const useGetUserTypes = ( - params: MaybeRefOrGetter<{limit?: number; offset?: number}> = {}, - options?: ResourceQueryOptions, -): ResourceQueryResult => { - const endpoint: ManagementEndpoint = useManagementEndpoint('userTypes'); - - return useResourceQuery( - () => { - const {limit, offset} = toValue(params); - - return [UserQueryKeys.USER_TYPES, {limit, offset}]; - }, - (fetcher) => { - const {limit, offset} = toValue(params); - - return getUserTypes({...endpoint, fetcher, limit, offset}); - }, - options, - ); -}; - -export default useGetUserTypes; diff --git a/packages/vue/src/composables/management/users/useGetUsers.ts b/packages/vue/src/composables/management/users/useGetUsers.ts deleted file mode 100644 index 24edf0f9..00000000 --- a/packages/vue/src/composables/management/users/useGetUsers.ts +++ /dev/null @@ -1,42 +0,0 @@ -// Copyright 2026 The ThunderID Authors -// SPDX-License-Identifier: Apache-2.0 - -import {UserQueryKeys, getUsers, type ApiFilteringParams, type ManagedUserListResponse} from '@thunderid/browser'; -import {type MaybeRefOrGetter, toValue} from 'vue'; -import useManagementEndpoint, {type ManagementEndpoint} from '../useManagementEndpoint'; -import useResourceQuery, {type ResourceQueryOptions, type ResourceQueryResult} from '../useResourceQuery'; - -/** - * Fetches a page of users. - * - * @param params - Pagination and filter parameters. A ref or getter refetches when it changes. - * @param options - Query options, including a per-composable `fetcher`. - * @returns The query state. - * - * @example - * ```ts - * const {data, isLoading} = useGetUsers({limit: 20, filter: 'username sw "a"'}); - * ``` - */ -const useGetUsers = ( - params: MaybeRefOrGetter = {}, - options?: ResourceQueryOptions, -): ResourceQueryResult => { - const endpoint: ManagementEndpoint = useManagementEndpoint('users'); - - return useResourceQuery( - () => { - const {filter, limit, offset} = toValue(params); - - return [UserQueryKeys.USERS, {filter, limit, offset}]; - }, - (fetcher) => { - const {filter, limit, offset} = toValue(params); - - return getUsers({...endpoint, fetcher, filter, limit, offset}); - }, - options, - ); -}; - -export default useGetUsers; diff --git a/packages/vue/src/composables/management/users/useUpdateUser.ts b/packages/vue/src/composables/management/users/useUpdateUser.ts deleted file mode 100644 index db11fcda..00000000 --- a/packages/vue/src/composables/management/users/useUpdateUser.ts +++ /dev/null @@ -1,34 +0,0 @@ -// Copyright 2026 The ThunderID Authors -// SPDX-License-Identifier: Apache-2.0 - -import {UserQueryKeys, type ManagedUser, type UpdateManagedUserRequest, updateUser} from '@thunderid/browser'; -import useManagementEndpoint, {type ManagementEndpoint} from '../useManagementEndpoint'; -import useResourceMutation, {type ResourceMutationOptions, type ResourceMutationResult} from '../useResourceMutation'; - -/** - * Variables accepted by {@link useUpdateUser}'s `mutate`. - */ -export interface UpdateUserVariables { - data: UpdateManagedUserRequest; - userId: string; -} - -/** - * Updates a user, then refetches it and any mounted user lists. Call `mutate({userId, data})`. - * - * @param options - Mutation options, including `onSuccess`, `onError`, and a per-composable `fetcher`. - * @returns The mutation state. - */ -const useUpdateUser = ( - options?: ResourceMutationOptions, -): ResourceMutationResult => { - const endpoint: ManagementEndpoint = useManagementEndpoint('users'); - - return useResourceMutation( - ({userId, data}: UpdateUserVariables, fetcher) => updateUser({...endpoint, fetcher, payload: data, userId}), - ({userId}: UpdateUserVariables) => [[UserQueryKeys.USER, userId], [UserQueryKeys.USERS]], - options, - ); -}; - -export default useUpdateUser; diff --git a/packages/vue/src/index.ts b/packages/vue/src/index.ts index 315cd720..c824b082 100644 --- a/packages/vue/src/index.ts +++ b/packages/vue/src/index.ts @@ -24,72 +24,18 @@ export {default as useTheme} from './composables/useTheme'; export {default as useUser} from './composables/useUser'; export {useOAuthCallback} from './composables/auth/useOAuthCallback'; export type {UseOAuthCallbackOptions, OAuthCallbackPayload} from './composables/auth/useOAuthCallback'; +export {default as useFetchAuthFlows} from './composables/auth/useFetchAuthFlows'; export {default as useInfiniteOptions} from './composables/management/useInfiniteOptions'; export type {UseInfiniteOptionsArgs, UseInfiniteOptionsResult} from './composables/management/useInfiniteOptions'; export {default as useFetchUsers} from './composables/management/useFetchUsers'; -export {default as useFetchAuthFlows} from './composables/auth/useFetchAuthFlows'; export {default as useFetchUserTypes} from './composables/management/useFetchUserTypes'; - -// ── Composables — Management ── -export {default as useResourceQuery} from './composables/management/useResourceQuery'; -export type {ResourceQueryOptions, ResourceQueryResult} from './composables/management/useResourceQuery'; -export {default as useResourceMutation} from './composables/management/useResourceMutation'; -export type {ResourceMutationOptions, ResourceMutationResult} from './composables/management/useResourceMutation'; export {default as useManagementEndpoint} from './composables/management/useManagementEndpoint'; export type {ManagementEndpoint, ManagementEndpointKey} from './composables/management/useManagementEndpoint'; +export {default as useManagementRequestConfig} from './composables/management/useManagementRequestConfig'; export {default as useGrantedPermissions} from './composables/management/useGrantedPermissions'; export type {GrantedPermissions} from './composables/management/useGrantedPermissions'; export {createResourceInvalidator} from '@thunderid/browser'; export type {ResourceInvalidator, ResourceQueryKey} from '@thunderid/browser'; -export {default as useGetUsers} from './composables/management/users/useGetUsers'; -export {default as useGetUser} from './composables/management/users/useGetUser'; -export {default as useCreateUser} from './composables/management/users/useCreateUser'; -export {default as useUpdateUser} from './composables/management/users/useUpdateUser'; -export type {UpdateUserVariables} from './composables/management/users/useUpdateUser'; -export {default as useDeleteUser} from './composables/management/users/useDeleteUser'; -export {default as useGetUserGroups} from './composables/management/users/useGetUserGroups'; -export {default as useGetUserTypes} from './composables/management/users/useGetUserTypes'; -export {default as useGetGroups} from './composables/management/groups/useGetGroups'; -export type {UseGetGroupsParams} from './composables/management/groups/useGetGroups'; -export {default as useGetGroup} from './composables/management/groups/useGetGroup'; -export {default as useCreateGroup} from './composables/management/groups/useCreateGroup'; -export {default as useUpdateGroup} from './composables/management/groups/useUpdateGroup'; -export type {UpdateGroupVariables} from './composables/management/groups/useUpdateGroup'; -export {default as useDeleteGroup} from './composables/management/groups/useDeleteGroup'; -export {default as useGetGroupMembers} from './composables/management/groups/useGetGroupMembers'; -export type {UseGetGroupMembersParams} from './composables/management/groups/useGetGroupMembers'; -export {default as useAddGroupMembers} from './composables/management/groups/useAddGroupMembers'; -export type {GroupMembersVariables} from './composables/management/groups/useAddGroupMembers'; -export {default as useRemoveGroupMembers} from './composables/management/groups/useRemoveGroupMembers'; -export {default as useGetRoles} from './composables/management/roles/useGetRoles'; -export type {UseGetRolesParams} from './composables/management/roles/useGetRoles'; -export {default as useGetRole} from './composables/management/roles/useGetRole'; -export {default as useCreateRole} from './composables/management/roles/useCreateRole'; -export {default as useUpdateRole} from './composables/management/roles/useUpdateRole'; -export type {UpdateRoleVariables} from './composables/management/roles/useUpdateRole'; -export {default as useDeleteRole} from './composables/management/roles/useDeleteRole'; -export {default as useGetRoleAssignments} from './composables/management/roles/useGetRoleAssignments'; -export type {UseGetRoleAssignmentsParams} from './composables/management/roles/useGetRoleAssignments'; -export {default as useAddRoleAssignments} from './composables/management/roles/useAddRoleAssignments'; -export type {RoleAssignmentsVariables} from './composables/management/roles/useAddRoleAssignments'; -export {default as useRemoveRoleAssignments} from './composables/management/roles/useRemoveRoleAssignments'; -export {default as useGetAgents} from './composables/management/agents/useGetAgents'; -export type {UseGetAgentsParams} from './composables/management/agents/useGetAgents'; -export {default as useGetAgent} from './composables/management/agents/useGetAgent'; -export {default as useCreateAgent} from './composables/management/agents/useCreateAgent'; -export {default as useUpdateAgent} from './composables/management/agents/useUpdateAgent'; -export type {UpdateAgentVariables} from './composables/management/agents/useUpdateAgent'; -export {default as useDeleteAgent} from './composables/management/agents/useDeleteAgent'; -export {default as useGetAgentGroups} from './composables/management/agents/useGetAgentGroups'; -export {default as useGetAgentRoles} from './composables/management/agents/useGetAgentRoles'; -export {default as useGetAgentTypes} from './composables/management/agents/useGetAgentTypes'; -export {default as useGetApplications} from './composables/management/applications/useGetApplications'; -export type {UseGetApplicationsParams} from './composables/management/applications/useGetApplications'; -export {default as useGetApplication} from './composables/management/applications/useGetApplication'; -export {default as useCreateApplication} from './composables/management/applications/useCreateApplication'; -export {default as useUpdateApplication} from './composables/management/applications/useUpdateApplication'; -export type {UpdateApplicationVariables} from './composables/management/applications/useUpdateApplication'; -export {default as useDeleteApplication} from './composables/management/applications/useDeleteApplication'; // ── Client ── export {default as ThunderIDVueClient} from './ThunderIDVueClient'; @@ -248,6 +194,21 @@ export {default as AgentCreate} from './components/admin/AgentCreate/AgentCreate export {default as BaseAgentDetails} from './components/admin/AgentDetails/BaseAgentDetails'; export type {BaseAgentDetailsRenderProps} from './components/admin/AgentDetails/BaseAgentDetails'; export {default as AgentDetails} from './components/admin/AgentDetails/AgentDetails'; +export {default as BaseApplicationList} from './components/admin/ApplicationList/BaseApplicationList'; +export {default as ApplicationList} from './components/admin/ApplicationList/ApplicationList'; +export {default as BaseApplicationCreate} from './components/admin/ApplicationCreate/BaseApplicationCreate'; +export type { + ApplicationCreateSubmission, + BaseApplicationCreateRenderProps, +} from './components/admin/ApplicationCreate/BaseApplicationCreate'; +export {default as ApplicationCreate} from './components/admin/ApplicationCreate/ApplicationCreate'; +export {default as BaseApplicationDetails} from './components/admin/ApplicationDetails/BaseApplicationDetails'; +export type { + ApplicationUpdateSubmission, + BaseApplicationDetailsRenderProps, +} from './components/admin/ApplicationDetails/BaseApplicationDetails'; +export {default as ApplicationDetails} from './components/admin/ApplicationDetails/ApplicationDetails'; +export type {ApplicationFormValues, ApplicationTextField} from './components/admin/shared/renderApplicationFields'; export type {IdentityCandidate} from './components/admin/shared/IdentityPicker'; // ── UI Components — Adapters ── @@ -420,4 +381,475 @@ export { type UpdateGroupRequest, type UpdateManagedUserRequest, type UpdateRoleRequest, + AdministrationFlowConfigKey, + AdministrationFlowData, + AdministrationFlowError, + AdministrationFlowInput, + AdministrationModes, + ADMINISTRATION_FLOW_TYPE, + createUserType, + deleteUserType, + executeAdministrationFlow, + findAdministrationFlows, + getAgentType, + getUserType, + getUserTypes, + getUserUsages, + previewCimdDocument, + regenerateAgentSecret, + regenerateClientSecret, + regenerateFlowSecret, + resolveAdministrationFlowHandles, + resolveAdministrationFlowId, + resolveAdministrationFlows, + runAdministrationFlow, + updateAgentType, + updateUserCredentials, + updateUserType, +} from '@thunderid/browser'; +export type { + AdministrationFlowConfig, + ServerError, + ServerErrorMessage, + AdministrationFlowConfigKeyValue, + AdministrationFlowExecutionResponse, + AdministrationFlowStepError, + AdministrationMode, + AdministrationOptions, + AgentTypeDetails, + ArrayItemDefinition, + ArrayPropertyDefinition, + BooleanPropertyDefinition, + CimdPreviewResponse, + CreateUserTypeConfig, + CreateUserTypeRequest, + DeleteUserTypeConfig, + EntityTypeSchema, + EntityTypeSystemAttributes, + GetAgentTypeConfig, + GetUserTypeConfig, + GetUserTypesConfig, + GetUserUsagesConfig, + ManagedUserTypeDetails, + NumberPropertyDefinition, + ObjectPropertyDefinition, + PreviewCimdDocumentConfig, + PropertyDefinition, + RegenerateAgentSecretConfig, + RegenerateAgentSecretResult, + RegenerateClientSecretConfig, + RegenerateClientSecretResult, + RegenerateFlowSecretConfig, + RegenerateFlowSecretResult, + StringPropertyDefinition, + UpdateAgentTypeConfig, + UpdateAgentTypeRequest, + UpdateUserCredentialsConfig, + UpdateUserCredentialsRequest, + UpdateUserTypeConfig, + UpdateUserTypeRequest, + UserUsage, + UserUsagesResponse, +} from '@thunderid/browser'; +// The management operations beyond users, groups, roles, agents and applications. +export { + applyGatewayConfiguration, + createAction, + createAgentType, + createApplicationSharingPolicy, + createConfigurationVersion, + createConnection, + createDesignTheme, + createFlow, + createGateway, + createGatewaySecret, + createGatewayVariable, + createLayout, + createNotificationTemplate, + createOrganizationUnit, + createResource, + createResourceServer, + createSecret, + createUserByPath, + createUserSharingPolicy, + createVariable, + deleteAction, + deleteAgentType, + deleteApplicationSharingPolicy, + deleteConnection, + deleteDesignTheme, + deleteFlow, + deleteGateway, + deleteGatewaySecret, + deleteGatewayVariable, + deleteImportedConfiguration, + deleteLayout, + deleteNotificationTemplate, + deleteOrganizationUnit, + deleteOrganizationUnitByPath, + deleteResource, + deleteResourceServer, + deleteSecret, + deleteTranslation, + deleteTranslations, + deleteUserSharingPolicy, + deleteVariable, + exportConfiguration, + getAction, + getActions, + getApplicationOverlayRules, + getApplicationSharingPolicies, + getApplicationSharingPolicy, + getConfigurationVersion, + getConfigurationVersions, + getConnection, + getConnectionUsages, + getConnections, + getConnectionsMeta, + getDesignTheme, + getDesignThemeUsages, + getDesignThemes, + getFlow, + getFlowUsages, + getFlowVersion, + getFlowVersions, + getFlows, + getGateway, + getGatewayAppliedConfiguration, + getGatewayAppliedVersion, + getGatewayConfigurationDiff, + getGatewaySecret, + getGatewaySecrets, + getGatewayVariable, + getGatewayVariables, + getGateways, + getLanguages, + getLayout, + getLayoutUsages, + getLayouts, + getNotificationTemplate, + getNotificationTemplates, + getOrganizationUnit, + getOrganizationUnitByPath, + getOrganizationUnitChildren, + getOrganizationUnitGroups, + getOrganizationUnitRoles, + getOrganizationUnitUsers, + getOrganizationUnits, + getResource, + getResourceServer, + getResourceServers, + getResources, + getSecret, + getSecrets, + getServerConfig, + getServerConfigs, + getUserOverlayRules, + getUserSharingPolicies, + getUserSharingPolicy, + getUsersByPath, + getVariable, + getVariables, + importConfiguration, + resolveDesign, + resolveTranslation, + resolveTranslations, + restoreFlowVersion, + revertGatewayConfiguration, + setTranslation, + setTranslations, + suspendAgent, + suspendApplication, + suspendUser, + unlockAgent, + unlockUser, + unsuspendAgent, + unsuspendApplication, + unsuspendUser, + updateAction, + updateApplicationSharingPolicy, + updateConnection, + updateDesignTheme, + updateFlow, + updateGateway, + updateGatewaySecret, + updateGatewayVariable, + updateLayout, + updateNotificationTemplate, + updateOrganizationUnit, + updateOrganizationUnitByPath, + updateResource, + updateResourceServer, + updateSecret, + updateServerConfig, + updateUserSharingPolicy, + updateVariable, +} from '@thunderid/browser'; +export type { + AccountLifecycleOptions, + AccountLifecycleResult, + ApplyGatewayConfigurationConfig, + ApplyGatewayConfigurationRequest, + CaptureConfigurationVersionRequest, + ConfigurationActor, + ConfigurationExportOptions, + ConfigurationExportRequest, + ConfigurationExportResponse, + ConfigurationImportOptions, + ConfigurationImportOutcome, + ConfigurationImportRequest, + ConfigurationImportResponse, + ConfigurationResourceDeletion, + ConfigurationValueReference, + ConfigurationVersion, + Connection, + ConnectionCategory, + ConnectionInstance, + ConnectionListResponse, + ConnectionMetaResponse, + ConnectionRequest, + ConnectionSummary, + ConnectionType, + ConnectionTypeWithUsages, + ConnectionUsage, + ConnectionUsagesResponse, + ConnectionVendorMeta, + CreateActionConfig, + CreateActionRequest, + CreateAgentTypeConfig, + CreateAgentTypeRequest, + CreateApplicationSharingPolicyConfig, + CreateConfigurationVersionConfig, + CreateConnectionConfig, + CreateDesignThemeConfig, + CreateEmailTemplateRequest, + CreateFlowConfig, + CreateGatewayConfig, + CreateGatewaySecretConfig, + CreateGatewayVariableConfig, + CreateLayoutConfig, + CreateNotificationTemplateConfig, + CreateNotificationTemplateRequest, + CreateOrganizationUnitConfig, + CreateOrganizationUnitRequest, + CreateResourceConfig, + CreateResourceRequest, + CreateResourceServerConfig, + CreateResourceServerRequest, + CreateSecretConfig, + CreateSmsTemplateRequest, + CreateUserByPathConfig, + CreateUserByPathRequest, + CreateUserSharingPolicyConfig, + CreateVariableConfig, + DeleteActionConfig, + DeleteAgentTypeConfig, + DeleteApplicationSharingPolicyConfig, + DeleteConnectionConfig, + DeleteDesignThemeConfig, + DeleteFlowConfig, + DeleteGatewayConfig, + DeleteGatewaySecretConfig, + DeleteGatewayVariableConfig, + DeleteImportedConfigurationConfig, + DeleteImportedConfigurationRequest, + DeleteImportedConfigurationResponse, + DeleteLayoutConfig, + DeleteNotificationTemplateConfig, + DeleteOrganizationUnitByPathConfig, + DeleteOrganizationUnitConfig, + DeleteResourceConfig, + DeleteResourceServerConfig, + DeleteSecretConfig, + DeleteTranslationConfig, + DeleteTranslationsConfig, + DeleteUserSharingPolicyConfig, + DeleteVariableConfig, + DesignLayout, + DesignLayoutListItem, + DesignLayoutListResponse, + DesignLayoutRequest, + DesignResolveResponse, + DesignResolveType, + DesignTheme, + DesignThemeListItem, + DesignThemeListResponse, + DesignThemeRequest, + EmailNotificationTemplate, + EmailTemplateContent, + ExportConfigurationConfig, + Gateway, + GatewayAppliedConfiguration, + GatewayAppliedVersion, + GatewayApplyResult, + GatewayConfigurationChange, + GatewayConfigurationDiff, + GatewayRegistration, + GatewayValueChanges, + GetActionConfig, + GetActionsConfig, + GetApplicationOverlayRulesConfig, + GetApplicationSharingPoliciesConfig, + GetApplicationSharingPolicyConfig, + GetConfigurationVersionConfig, + GetConfigurationVersionsConfig, + GetConnectionConfig, + GetConnectionUsagesConfig, + GetConnectionsConfig, + GetConnectionsMetaConfig, + GetConnectionsResult, + GetDesignThemeConfig, + GetDesignThemeUsagesConfig, + GetDesignThemesConfig, + GetFlowConfig, + GetFlowUsagesConfig, + GetFlowVersionConfig, + GetFlowVersionsConfig, + GetFlowsConfig, + GetGatewayAppliedConfigurationConfig, + GetGatewayAppliedVersionConfig, + GetGatewayConfig, + GetGatewayConfigurationDiffConfig, + GetGatewaySecretConfig, + GetGatewaySecretsConfig, + GetGatewayVariableConfig, + GetGatewayVariablesConfig, + GetGatewaysConfig, + GetLanguagesConfig, + GetLayoutConfig, + GetLayoutUsagesConfig, + GetLayoutsConfig, + GetNotificationTemplateConfig, + GetNotificationTemplatesConfig, + GetOrganizationUnitByPathConfig, + GetOrganizationUnitChildrenConfig, + GetOrganizationUnitConfig, + GetOrganizationUnitGroupsConfig, + GetOrganizationUnitRolesConfig, + GetOrganizationUnitUsersConfig, + GetOrganizationUnitsConfig, + GetResourceConfig, + GetResourceServerConfig, + GetResourceServersConfig, + GetResourcesConfig, + GetSecretConfig, + GetSecretsConfig, + GetServerConfigConfig, + GetServerConfigsConfig, + GetUserOverlayRulesConfig, + GetUserSharingPoliciesConfig, + GetUserSharingPolicyConfig, + GetUsersByPathConfig, + GetVariableConfig, + GetVariablesConfig, + ImportConfigurationConfig, + LanguageListResponse, + LanguageTranslationsResponse, + ManagedAction, + ManagedActionKind, + ManagedActionListResponse, + ManagedFlow, + ManagedFlowDefinition, + ManagedFlowListResponse, + ManagedFlowNode, + ManagedFlowRequest, + ManagedFlowVersion, + ManagedFlowVersionInfo, + ManagedFlowVersionListResponse, + ManagedResource, + ManagedResourceListResponse, + ManagedTranslation, + NotificationChannel, + NotificationTemplate, + NotificationTemplateDesign, + NotificationTemplateListResponse, + NotificationTemplateSummary, + OrganizationUnit, + OrganizationUnitBasic, + OrganizationUnitGroup, + OrganizationUnitGroupListResponse, + OrganizationUnitListResponse, + OrganizationUnitReference, + OrganizationUnitRole, + OrganizationUnitRoleListResponse, + OrganizationUnitUser, + OrganizationUnitUserListResponse, + OverlayRule, + RegisterGatewayRequest, + ResolveDesignConfig, + ResolveTranslationConfig, + ResolveTranslationsConfig, + ResolvedOverlay, + ResourceServer, + ResourceServerAuthorizationEngine, + ResourceServerListResponse, + ResourceServerType, + ResourceUsage, + ResourceUsagesResponse, + RestoreFlowVersionConfig, + RevertGatewayConfigurationConfig, + RevertGatewayConfigurationRequest, + Secret, + SecretListResponse, + SecretRequest, + SecretUpdateRequest, + ServerConfigLayers, + SetTranslationConfig, + SetTranslationRequest, + SetTranslationsConfig, + SetTranslationsRequest, + SharingPolicy, + SharingPolicyAllUnits, + SharingPolicyChildUnit, + SharingPolicyChildUnits, + SharingPolicyChildUnitsAll, + SharingPolicyChildUnitsSelected, + SharingPolicyListResponse, + SharingPolicyOtherTreeRoots, + SharingPolicyOtherTreeRootsAll, + SharingPolicyOtherTreeRootsSelected, + SharingPolicyRequest, + SharingPolicyUpdateRequest, + SmsNotificationTemplate, + SmsTemplateContent, + SuspendAgentConfig, + SuspendApplicationConfig, + SuspendUserConfig, + TranslationsByNamespace, + UnlockAgentConfig, + UnlockUserConfig, + UnsuspendAgentConfig, + UnsuspendApplicationConfig, + UnsuspendUserConfig, + UpdateActionConfig, + UpdateActionRequest, + UpdateApplicationSharingPolicyConfig, + UpdateConnectionConfig, + UpdateDesignThemeConfig, + UpdateEmailTemplateRequest, + UpdateFlowConfig, + UpdateGatewayConfig, + UpdateGatewayRequest, + UpdateGatewaySecretConfig, + UpdateGatewayVariableConfig, + UpdateLayoutConfig, + UpdateNotificationTemplateConfig, + UpdateNotificationTemplateRequest, + UpdateOrganizationUnitByPathConfig, + UpdateOrganizationUnitByPathRequest, + UpdateOrganizationUnitConfig, + UpdateOrganizationUnitRequest, + UpdateResourceConfig, + UpdateResourceRequest, + UpdateResourceServerConfig, + UpdateResourceServerRequest, + UpdateSecretConfig, + UpdateServerConfigConfig, + UpdateSmsTemplateRequest, + UpdateUserSharingPolicyConfig, + UpdateVariableConfig, + Variable, + VariableListResponse, + VariableRequest, + VariableUpdateRequest, } from '@thunderid/browser'; diff --git a/packages/vue/src/models/contexts.ts b/packages/vue/src/models/contexts.ts index 46cdcec8..51f27ede 100644 --- a/packages/vue/src/models/contexts.ts +++ b/packages/vue/src/models/contexts.ts @@ -56,9 +56,9 @@ export interface ThunderIDContext { // ── HTTP ── http: { /** - * Transport supplied through `ThunderIDProvider`'s `http.fetcher` prop, used by the management - * composables (`useGetUsers`, `useCreateGroup`, and so on) only. A `fetcher` passed to an - * individual composable takes precedence over it. + * Transport supplied through `ThunderIDProvider`'s `http.fetcher` prop, used for management + * operations only: by the admin components (`UserList`, `GroupCreate`, and so on) and by + * `useManagementRequestConfig`. */ fetcher?: ApiFetcher; request: (requestConfig?: HttpRequestConfig) => Promise>; diff --git a/packages/vue/src/providers/ThunderIDProvider.ts b/packages/vue/src/providers/ThunderIDProvider.ts index 51b402d3..a7dd5079 100644 --- a/packages/vue/src/providers/ThunderIDProvider.ts +++ b/packages/vue/src/providers/ThunderIDProvider.ts @@ -137,10 +137,10 @@ const ThunderIDProvider: Component = defineComponent({ }, /** Instance ID for multi-instance support. */ /** - * HTTP options. `http.fetcher` is the transport used by the management composables - * (`useGetUsers`, `useCreateGroup`, and so on) and applies to management operations only. + * HTTP options. `http.fetcher` is the transport used for management operations only: by the + * admin components (`UserList`, `GroupCreate`, and so on) and by `useManagementRequestConfig`. * Defaults to the SDK's authenticated HTTP client, which attaches the signed-in user's access - * token. A `fetcher` passed to an individual composable takes precedence over this one. + * token. */ http: { default: undefined, diff --git a/playgrounds/nextjs/README.md b/playgrounds/nextjs/README.md index 3cd56daf..21529ef0 100644 --- a/playgrounds/nextjs/README.md +++ b/playgrounds/nextjs/README.md @@ -8,7 +8,7 @@ code you are changing. It is not published. | -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Overview (`/`) | Capability checks driven by the real SDK API, client and server (run one or run all, pass/fail with a reason, filters), a session card with an access token countdown, the provider configuration as the server sees it, the resolved management endpoints, and a live SDK event log. | | Components (`/components`) | A component explorer: a searchable tree of the `@thunderid/nextjs` components and their stories (including the Admin group: the management components, each as a Base story with sample data and a live story, plus `DataTable` and `Attribution`), a viewport, theme, and grid toolbar, and Controls, Code (a client component snippet), Actions, and Accessibility panels. | -| Hooks (`/hooks`) | The management hooks from `@thunderid/nextjs` (groups, roles, user groups and types, agent types, groups and roles, and the group and role mutations on a temporary resource) and `useGrantedPermissions`, next to the `@thunderid/react` hook for comparison. | +| Hooks (`/hooks`) | The management API functions called with `useManagementRequestConfig` from `@thunderid/nextjs` (groups, roles, user groups and types, agent types, groups and roles, and the group and role writes on a temporary resource) and `useGrantedPermissions`, next to the `@thunderid/react` hook for comparison. | | Server (`/server`) | A Server Component that reads the session with `thunderid()` while rendering, with a trace of each SDK call, and an API explorer for the proxy, route handlers, protected routes, and the back-channel logout endpoint ("Send as" session or anonymous). | | Tokens (`/tokens`) | The access token held in the session cookie, read through a server action and decoded locally, with expiry and claim notes. | | Protected (`/protected`) | A page behind `protectRoute()`. | diff --git a/playgrounds/nextjs/src/explorer/adminStories.tsx b/playgrounds/nextjs/src/explorer/adminStories.tsx index d0cddcc0..1b944843 100644 --- a/playgrounds/nextjs/src/explorer/adminStories.tsx +++ b/playgrounds/nextjs/src/explorer/adminStories.tsx @@ -7,9 +7,15 @@ import { AgentCreate, AgentDetails, AgentList, + ApplicationCreate, + ApplicationDetails, + ApplicationList, BaseAgentCreate, BaseAgentDetails, BaseAgentList, + BaseApplicationCreate, + BaseApplicationDetails, + BaseApplicationList, BaseGroupCreate, BaseGroupList, BaseGroupMembers, @@ -33,7 +39,9 @@ import { } from '@thunderid/nextjs'; import {Attribution} from '@thunderid/react'; import type { + Application, BasicAgent, + BasicApplication, Group, GroupMember, ManagedUser, @@ -97,6 +105,31 @@ const SAMPLE_AGENTS: BasicAgent[] = [ {description: 'Reconciles invoices', id: 'agt_ledger', name: 'Ledger', ouId: OU, type: 'worker'}, ]; +const SAMPLE_APPLICATIONS: BasicApplication[] = [ + {clientId: 'console_spa', id: 'app_console', name: 'Console', template: 'browser'}, + {clientId: 'billing_svc', id: 'app_billing', name: 'Billing service', template: 'm2m'}, +]; + +const SAMPLE_APPLICATION: Application = { + description: 'Admin console', + id: 'app_console', + inboundAuthConfig: [ + { + config: { + clientId: 'console_spa', + grantTypes: ['authorization_code', 'refresh_token'], + redirectUris: ['https://console.acme.io/callback'], + responseTypes: ['code'], + }, + type: 'oauth2', + }, + ], + name: 'Console', + ouId: OU, + template: 'browser', + url: 'https://console.acme.io', +}; + const SAMPLE_TYPES = [ {label: 'Customer', value: 'customer'}, {label: 'Employee', value: 'employee'}, @@ -141,6 +174,13 @@ const DELETABLE: PropControl = { type: 'boolean', }; +const SECRET_REGENERABLE: PropControl = { + control: 'bool', + description: 'Shows the regenerate client secret action (live only).', + name: 'secretRegenerable', + type: 'boolean', +}; + const idControl = (name: string, from: string): PropControl => ({ control: 'text', description: `Identifier to load (live only). Select one in ${from} and copy it from Actions.`, @@ -583,6 +623,96 @@ export const ADMIN_COMPONENTS: ComponentDef[] = [ ), }), + admin({ + baseExtra: listBaseExtra('applications'), + controls: [TITLE, LIMIT], + defaults: {limit: 20, title: ''}, + id: 'ApplicationList', + liveExtra: ['onSelect={(application) => setApplicationId(application.id)}'], + name: 'ApplicationList', + renderBase: (args: Args, action: Action) => ( + + {(selected: string | undefined, select: (rowId: string) => void) => ( + { + select(application.id); + action('onSelect', application.id); + }} + /> + )} + + ), + renderLive: (args: Args, action: Action) => ( + action('onSelect', application.id)} + /> + ), + }), + admin({ + baseExtra: ['onSubmit={handleSubmit}', 'clientSecret={createdSecret}', 'onSecretDismiss={clearSecret}'], + controls: [TITLE], + defaults: {title: ''}, + id: 'ApplicationCreate', + liveExtra: ['onCreated={handleCreated}'], + name: 'ApplicationCreate', + renderBase: (args: Args, action: Action) => ( + action('onSubmit', `name "${application.name}"`)} + onCancel={() => action('onCancel')} + /> + ), + renderLive: (args: Args, action: Action) => ( + action('onCreated', application.id)} + onCancel={() => action('onCancel')} + /> + ), + }), + admin({ + baseExtra: [ + 'application={application}', + 'onUpdate={handleUpdate}', + 'onDelete={handleDelete}', + 'onRegenerateSecret={handleRegenerate}', + ], + controls: [idControl('applicationId', 'ApplicationList'), EDITABLE, DELETABLE, SECRET_REGENERABLE], + defaults: {applicationId: '', deletable: false, editable: false, secretRegenerable: false}, + id: 'ApplicationDetails', + liveExtra: ['onDeleted={handleDeleted}'], + liveOnly: ['applicationId', 'editable', 'deletable', 'secretRegenerable'], + name: 'ApplicationDetails', + renderBase: (_args: Args, action: Action) => ( + action('onUpdate', `name "${changes.name}"`)} + onDelete={() => action('onDelete')} + onRegenerateSecret={() => action('onRegenerateSecret')} + /> + ), + renderLive: (args: Args, action: Action) => + str(args, 'applicationId') ? ( + action('onDeleted')} + /> + ) : ( + + ), + }), { code: (args: Args) => fromReact({ diff --git a/playgrounds/nextjs/src/hooks/Hooks.tsx b/playgrounds/nextjs/src/hooks/Hooks.tsx index 242edfe3..d29fcb4f 100644 --- a/playgrounds/nextjs/src/hooks/Hooks.tsx +++ b/playgrounds/nextjs/src/hooks/Hooks.tsx @@ -4,39 +4,45 @@ 'use client'; import { - ResourceMutationResult, - ResourceQueryResult, - useAddGroupMembers, - useAddRoleAssignments, - useCreateGroup, - useCreateRole, - useDeleteGroup, - useDeleteRole, - useGetAgentGroups, - useGetAgentRoles, - useGetAgents, - useGetAgentTypes, - useGetGroup, - useGetGroupMembers, - useGetGroups, - useGetRole, - useGetRoleAssignments, - useGetRoles, - useGetUserGroups, - useGetUserTypes, - useGrantedPermissions, - useRemoveGroupMembers, - useRemoveRoleAssignments, - useThunderID, - useUpdateGroup, - useUpdateRole, -} from '@thunderid/nextjs'; + addGroupMembers, + addRoleAssignments, + createGroup, + createRole, + deleteGroup, + deleteRole, + getAgentGroups, + getAgentRoles, + getAgents, + getAgentTypes, + getGroup, + getGroupMembers, + getGroups, + getRole, + getRoleAssignments, + getRoles, + getUserGroups, + getUserTypes, + removeGroupMembers, + removeRoleAssignments, + updateGroup, + updateRole, + type CreateGroupRequest, + type CreateRoleRequest, + type ManagementRequestConfig, + type RoleAssignmentInput, + type UpdateGroupRequest, + type UpdateRoleRequest, +} from '@thunderid/browser'; +import {useGrantedPermissions, useManagementRequestConfig, useThunderID} from '@thunderid/nextjs'; import {useGrantedPermissions as useReactGrantedPermissions, useUser} from '@thunderid/react'; import {ChangeEvent, ReactElement, ReactNode, useState} from 'react'; import {describeError, logEvent} from '../lib/events'; +import requireId from '../lib/requireId'; import {useServerSession} from '../lib/session'; +import useApiMutation, {type ApiMutation} from '../lib/useApiMutation'; +import useApiQuery, {type ApiQuery} from '../lib/useApiQuery'; -type Query = Pick, 'data' | 'error' | 'isLoading'> & {refetch?: () => Promise}; +type Query = Pick, 'data' | 'error' | 'isLoading'> & {refetch?: () => Promise}; const stateOf = (query: Query): [string, string] => { if (query.isLoading) return ['Loading', '']; @@ -46,7 +52,7 @@ const stateOf = (query: Query): [string, string] => { return ['OK', 'ok']; }; -/** One hook: its call, what it returned, and a refetch button. */ +/** One call: its signature, what it returned, and a refetch button. */ const HookCard = ({ children = null, name, @@ -110,7 +116,9 @@ const IdInput = ({ ); -type AnyMutation = Pick, 'error' | 'isLoading'>; +type Members = {id: string; type: 'user'}[]; + +type AnyMutation = Pick, 'error' | 'isLoading'>; const MutationRow = ({label, mutation}: {label: string; mutation: AnyMutation}): ReactElement => ( <> @@ -120,9 +128,10 @@ const MutationRow = ({label, mutation}: {label: string; mutation: AnyMutation}): ); /** - * 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. + * The management API functions, called with `useManagementRequestConfig` from @thunderid/nextjs through + * the playground's own data layer (lib/useApiQuery.ts and lib/useApiMutation.ts), and `useGrantedPermissions`. The config's + * fetcher sends every request 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(); @@ -141,12 +150,19 @@ const Hooks = (): ReactElement => { 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 groupsConfig: ManagementRequestConfig = useManagementRequestConfig('groups'); + const rolesConfig: ManagementRequestConfig = useManagementRequestConfig('roles'); + const usersConfig: ManagementRequestConfig = useManagementRequestConfig('users'); + const agentsConfig: ManagementRequestConfig = useManagementRequestConfig('agents'); + const userTypesConfig: ManagementRequestConfig = useManagementRequestConfig('userTypes'); + const agentTypesConfig: ManagementRequestConfig = useManagementRequestConfig('agentTypes'); + + // Lists, which also supply the default identifiers for the single-resource calls. + const groups = useApiQuery(() => getGroups({...groupsConfig, limit: 5})); + const roles = useApiQuery(() => getRoles({...rolesConfig, limit: 5})); + const agents = useApiQuery(() => getAgents({...agentsConfig, limit: 5})); + const userTypes = useApiQuery(() => getUserTypes({...userTypesConfig, limit: 10})); + const agentTypes = useApiQuery(() => getAgentTypes({...agentTypesConfig, limit: 10})); const firstGroup: string | undefined = groups.data?.groups[0]?.id; const firstRole: string | undefined = roles.data?.roles[0]?.id; @@ -156,36 +172,86 @@ const Hooks = (): ReactElement => { 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); + // The ID-scoped calls wait for an ID and run again when it changes. + const byGroup: {enabled: boolean; key?: string} = {enabled: !!groupId, key: groupId}; + const byRole: {enabled: boolean; key?: string} = {enabled: !!roleId, key: roleId}; + const byAgent: {enabled: boolean; key?: string} = {enabled: !!agentId, key: agentId}; + + const group = useApiQuery(() => getGroup({...groupsConfig, groupId: requireId(groupId, 'groupId')}), byGroup); + const members = useApiQuery( + () => getGroupMembers({...groupsConfig, groupId: requireId(groupId, 'groupId'), limit: 5}), + byGroup, + ); + const role = useApiQuery(() => getRole({...rolesConfig, roleId: requireId(roleId, 'roleId')}), byRole); + const assignments = useApiQuery( + () => getRoleAssignments({...rolesConfig, limit: 5, roleId: requireId(roleId, 'roleId')}), + byRole, + ); + const userGroups = useApiQuery(() => getUserGroups({...usersConfig, userId: requireId(userId, 'userId')}), { + enabled: !!userId, + key: userId, + }); + const agentGroups = useApiQuery( + () => getAgentGroups({...agentsConfig, agentId: requireId(agentId, 'agentId')}), + byAgent, + ); + const agentRoles = useApiQuery( + () => getAgentRoles({...agentsConfig, agentId: requireId(agentId, 'agentId')}), + byAgent, + ); const granted = useGrantedPermissions(); const reactGranted = useReactGrantedPermissions(); const onError = (name: string) => (error: Error) => logEvent(`${name}:failed`, error.message, 'error'); - const createGroup = useCreateGroup({ - onError: onError('useCreateGroup'), + const createGroupCall = useApiMutation((payload: CreateGroupRequest) => createGroup({...groupsConfig, payload}), { + onError: onError('createGroup'), 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 updateGroupCall = useApiMutation( + ({groupId: id, payload}: {groupId: string; payload: UpdateGroupRequest}) => + updateGroup({...groupsConfig, groupId: id, payload}), + {onError: onError('updateGroup')}, + ); + const addMembers = useApiMutation( + ({groupId: id, members: identities}: {groupId: string; members: Members}) => + addGroupMembers({...groupsConfig, groupId: id, members: identities}), + {onError: onError('addGroupMembers')}, + ); + const removeMembers = useApiMutation( + ({groupId: id, members: identities}: {groupId: string; members: Members}) => + removeGroupMembers({...groupsConfig, groupId: id, members: identities}), + {onError: onError('removeGroupMembers')}, + ); + const deleteGroupCall = useApiMutation((id: string) => deleteGroup({...groupsConfig, groupId: id}), { + onError: onError('deleteGroup'), + onSuccess: () => setCreatedGroup(null), + }); - const createRole = useCreateRole({ - onError: onError('useCreateRole'), + const createRoleCall = useApiMutation((payload: CreateRoleRequest) => createRole({...rolesConfig, payload}), { + onError: onError('createRole'), 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 updateRoleCall = useApiMutation( + ({payload, roleId: id}: {payload: UpdateRoleRequest; roleId: string}) => + updateRole({...rolesConfig, payload, roleId: id}), + {onError: onError('updateRole')}, + ); + const addAssignments = useApiMutation( + ({assignments: list, roleId: id}: {assignments: RoleAssignmentInput[]; roleId: string}) => + addRoleAssignments({...rolesConfig, assignments: list, roleId: id}), + {onError: onError('addRoleAssignments')}, + ); + const removeAssignments = useApiMutation( + ({assignments: list, roleId: id}: {assignments: RoleAssignmentInput[]; roleId: string}) => + removeRoleAssignments({...rolesConfig, assignments: list, roleId: id}), + {onError: onError('removeRoleAssignments')}, + ); + const deleteRoleCall = useApiMutation((id: string) => deleteRole({...rolesConfig, roleId: id}), { + onError: onError('deleteRole'), + 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}] : []); @@ -203,9 +269,12 @@ const Hooks = (): ReactElement => {

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. + The management API functions (getGroups(),{' '} + createGroup(), ...) called with{' '} + useManagementRequestConfig(collection) from @thunderid/nextjs through the + playground's own small data layer, and useGrantedPermissions. The + config's fetcher runs each request through the SDK's management server action with the server-held + session; the access token never reaches the browser.

@@ -214,7 +283,7 @@ const Hooks = (): ReactElement => {
{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.'} + : 'Not signed in: every management call below fails with HTTP 401 from the server action. Sign in from the Overview page.'}
)} @@ -242,15 +311,15 @@ const Hooks = (): ReactElement => {
Groups
- - + + - + { className="pg-btn" data-size="sm" disabled={!!writeBlocked || !!createdGroup} - onClick={() => void createGroup.mutate({name: name('group'), ouId: ouId!})} + onClick={() => void createGroupCall.mutate({name: name('group'), ouId: ouId!})} > Create @@ -273,8 +342,12 @@ const Hooks = (): ReactElement => { data-size="sm" disabled={!createdGroup} onClick={() => - void updateGroup.mutate({ - data: {description: `renamed ${new Date().toLocaleTimeString()}`, name: name('group'), ouId: ouId!}, + void updateGroupCall.mutate({ + payload: { + description: `renamed ${new Date().toLocaleTimeString()}`, + name: name('group'), + ouId: ouId!, + }, groupId: createdGroup!, }) } @@ -305,36 +378,36 @@ const Hooks = (): ReactElement => { data-size="sm" data-variant="danger" disabled={!createdGroup} - onClick={() => void deleteGroup.mutate(createdGroup!)} + onClick={() => void deleteGroupCall.mutate(createdGroup!)} > Delete
- - - - - + + + + +
Roles
- - + + { className="pg-btn" data-size="sm" disabled={!!writeBlocked || !!createdRole} - onClick={() => void createRole.mutate({name: name('role'), ouId: ouId!})} + onClick={() => void createRoleCall.mutate({name: name('role'), ouId: ouId!})} > Create @@ -357,8 +430,8 @@ const Hooks = (): ReactElement => { data-size="sm" disabled={!createdRole} onClick={() => - void updateRole.mutate({ - data: {description: 'updated', name: name('role'), ouId: ouId!, permissions: []}, + void updateRoleCall.mutate({ + payload: {description: 'updated', name: name('role'), ouId: ouId!, permissions: []}, roleId: createdRole!, }) } @@ -389,24 +462,24 @@ const Hooks = (): ReactElement => { data-size="sm" data-variant="danger" disabled={!createdRole} - onClick={() => void deleteRole.mutate(createdRole!)} + onClick={() => void deleteRoleCall.mutate(createdRole!)} > Delete
- - - - - + + + + +
Users
- + { onChange={setUserInput} /> - +
Agents
- - - + + + - +
); diff --git a/playgrounds/nextjs/src/lib/requireId.ts b/playgrounds/nextjs/src/lib/requireId.ts new file mode 100644 index 00000000..e17ee879 --- /dev/null +++ b/playgrounds/nextjs/src/lib/requireId.ts @@ -0,0 +1,11 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +/** Throws when an ID-scoped request has no ID yet, so its card shows why instead of calling the API. */ +const requireId = (id: string | undefined, name: string): string => { + if (!id) throw new Error(`No ${name} yet`); + + return id; +}; + +export default requireId; diff --git a/playgrounds/nextjs/src/lib/toError.ts b/playgrounds/nextjs/src/lib/toError.ts new file mode 100644 index 00000000..a962215e --- /dev/null +++ b/playgrounds/nextjs/src/lib/toError.ts @@ -0,0 +1,7 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +/** Normalizes a thrown value to an `Error`. */ +const toError = (caught: unknown): Error => (caught instanceof Error ? caught : new Error(String(caught))); + +export default toError; diff --git a/playgrounds/nextjs/src/lib/useApiMutation.ts b/playgrounds/nextjs/src/lib/useApiMutation.ts new file mode 100644 index 00000000..64e50732 --- /dev/null +++ b/playgrounds/nextjs/src/lib/useApiMutation.ts @@ -0,0 +1,71 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +'use client'; + +import {MutableRefObject, useCallback, useState} from 'react'; +import toError from './toError'; +import useLatest from './useLatest'; + +export interface ApiMutation { + data: T | undefined; + error: Error | null; + isLoading: boolean; + /** Runs the request and ignores a failure, which stays in `error`. */ + mutate: (variables: V) => void; + /** Runs the request; rejects when it fails. */ + mutateAsync: (variables: V) => Promise; +} + +export interface ApiMutationOptions { + onError?: (error: Error) => void; + onSuccess?: (data: T) => void; +} + +/** Runs `request` on demand, such as a create or delete, and keeps the state of the last run. */ +const useApiMutation = ( + request: (variables: V) => Promise, + options: ApiMutationOptions = {}, +): ApiMutation => { + const [data, setData] = useState(undefined); + const [error, setError] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const latest: MutableRefObject<(variables: V) => Promise> = useLatest(request); + const callbacks: MutableRefObject> = useLatest(options); + + const mutateAsync: (variables: V) => Promise = useCallback( + async (variables: V): Promise => { + setIsLoading(true); + setError(null); + + try { + const result: T = await latest.current(variables); + + setData(result); + callbacks.current.onSuccess?.(result); + + return result; + } catch (caught) { + const failure: Error = toError(caught); + + setError(failure); + callbacks.current.onError?.(failure); + throw failure; + } finally { + setIsLoading(false); + } + }, + [callbacks, latest], + ); + + const mutate: (variables: V) => void = useCallback( + (variables: V): void => { + mutateAsync(variables).catch(() => undefined); + }, + [mutateAsync], + ); + + return {data, error, isLoading, mutate, mutateAsync}; +}; + +export default useApiMutation; diff --git a/playgrounds/nextjs/src/lib/useApiQuery.ts b/playgrounds/nextjs/src/lib/useApiQuery.ts new file mode 100644 index 00000000..1066c4ad --- /dev/null +++ b/playgrounds/nextjs/src/lib/useApiQuery.ts @@ -0,0 +1,69 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +'use client'; + +import {MutableRefObject, useCallback, useEffect, useRef, useState} from 'react'; +import toError from './toError'; +import useLatest from './useLatest'; + +// The playground's own data layer for the SDK's management API functions. An app would use TanStack +// Query, SWR, or its own store; this is the smallest stand-in that gives the cards a +// {data, error, isLoading, refetch} result for a call such as +// `getGroups({...useManagementRequestConfig('groups'), limit: 5})`. + +export interface ApiQuery { + data: T | undefined; + error: Error | null; + isLoading: boolean; + /** Runs the request now, also while disabled. Resolves with the data, or undefined when it failed. */ + refetch: () => Promise; +} + +export interface ApiQueryOptions { + /** Runs the request on mount and whenever `key` changes. Defaults to true. */ + enabled?: boolean; + /** What the request is for, such as an ID; a change runs it again while enabled. */ + key?: string; +} + +/** Runs `request` and keeps its result. Only the response to the latest run is kept. */ +const useApiQuery = (request: () => Promise, {enabled = true, key = ''}: ApiQueryOptions = {}): ApiQuery => { + const [data, setData] = useState(undefined); + const [error, setError] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const latest: MutableRefObject<() => Promise> = useLatest(request); + const runs: MutableRefObject = useRef(0); + + const refetch: () => Promise = useCallback(async (): Promise => { + runs.current += 1; + const run: number = runs.current; + + setIsLoading(true); + + try { + const result: T = await latest.current(); + + if (run === runs.current) { + setData(result); + setError(null); + } + + return result; + } catch (caught) { + if (run === runs.current) setError(toError(caught)); + + return undefined; + } finally { + if (run === runs.current) setIsLoading(false); + } + }, [latest]); + + useEffect(() => { + if (enabled) refetch().catch(() => undefined); + }, [enabled, key, refetch]); + + return {data, error, isLoading, refetch}; +}; + +export default useApiQuery; diff --git a/playgrounds/nextjs/src/lib/useLatest.ts b/playgrounds/nextjs/src/lib/useLatest.ts new file mode 100644 index 00000000..64e70649 --- /dev/null +++ b/playgrounds/nextjs/src/lib/useLatest.ts @@ -0,0 +1,19 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +'use client'; + +import {MutableRefObject, useEffect, useRef} from 'react'; + +/** Holds the latest value of a callback, so a stable function can call it. */ +const useLatest = (value: F): MutableRefObject => { + const ref: MutableRefObject = useRef(value); + + useEffect(() => { + ref.current = value; + }); + + return ref; +}; + +export default useLatest; diff --git a/playgrounds/nuxt/README.md b/playgrounds/nuxt/README.md index 838d8a26..bb426d8d 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, 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. | +| 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 API functions: `useGrantedPermissions`, `useManagementEndpoint`, `useManagementRequestConfig`, and the group, role, user type, agent type, user group and agent group/role reads called with it through a small local data layer. | +| 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: @@ -104,16 +104,17 @@ The playground reports these instead of faking them: - 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`. + resource endpoint key and `getApplications` exists in `@thunderid/vue`, but the route answers 400 for it. +- The module still lists the management data composables (`useGetGroups`, `useCreateGroup`, ...) in + `MANAGEMENT_COMPOSABLES`, though `@thunderid/vue` no longer exports them; only `useGrantedPermissions` resolves. +- `DataTable`, `Attribution`, `useManagementEndpoint` and `useManagementRequestConfig` 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. +- The playground's management data layer (`lib/useApiQuery.ts` and `lib/useApiMutation.ts`) is not SSR-aware: on a + server render it reports `isLoading` and fetches 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/components/ManagementComposables.vue b/playgrounds/nuxt/components/ManagementComposables.vue index c8451e98..8f6602a9 100644 --- a/playgrounds/nuxt/components/ManagementComposables.vue +++ b/playgrounds/nuxt/components/ManagementComposables.vue @@ -2,71 +2,90 @@ // Copyright 2026 The ThunderID Authors // SPDX-License-Identifier: Apache-2.0 -import {toManagementErrorMessage} from '@thunderid/browser'; -import {useManagementEndpoint, type ManagementEndpointKey} from '@thunderid/vue'; -import {computed, type ComputedRef} from 'vue'; +import {getUserTypes, toManagementErrorMessage} from '@thunderid/browser'; import { - useCreateGroup, - useGetAgentGroups, - useGetAgentRoles, - useGetAgents, - useGetAgentTypes, - useGetGroupMembers, - useGetGroups, - useGetRoleAssignments, - useGetRoles, - useGetUserGroups, - useGetUsers, - useGetUserTypes, - useGrantedPermissions, - useRuntimeConfig, - useThunderID, - useThunderIDI18n, -} from '#imports'; + getAgentGroups, + getAgentRoles, + getAgents, + getAgentTypes, + getGroupMembers, + getGroups, + getRoleAssignments, + getRoles, + getUserGroups, + getUsers, + useManagementEndpoint, + useManagementRequestConfig, + type ManagementEndpointKey, + type ManagementRequestConfig, +} from '@thunderid/vue'; +import {computed, type ComputedRef} from 'vue'; +import {useGrantedPermissions, useRuntimeConfig, useThunderID, useThunderIDI18n} from '#imports'; import {describeManagementError, MANAGEMENT_COLLECTIONS, MANAGEMENT_ROUTE} from '~/lib/management'; +import requireId from '~/lib/requireId'; +import useApiQuery, {type ApiQuery, type ApiQueryOptions} from '~/lib/useApiQuery'; -// Every composable below is auto-imported by the module (thunderid.management) and fetches through -// http.fetcher, which posts to /api/auth/management. Without a session each query ends in a 401 error. +// Each card calls a management API function with the config from useManagementRequestConfig(), through +// the playground's own data layer (lib/useApiQuery.ts and lib/useApiMutation.ts). The config's fetcher is the http.fetcher the +// module installs (thunderid.management), which posts to /api/auth/management. Without a session each +// query ends in a 401 error. useGrantedPermissions is auto-imported by the module. const {isSignedIn} = useThunderID(); const {t} = useThunderIDI18n(); const clientId: string | undefined = (useRuntimeConfig().public.thunderid as {clientId?: string}).clientId; +const groupsConfig: ManagementRequestConfig = useManagementRequestConfig('groups'); +const rolesConfig: ManagementRequestConfig = useManagementRequestConfig('roles'); +const usersConfig: ManagementRequestConfig = useManagementRequestConfig('users'); +const agentsConfig: ManagementRequestConfig = useManagementRequestConfig('agents'); +const userTypesConfig: ManagementRequestConfig = useManagementRequestConfig('userTypes'); +const agentTypesConfig: ManagementRequestConfig = useManagementRequestConfig('agentTypes'); + const permissions = useGrantedPermissions(); -const groups = useGetGroups({limit: 5}); -const roles = useGetRoles({limit: 5}); -const users = useGetUsers({limit: 5}); -const agents = useGetAgents({limit: 5}); -const agentTypes = useGetAgentTypes({limit: 5}); -const userTypes = useGetUserTypes({limit: 5}); +const groups = useApiQuery(() => getGroups({...groupsConfig, limit: 5})); +const roles = useApiQuery(() => getRoles({...rolesConfig, limit: 5})); +const users = useApiQuery(() => getUsers({...usersConfig, limit: 5})); +const agents = useApiQuery(() => getAgents({...agentsConfig, limit: 5})); +const agentTypes = useApiQuery(() => getAgentTypes({...agentTypesConfig, limit: 5})); +const userTypes = useApiQuery(() => getUserTypes({...userTypesConfig, limit: 5})); -// The sub-resource composables wait for an identifier, so they start once the first page has one. +// The sub-resource calls wait for an identifier, so they start once the first page has one. const firstGroupId: ComputedRef = computed(() => groups.data.value?.groups[0]?.id); const firstRoleId: ComputedRef = computed(() => roles.data.value?.roles[0]?.id); const firstUserId: ComputedRef = computed(() => users.data.value?.users[0]?.id); const firstAgentId: ComputedRef = computed(() => agents.data.value?.agents[0]?.id); -const groupMembers = useGetGroupMembers(firstGroupId); -const roleAssignments = useGetRoleAssignments(firstRoleId); -const userGroups = useGetUserGroups(firstUserId); -const agentGroups = useGetAgentGroups(firstAgentId); -const agentRoles = useGetAgentRoles(firstAgentId); +const follows = (id: ComputedRef): ApiQueryOptions => ({ + enabled: () => !!id.value, + key: () => id.value, +}); -// A mutation is shown, not run: it would write to the directory. -const createGroup = useCreateGroup(); +const groupMembers = useApiQuery( + () => getGroupMembers({...groupsConfig, groupId: requireId(firstGroupId.value, 'group')}), + follows(firstGroupId), +); +const roleAssignments = useApiQuery( + () => getRoleAssignments({...rolesConfig, roleId: requireId(firstRoleId.value, 'role')}), + follows(firstRoleId), +); +const userGroups = useApiQuery( + () => getUserGroups({...usersConfig, userId: requireId(firstUserId.value, 'user')}), + follows(firstUserId), +); +const agentGroups = useApiQuery( + () => getAgentGroups({...agentsConfig, agentId: requireId(firstAgentId.value, 'agent')}), + follows(firstAgentId), +); +const agentRoles = useApiQuery( + () => getAgentRoles({...agentsConfig, agentId: requireId(firstAgentId.value, 'agent')}), + follows(firstAgentId), +); const endpoints: Record = Object.fromEntries( MANAGEMENT_COLLECTIONS.map(({key}: {key: ManagementEndpointKey}) => [key, useManagementEndpoint(key)]), ); -interface QueryState { - data: {value: unknown}; - error: {value: Error | null}; - isLoading: {value: boolean}; - refetch: () => Promise; -} - /** Shows a query with its error explained, and the translated message the admin components would show. */ -const view = (query: QueryState, extra: Record = {}): Record => ({ +const view = (query: ApiQuery, extra: Record = {}): Record => ({ ...extra, data: query.data.value, error: query.error.value @@ -88,54 +107,66 @@ const permissionsValue: ComputedRef> = computed(() => ({ })); const queries: ComputedRef<{name: string; signature: string; value: Record}[]> = computed(() => [ - {name: 'useGetGroups({limit: 5})', signature: 'ResourceQueryResult', value: view(groups)}, + {name: 'getGroups({...config, limit: 5})', signature: 'Promise', value: view(groups)}, { - name: 'useGetGroupMembers(groupId)', + name: 'getGroupMembers({...config, groupId})', signature: 'members of the first group; waits for an id', value: view(groupMembers, {groupId: firstGroupId.value ?? null}), }, - {name: 'useGetRoles({limit: 5})', signature: 'ResourceQueryResult', value: view(roles)}, + {name: 'getRoles({...config, limit: 5})', signature: 'Promise', value: view(roles)}, { - name: 'useGetRoleAssignments(roleId)', + name: 'getRoleAssignments({...config, roleId})', signature: 'assignments of the first role; waits for an id', value: view(roleAssignments, {roleId: firstRoleId.value ?? null}), }, - {name: 'useGetUserTypes({limit: 5})', signature: 'endpoints.userTypes', value: view(userTypes)}, { - name: 'useGetUserGroups(userId)', + name: 'getUserTypes({...config, limit: 5})', + signature: "useManagementRequestConfig('userTypes'): endpoints.userTypes", + value: view(userTypes), + }, + { + name: 'getUserGroups({...config, userId})', signature: 'groups of the first user (endpoints.users); waits for an id', value: view(userGroups, {userId: firstUserId.value ?? null}), }, - {name: 'useGetAgentTypes({limit: 5})', signature: 'endpoints.agentTypes', value: view(agentTypes)}, { - name: 'useGetAgentGroups(agentId)', + name: 'getAgentTypes({...config, limit: 5})', + signature: "useManagementRequestConfig('agentTypes'): endpoints.agentTypes", + value: view(agentTypes), + }, + { + name: 'getAgentGroups({...config, agentId})', signature: 'groups of the first agent; waits for an id', value: view(agentGroups, {agentId: firstAgentId.value ?? null}), }, { - name: 'useGetAgentRoles(agentId)', + name: 'getAgentRoles({...config, agentId})', signature: 'roles of the first agent; waits for an id', value: view(agentRoles, {agentId: firstAgentId.value ?? null}), }, ]); -const mutationValue: ComputedRef> = computed(() => ({ - data: createGroup.data.value, - error: createGroup.error.value?.message ?? null, - isLoading: createGroup.isLoading.value, - mutate: createGroup.mutate, - mutateAsync: createGroup.mutateAsync, - reset: createGroup.reset, -})); +const requestConfigs: Record = { + agentTypes: agentTypesConfig, + agents: agentsConfig, + groups: groupsConfig, + roles: rolesConfig, + userTypes: userTypesConfig, + users: usersConfig, +}; diff --git a/playgrounds/nuxt/lib/adminStories.ts b/playgrounds/nuxt/lib/adminStories.ts index e6b461b1..10b4f0c3 100644 --- a/playgrounds/nuxt/lib/adminStories.ts +++ b/playgrounds/nuxt/lib/adminStories.ts @@ -5,7 +5,9 @@ /* eslint-disable vue/one-component-per-file */ import type { + Application, BasicAgent, + BasicApplication, Group, GroupMember, ManagedUser, @@ -23,9 +25,15 @@ import { AgentCreate, AgentDetails, AgentList, + ApplicationCreate, + ApplicationDetails, + ApplicationList, BaseAgentCreate, BaseAgentDetails, BaseAgentList, + BaseApplicationCreate, + BaseApplicationDetails, + BaseApplicationList, BaseGroupCreate, BaseGroupList, BaseGroupMembers, @@ -148,6 +156,42 @@ const SAMPLE_AGENTS: BasicAgent[] = [ {clientId: 'agt_reports', id: 'agt_3', name: 'Report builder', ouHandle: 'acme', ouId: 'ou_acme', type: 'worker'}, ]; +const SAMPLE_APPLICATIONS: BasicApplication[] = [ + {clientId: 'portal-web', id: 'app_portal', name: 'Customer portal', template: 'browser'}, + {clientId: 'billing-api', id: 'app_billing', name: 'Billing service', template: 'm2m'}, + {clientId: 'acme-mobile', id: 'app_mobile', name: 'Acme mobile', template: 'mobile'}, +]; + +const SAMPLE_APPLICATION: Application = { + description: 'Self-service portal for customers', + id: 'app_portal', + inboundAuthConfig: [ + { + config: { + clientId: 'portal-web', + grantTypes: ['authorization_code', 'refresh_token'], + redirectUris: ['https://portal.acme.io/callback'], + responseTypes: ['code'], + }, + type: 'oauth2', + }, + ], + name: 'Customer portal', + ouId: 'ou_acme', + template: 'browser', + url: 'https://portal.acme.io', +}; + +/** A secret shown in the "secret shown once" stories. Sample only, never a real credential. */ +const SAMPLE_SECRET = 'sample-secret-5f1c9e2a'; + +const SHOW_SECRET: PropControl = { + control: 'bool', + description: 'Passes clientSecret, the secret shown once (base only).', + name: 'showSecret', + type: 'boolean', +}; + const SAMPLE_MEMBERSHIPS: MembershipGroup[] = [ {id: 'grp_eng', name: 'Engineering', ouId: 'ou_acme'}, {id: 'grp_support', name: 'Support', ouId: 'ou_acme'}, @@ -691,6 +735,52 @@ const ribbonCard = (child: VNode): VNode => [h('div', {style: {padding: '20px'}}, [h(Button, {variant: 'outline'}, {default: () => 'Sign in'})]), child], ); +/** + * The details story with its actions on for the sample application too, plus a read-only story. + */ +const applicationDetailsDef = (spec: DetailsSpec): ComponentDef => { + const def: ComponentDef = detailsDef(spec); + + return { + ...def, + controls: def.controls.map((control: PropControl) => + spec.liveFlags.includes(control.name) + ? { + ...control, + description: `Turns on ${control.name} actions (onUpdate, onDelete, onRegenerateSecret on the base).`, + } + : control, + ), + defaults: {...def.defaults, ...Object.fromEntries(spec.liveFlags.map((flag: string) => [flag, true]))}, + stories: [ + def.stories[0], + { + args: Object.fromEntries(spec.liveFlags.map((flag: string) => [flag, false])), + id: 'readonly', + name: 'Read only', + }, + ...def.stories.slice(1), + ], + }; +}; + +/** + * Adds the "client secret shown once" control and story to an application create or details story, + * whose base variant `renderBase` draws with the secret. + */ +const withSecretStory = ( + def: ComponentDef, + storyName: string, + renderBase: (args: Args, action: Action, clientSecret: string | null) => VNode, +): ComponentDef => ({ + ...def, + controls: [...def.controls, SHOW_SECRET], + defaults: {...def.defaults, showSecret: false}, + render: (args: Args, action: Action) => + isLive(args) ? def.render(args, action) : renderBase(args, action, bool(args, 'showSecret') ? SAMPLE_SECRET : null), + stories: [def.stories[0], {args: {showSecret: true}, id: 'secret', name: storyName}, ...def.stories.slice(1)], +}); + const sampleDetailsScript = (name: string, value: unknown): string[] => [ `const ${name} = ref(${literal(value)});`, `const groups = ref(${literal(SAMPLE_MEMBERSHIPS.slice(0, 1))});`, @@ -971,4 +1061,72 @@ export const ADMIN_COMPONENTS: ComponentDef[] = [ liveFlags: ['deletable'], name: 'AgentDetails', }), + listDef({ + base: BaseApplicationList, + id: 'applicationlist', + live: ApplicationList, + name: 'ApplicationList', + rows: SAMPLE_APPLICATIONS, + rowsProp: 'applications', + selectedProp: 'selectedApplicationId', + }), + withSecretStory( + createDef({ + base: BaseApplicationCreate, + baseExtra: [':client-secret="clientSecret"', '@secret-dismiss="clientSecret = null"'], + baseScript: ['// Set from the created application, then cleared once copied.', 'const clientSecret = ref(null);'], + id: 'applicationcreate', + live: ApplicationCreate, + name: 'ApplicationCreate', + }), + 'Client secret shown once', + (args: Args, action: Action, clientSecret: string | null) => + narrow( + 480, + h(BaseApplicationCreate, { + clientSecret, + error: optional(args, 'error') ?? null, + isSubmitting: bool(args, 'isSubmitting'), + onCancel: () => action('cancel'), + onSecretDismiss: () => action('secretDismiss'), + onSubmit: (submission: unknown) => action('submit', JSON.stringify(submission)), + title: optional(args, 'title'), + }), + ), + ), + withSecretStory( + applicationDetailsDef({ + base: BaseApplicationDetails, + baseExtra: [ + ':application="application"', + ':client-secret="clientSecret"', + '@update="handleUpdate"', + '@regenerate-secret="handleRegenerateSecret"', + '@secret-dismiss="clientSecret = null"', + ], + baseProps: {application: SAMPLE_APPLICATION}, + baseScript: [`const application = ref(${literal(SAMPLE_APPLICATION)});`, 'const clientSecret = ref(null);'], + id: 'applicationdetails', + idProp: 'applicationId', + live: ApplicationDetails, + liveFlags: ['editable', 'deletable', 'secretRegenerable'], + name: 'ApplicationDetails', + }), + 'Regenerated secret', + (args: Args, action: Action, clientSecret: string | null) => + narrow( + 560, + h(BaseApplicationDetails, { + ...stateProps(args), + application: SAMPLE_APPLICATION, + clientSecret, + onDelete: bool(args, 'deletable') ? () => action('delete') : undefined, + onRegenerateSecret: bool(args, 'secretRegenerable') ? () => action('regenerateSecret') : undefined, + onSecretDismiss: () => action('secretDismiss'), + onUpdate: bool(args, 'editable') + ? (changes: unknown) => action('update', JSON.stringify(changes)) + : undefined, + }), + ), + ), ]; diff --git a/playgrounds/nuxt/lib/capabilities.ts b/playgrounds/nuxt/lib/capabilities.ts index eab417c1..9b14cd81 100644 --- a/playgrounds/nuxt/lib/capabilities.ts +++ b/playgrounds/nuxt/lib/capabilities.ts @@ -751,7 +751,7 @@ export const CAPABILITIES: Capability[] = [ section: 'Management', }, { - api: 'getGroups() · getGroupMembers() · useGetGroups()', + api: 'getGroups() · getGroupMembers()', id: 'groups', name: 'Groups', run: managementCheck(async (context: CapabilityContext) => { @@ -769,7 +769,7 @@ export const CAPABILITIES: Capability[] = [ section: 'Management', }, { - api: 'getRoles() · getRoleAssignments() · useGetRoles()', + api: 'getRoles() · getRoleAssignments()', id: 'roles', name: 'Roles', run: managementCheck(async (context: CapabilityContext) => { @@ -787,7 +787,7 @@ export const CAPABILITIES: Capability[] = [ section: 'Management', }, { - api: 'getAgentTypes() · useGetAgentTypes() · endpoints.agentTypes', + api: 'getAgentTypes() · endpoints.agentTypes', id: 'agentTypes', name: 'Agent types', run: managementCheck(async (context: CapabilityContext) => { @@ -802,7 +802,7 @@ export const CAPABILITIES: Capability[] = [ section: 'Management', }, { - api: 'getAgentGroups() · getAgentRoles() · useGetAgentGroups() · useGetAgentRoles()', + api: 'getAgentGroups() · getAgentRoles()', id: 'agentMemberships', name: 'Agent groups and roles', run: managementCheck(async (context: CapabilityContext) => { @@ -823,7 +823,7 @@ export const CAPABILITIES: Capability[] = [ section: 'Management', }, { - api: 'getUserGroups() · getUserTypes() · useGetUserGroups() · useGetUserTypes()', + api: 'getUserGroups() · getUserTypes()', id: 'userMemberships', name: 'User groups and types', run: managementCheck(async (context: CapabilityContext) => { diff --git a/playgrounds/nuxt/lib/customRenderStories.ts b/playgrounds/nuxt/lib/customRenderStories.ts index be1de27e..f1b5ffa4 100644 --- a/playgrounds/nuxt/lib/customRenderStories.ts +++ b/playgrounds/nuxt/lib/customRenderStories.ts @@ -1,13 +1,31 @@ // 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 type {Application, ManagedUser, MembershipGroup} from '@thunderid/browser'; +import type { + AdminListRenderProps, + ApplicationCreateSubmission, + BaseApplicationCreateRenderProps, + BaseApplicationDetailsRenderProps, + 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 {applicationCard, applicationForm, 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'; +import { + ApplicationCreate, + ApplicationDetails, + BaseApplicationCreate, + BaseApplicationDetails, + BaseGroupCreate, + BaseUserDetails, + BaseUserList, + GroupCreate, + UserDetails, + UserList, +} from '#components'; // The admin components are auto-imported by @thunderid/nuxt when thunderid.management is on. @@ -48,6 +66,27 @@ const SAMPLE_MEMBERSHIPS: MembershipGroup[] = [ {id: 'grp_sec', name: 'Security', ouId: OU}, ]; +const SAMPLE_APPLICATION: Application = { + id: 'app_portal', + inboundAuthConfig: [ + { + config: { + clientId: 'portal-web', + grantTypes: ['authorization_code', 'refresh_token'], + redirectUris: ['https://portal.acme.io/callback'], + responseTypes: ['code'], + }, + type: 'oauth2', + }, + ], + name: 'Customer portal', + ouId: OU, + template: 'browser', +}; + +/** A secret shown in the "secret shown once" stories. Sample only, never a real credential. */ +const SAMPLE_SECRET = 'sample-secret-5f1c9e2a'; + const isLive = (args: Args): boolean => str(args, 'source') === 'live'; const themed = (width: number, child: VNode): VNode => narrow(width, h(PrimeTheme, null, {default: () => child})); @@ -135,6 +174,51 @@ const CARD_TEMPLATE = ` `; +const APPLICATION_FORM_TEMPLATE = ` + +`; + +const APPLICATION_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. @@ -293,4 +377,126 @@ export const CUSTOM_RENDER_COMPONENTS: ComponentDef[] = [ {args: {source: 'live'}, id: 'live', name: 'Live (needs userId)'}, ], }, + { + code: () => ({ + imports: ['ApplicationCreate'], + otherImports: [ + "import Button from 'primevue/button';", + "import InputText from 'primevue/inputtext';", + "import Message from 'primevue/message';", + "import MultiSelect from 'primevue/multiselect';", + "import Password from 'primevue/password';", + ], + template: APPLICATION_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'}, + {control: 'bool', description: 'Base only: passes clientSecret.', name: 'showSecret', type: 'boolean'}, + ], + defaults: {error: '', isSubmitting: false, showSecret: false, source: 'base'}, + group: 'Custom rendering', + id: 'custom-applicationcreate-form', + live: true, + name: 'ApplicationCreate → PrimeVue form', + render: (args: Args, action: Action): VNode => + themed( + 480, + isLive(args) + ? h( + ApplicationCreate, + { + onCancel: (): void => action('cancel'), + onCreated: (application: {id: string}): void => action('created', quoted(application.id)), + }, + {default: (state: BaseApplicationCreateRenderProps) => applicationForm(state)}, + ) + : h( + BaseApplicationCreate, + { + clientSecret: bool(args, 'showSecret') ? SAMPLE_SECRET : null, + error: str(args, 'error') || null, + isSubmitting: bool(args, 'isSubmitting'), + onCancel: (): void => action('cancel'), + onSecretDismiss: (): void => action('secret-dismiss'), + onSubmit: (application: ApplicationCreateSubmission): void => + action('submit', JSON.stringify(application)), + }, + {default: (state: BaseApplicationCreateRenderProps) => applicationForm(state)}, + ), + ), + stories: [ + {id: 'base', name: 'Sample'}, + {args: {showSecret: true}, id: 'secret', name: 'Client secret shown once'}, + {args: {source: 'live'}, id: 'live', name: 'Live (needs sign-in)'}, + ], + }, + { + code: () => ({ + imports: ['ApplicationDetails'], + otherImports: [ + "import Button from 'primevue/button';", + "import Card from 'primevue/card';", + "import Chip from 'primevue/chip';", + "import Password from 'primevue/password';", + ], + template: APPLICATION_CARD_TEMPLATE, + }), + controls: [ + SOURCE_CONTROL, + {control: 'text', description: 'Live only: the application to load.', name: 'applicationId', type: 'string'}, + {control: 'bool', description: 'Passes onDelete.', name: 'deletable', type: 'boolean'}, + {control: 'bool', description: 'Passes onRegenerateSecret.', name: 'secretRegenerable', type: 'boolean'}, + {control: 'bool', description: 'Base only: passes clientSecret.', name: 'showSecret', type: 'boolean'}, + ], + defaults: {applicationId: '', deletable: true, secretRegenerable: true, showSecret: false, source: 'base'}, + group: 'Custom rendering', + id: 'custom-applicationdetails-card', + live: true, + name: 'ApplicationDetails → PrimeVue card', + render: (args: Args, action: Action): VNode => { + if (isLive(args) && !str(args, 'applicationId')) { + return narrow( + 420, + h('p', 'Set applicationId in Controls to an ID from your server (copy one from the ApplicationList story).'), + ); + } + + return themed( + 480, + isLive(args) + ? h( + ApplicationDetails, + { + applicationId: str(args, 'applicationId'), + deletable: bool(args, 'deletable'), + onDeleted: (): void => action('deleted'), + secretRegenerable: bool(args, 'secretRegenerable'), + }, + {default: (state: BaseApplicationDetailsRenderProps) => applicationCard(state)}, + ) + : h( + BaseApplicationDetails, + { + application: SAMPLE_APPLICATION, + clientSecret: bool(args, 'showSecret') ? SAMPLE_SECRET : null, + onDelete: bool(args, 'deletable') + ? (): void => action('delete', quoted(SAMPLE_APPLICATION.id)) + : undefined, + onRegenerateSecret: bool(args, 'secretRegenerable') + ? (): void => action('regenerate-secret', quoted(SAMPLE_APPLICATION.id)) + : undefined, + onSecretDismiss: (): void => action('secret-dismiss'), + }, + {default: (state: BaseApplicationDetailsRenderProps) => applicationCard(state)}, + ), + ); + }, + stories: [ + {id: 'base', name: 'Sample application'}, + {args: {showSecret: true}, id: 'secret', name: 'Regenerated secret'}, + {args: {source: 'live'}, id: 'live', name: 'Live (needs applicationId)'}, + ], + }, ]; diff --git a/playgrounds/nuxt/lib/management.ts b/playgrounds/nuxt/lib/management.ts index b4e93f66..b2f2315c 100644 --- a/playgrounds/nuxt/lib/management.ts +++ b/playgrounds/nuxt/lib/management.ts @@ -9,9 +9,15 @@ import { AgentCreate, AgentDetails, AgentList, + ApplicationCreate, + ApplicationDetails, + ApplicationList, BaseAgentCreate, BaseAgentDetails, BaseAgentList, + BaseApplicationCreate, + BaseApplicationDetails, + BaseApplicationList, BaseGroupCreate, BaseGroupList, BaseGroupMembers, @@ -33,40 +39,7 @@ import { 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'; +import {useGrantedPermissions} from '#imports'; /** The route the module serves when `management` is on. */ export const MANAGEMENT_ROUTE = '/api/auth/management'; @@ -141,9 +114,15 @@ export const AUTO_IMPORTED_COMPONENTS: Record = { AgentCreate, AgentDetails, AgentList, + ApplicationCreate, + ApplicationDetails, + ApplicationList, BaseAgentCreate, BaseAgentDetails, BaseAgentList, + BaseApplicationCreate, + BaseApplicationDetails, + BaseApplicationList, BaseGroupCreate, BaseGroupList, BaseGroupMembers, @@ -166,39 +145,13 @@ export const AUTO_IMPORTED_COMPONENTS: Record = { UserList, }; +/** + * Of the management composables the module auto-imports, the ones `@thunderid/vue` still exports. The + * data composables (`useGetGroups`, `useCreateGroup`, ...) are internal to the SDK's own components now; + * apps call the API functions with `useManagementRequestConfig` instead. + */ 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. */ diff --git a/playgrounds/nuxt/lib/primeRenderers.ts b/playgrounds/nuxt/lib/primeRenderers.ts index 1af3457e..5ee883cb 100644 --- a/playgrounds/nuxt/lib/primeRenderers.ts +++ b/playgrounds/nuxt/lib/primeRenderers.ts @@ -4,6 +4,8 @@ import type {ManagedUser, MembershipGroup} from '@thunderid/browser'; import { type AdminListRenderProps, + type BaseApplicationCreateRenderProps, + type BaseApplicationDetailsRenderProps, type BaseGroupCreateRenderProps, type BaseUserDetailsRenderProps, type DataTableColumn, @@ -12,11 +14,13 @@ import { import Avatar from 'primevue/avatar'; import Button from 'primevue/button'; import Card from 'primevue/card'; +import Checkbox from 'primevue/checkbox'; 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 Password from 'primevue/password'; import Textarea from 'primevue/textarea'; import {defineComponent, h, onBeforeUnmount, watchEffect, type Component, type VNode, type VNodeChild} from 'vue'; @@ -162,3 +166,137 @@ export const userCard = (state: BaseUserDetailsRenderProps): VNode => ]), }, ); + +/** A secret the SDK hands over once, drawn as a PrimeVue warning with a masked, revealable field. */ +const secretNotice = (secret: string, onDismiss?: () => void): VNode => + h('div', {class: 'pg-prime-stack'}, [ + h(Message, {severity: 'warn'}, {default: () => 'Copy the client secret now. It will not be shown again.'}), + h(Password, {feedback: false, modelValue: secret, readonly: true, toggleMask: true}), + onDismiss ? h(Button, {label: 'Done', onClick: onDismiss}) : null, + ]); + +/** The application form's default scoped slot drawn with PrimeVue inputs and checkboxes. */ +export const applicationForm = (state: BaseApplicationCreateRenderProps): VNode => + h( + Card, + {}, + { + content: () => + state.clientSecret + ? secretNotice(state.clientSecret, state.onSecretDismiss) + : 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', 'Redirect URIs (comma separated)'), + h(InputText, { + modelValue: state.values.redirectUris, + 'onUpdate:modelValue': (value: string | undefined) => + state.handleInputChange('redirectUris', value ?? ''), + }), + ]), + h( + 'div', + {class: 'pg-prime-chips'}, + state.grantTypeOptions.map((option: {label: string; value: string}) => + h('label', {class: 'pg-prime-title', key: option.value}, [ + h(Checkbox, { + binary: true, + modelValue: state.values.grantTypes.includes(option.value), + 'onUpdate:modelValue': (checked: boolean) => + state.handleGrantTypesChange( + checked + ? [...state.values.grantTypes, option.value] + : state.values.grantTypes.filter((value: string) => value !== option.value), + ), + }), + option.label, + ]), + ), + ), + h('label', {class: 'pg-prime-title'}, [ + h(Checkbox, { + binary: true, + modelValue: state.values.publicClient, + 'onUpdate:modelValue': (checked: boolean) => state.handlePublicClientChange(checked), + }), + 'Public client', + ]), + ]), + footer: () => + state.clientSecret + ? null + : 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 application', + loading: state.isSubmitting, + onClick: state.handleSubmit, + }), + ]), + subtitle: () => "Rendered with PrimeVue from the SDK's default scoped slot", + title: () => 'New application', + }, + ); + +/** + * The application details' default scoped slot drawn as a PrimeVue card. The slot hands over only the + * actions the app allowed; the SDK leaves the confirmation of a destructive action to the custom UI. + */ +export const applicationCard = (state: BaseApplicationDetailsRenderProps): VNode => + h( + Card, + {}, + { + content: () => + h('div', {class: 'pg-prime-stack'}, [ + state.error ? h(Message, {severity: 'error'}, {default: () => state.error}) : null, + state.clientSecret ? secretNotice(state.clientSecret, state.onSecretDismiss) : null, + h( + 'div', + {class: 'pg-prime-chips'}, + (state.oauth?.grantTypes ?? []).map((grantType: string) => + h(Chip, {class: 'pg-prime-group', key: grantType, label: grantType}), + ), + ), + h( + 'div', + {class: 'pg-prime-chips'}, + (state.oauth?.redirectUris ?? []).map((uri: string) => h(Chip, {key: uri, label: uri})), + ), + ]), + footer: () => + h('div', {class: 'pg-prime-actions'}, [ + state.onRegenerateSecret + ? h(Button, { + label: 'Regenerate secret', + loading: state.isRegeneratingSecret, + onClick: state.onRegenerateSecret, + severity: 'secondary', + text: true, + }) + : null, + state.onDelete + ? h(Button, { + label: 'Delete', + loading: state.isDeleting, + onClick: state.onDelete, + severity: 'danger', + text: true, + }) + : null, + ]), + subtitle: () => + state.oauth?.clientId ? `Client ID ${state.oauth.clientId}` : (state.application?.template ?? ''), + title: () => (state.isLoading && !state.application ? 'Loading…' : (state.application?.name ?? '')), + }, + ); diff --git a/playgrounds/nuxt/lib/requireId.ts b/playgrounds/nuxt/lib/requireId.ts new file mode 100644 index 00000000..e17ee879 --- /dev/null +++ b/playgrounds/nuxt/lib/requireId.ts @@ -0,0 +1,11 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +/** Throws when an ID-scoped request has no ID yet, so its card shows why instead of calling the API. */ +const requireId = (id: string | undefined, name: string): string => { + if (!id) throw new Error(`No ${name} yet`); + + return id; +}; + +export default requireId; diff --git a/playgrounds/nuxt/lib/toError.ts b/playgrounds/nuxt/lib/toError.ts new file mode 100644 index 00000000..a962215e --- /dev/null +++ b/playgrounds/nuxt/lib/toError.ts @@ -0,0 +1,7 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +/** Normalizes a thrown value to an `Error`. */ +const toError = (caught: unknown): Error => (caught instanceof Error ? caught : new Error(String(caught))); + +export default toError; diff --git a/playgrounds/nuxt/lib/useApiMutation.ts b/playgrounds/nuxt/lib/useApiMutation.ts new file mode 100644 index 00000000..4fedaab9 --- /dev/null +++ b/playgrounds/nuxt/lib/useApiMutation.ts @@ -0,0 +1,54 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ref, shallowRef, type Ref, type ShallowRef} from 'vue'; +import toError from './toError'; + +export interface ApiMutation { + data: ShallowRef; + error: Ref; + isLoading: Ref; + /** Runs the request; rejects when it fails. */ + mutateAsync: (variables: V) => Promise; +} + +export interface ApiMutationOptions { + onError?: (error: Error) => void; + onSuccess?: (data: T) => unknown; +} + +/** Runs `request` on demand, such as a create or delete, and keeps the state of the last run. */ +const useApiMutation = ( + request: (variables: V) => Promise, + options: ApiMutationOptions = {}, +): ApiMutation => { + const data: ShallowRef = shallowRef(undefined); + const error: Ref = ref(null); + const isLoading: Ref = ref(false); + + const mutateAsync = async (variables: V): Promise => { + isLoading.value = true; + error.value = null; + + try { + const result: T = await request(variables); + + data.value = result; + await options.onSuccess?.(result); + + return result; + } catch (caught) { + const failure: Error = toError(caught); + + error.value = failure; + options.onError?.(failure); + throw failure; + } finally { + isLoading.value = false; + } + }; + + return {data, error, isLoading, mutateAsync}; +}; + +export default useApiMutation; diff --git a/playgrounds/nuxt/lib/useApiQuery.ts b/playgrounds/nuxt/lib/useApiQuery.ts new file mode 100644 index 00000000..f8ad2f96 --- /dev/null +++ b/playgrounds/nuxt/lib/useApiQuery.ts @@ -0,0 +1,69 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ref, shallowRef, watch, type Ref, type ShallowRef} from 'vue'; +import toError from './toError'; + +// The playground's own data layer for the SDK's management API functions. An app would use TanStack +// Query, Pinia Colada, or its own store; this is the smallest stand-in that gives the cards a +// {data, error, isLoading, refetch} result for a call such as +// `getGroups({...useManagementRequestConfig('groups'), limit: 5})`. + +export interface ApiQuery { + data: ShallowRef; + error: Ref; + isLoading: Ref; + /** Runs the request now, also while disabled. Resolves with the data, or undefined when it failed. */ + refetch: () => Promise; +} + +export interface ApiQueryOptions { + /** Runs the request when it turns true, and again whenever `key` changes. Defaults to always. */ + enabled?: () => boolean; + /** What the request is for, such as an ID read from another query. */ + key?: () => unknown; +} + +/** Runs `request` and keeps its result. Only the response to the latest run is kept. */ +const useApiQuery = (request: () => Promise, options: ApiQueryOptions = {}): ApiQuery => { + const data: ShallowRef = shallowRef(undefined); + const error: Ref = ref(null); + const isLoading: Ref = ref(false); + let runs = 0; + + const refetch = async (): Promise => { + runs += 1; + const run: number = runs; + + isLoading.value = true; + + try { + const result: T = await request(); + + if (run === runs) { + data.value = result; + error.value = null; + } + + return result; + } catch (caught) { + if (run === runs) error.value = toError(caught); + + return undefined; + } finally { + if (run === runs) isLoading.value = false; + } + }; + + watch( + () => [options.enabled?.() ?? true, options.key?.()], + ([enabled]: unknown[]) => { + if (enabled) refetch().catch(() => undefined); + }, + {immediate: true}, + ); + + return {data, error, isLoading, refetch}; +}; + +export default useApiQuery; diff --git a/playgrounds/react/README.md b/playgrounds/react/README.md index 9e83eac4..a8d3f496 100644 --- a/playgrounds/react/README.md +++ b/playgrounds/react/README.md @@ -3,12 +3,12 @@ A Vite app that exercises every capability of `@thunderid/react` against a 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 driven by the real SDK API (run one or run all, pass/fail with a reason, filters), a session card with an access token countdown, the provider configuration with the resolved management endpoints, and a live SDK event log. | -| Components (`#/components`) | A component explorer: a searchable tree of the SDK components and their stories (including the admin components, `DataTable`, and `Attribution`), a viewport, theme, and grid toolbar, and Controls, Code, Actions, and Accessibility panels. | -| Hooks (`#/hooks`) | Live values of `useThunderID`, `useUser`, `useI18n`, `useTranslation`, `useTheme`, `useFlow`, `useGrantedPermissions`, the management query hooks, and the group and role mutation hooks. | -| Tokens (`#/tokens`) | The access and ID tokens held by the SDK, decoded locally, with expiry and claim notes. | +| Page | What it does | +| --------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Overview (`#/`) | Capability checks driven by the real SDK API (run one or run all, pass/fail with a reason, filters), a session card with an access token countdown, the provider configuration with the resolved management endpoints, and a live SDK event log. | +| Components (`#/components`) | A component explorer: a searchable tree of the SDK components and their stories (including the admin components, `DataTable`, and `Attribution`), a viewport, theme, and grid toolbar, and Controls, Code, Actions, and Accessibility panels. | +| Hooks (`#/hooks`) | Live values of `useThunderID`, `useUser`, `useI18n`, `useTranslation`, `useTheme`, `useFlow`, `useGrantedPermissions`, and the management API functions (`getGroups()`, `createGroup()`, ...) called with `useManagementRequestConfig`, reads and the group and role writes. | +| Tokens (`#/tokens`) | The access and ID tokens held by the SDK, decoded locally, with expiry and claim notes. | ## Set up a local ThunderID server @@ -105,11 +105,10 @@ The playground reports these instead of faking them: management URL. The playground mirrors the paths in `src/lib/management.ts`. - `@thunderid/react` does not re-export `resolveResourceEndpoint`, `ThunderIDAPIError`, `getUserTypes`, or `toManagementErrorMessage`; the playground imports them from `@thunderid/browser`. -- The authenticated fetcher the management hooks use (`createAuthenticatedFetcher`) is internal, and - `useThunderID().http.fetcher` is only set when the app passes one, so code calling the core management functions - directly has to build its own (the checks attach `getAccessToken()` as a bearer token). -- `refetch()` on the ID-scoped hooks (`useGetGroup`, `useGetRole`, `useGetGroupMembers`, ...) ignores a missing ID: with - `undefined` it requests the collection URL and returns the list as if it were one item. The Hooks page disables Fetch - until an ID is set. +- The capability checks build their own token fetcher (they attach `getAccessToken()` as a bearer token); the Hooks page + uses `useManagementRequestConfig(collection)`, which supplies the SDK's authenticated fetcher. +- The management data hooks (`useGetGroups`, `useCreateGroup`, ...) are internal to the SDK's own components. The Hooks + page calls the API functions through a small local data layer (`src/lib/useApiQuery.ts` and + `src/lib/useApiMutation.ts`) in their place, and disables Fetch on the ID-scoped calls until an ID is set. - `useGrantedPermissions()` reads the token once per sign-in; it does not update after a token refresh that changes scopes. diff --git a/playgrounds/react/src/components/HookCard.tsx b/playgrounds/react/src/components/HookCard.tsx index f8dbc2e7..a047c273 100644 --- a/playgrounds/react/src/components/HookCard.tsx +++ b/playgrounds/react/src/components/HookCard.tsx @@ -36,7 +36,7 @@ export interface QueryLike { interface QueryCardProps { children?: ReactNode; - /** Disables Fetch, for example while a hook that needs an ID has none. */ + /** Disables Fetch, for example while a call that needs an ID has none. */ disabled?: boolean; name: string; query: QueryLike; @@ -48,7 +48,7 @@ export const QueryCard = ({ disabled = false, name, query, - signature = '({limit}, {enabled: false}) => {data, error, isLoading, refetch}', + signature = '({...config, limit}) => Promise', }: QueryCardProps): ReactElement => ( {children ?? (

- Management hook. Calls the ThunderID management API with the signed-in user's token. + Management API function, called with useManagementRequestConfig() so the request carries the signed-in + user's token.

)}
diff --git a/playgrounds/react/src/lib/requireId.ts b/playgrounds/react/src/lib/requireId.ts new file mode 100644 index 00000000..e17ee879 --- /dev/null +++ b/playgrounds/react/src/lib/requireId.ts @@ -0,0 +1,11 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +/** Throws when an ID-scoped request has no ID yet, so its card shows why instead of calling the API. */ +const requireId = (id: string | undefined, name: string): string => { + if (!id) throw new Error(`No ${name} yet`); + + return id; +}; + +export default requireId; diff --git a/playgrounds/react/src/lib/toError.ts b/playgrounds/react/src/lib/toError.ts new file mode 100644 index 00000000..a962215e --- /dev/null +++ b/playgrounds/react/src/lib/toError.ts @@ -0,0 +1,7 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +/** Normalizes a thrown value to an `Error`. */ +const toError = (caught: unknown): Error => (caught instanceof Error ? caught : new Error(String(caught))); + +export default toError; diff --git a/playgrounds/react/src/lib/useApiMutation.ts b/playgrounds/react/src/lib/useApiMutation.ts new file mode 100644 index 00000000..1179e7e9 --- /dev/null +++ b/playgrounds/react/src/lib/useApiMutation.ts @@ -0,0 +1,69 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {MutableRefObject, useCallback, useState} from 'react'; +import toError from './toError'; +import useLatest from './useLatest'; + +export interface ApiMutation { + data: T | undefined; + error: Error | null; + isLoading: boolean; + /** Runs the request and ignores a failure, which stays in `error`. */ + mutate: (variables: V) => void; + /** Runs the request; rejects when it fails. */ + mutateAsync: (variables: V) => Promise; +} + +export interface ApiMutationOptions { + onError?: (error: Error) => void; + onSuccess?: (data: T) => void; +} + +/** Runs `request` on demand, such as a create or delete, and keeps the state of the last run. */ +const useApiMutation = ( + request: (variables: V) => Promise, + options: ApiMutationOptions = {}, +): ApiMutation => { + const [data, setData] = useState(undefined); + const [error, setError] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const latest: MutableRefObject<(variables: V) => Promise> = useLatest(request); + const callbacks: MutableRefObject> = useLatest(options); + + const mutateAsync: (variables: V) => Promise = useCallback( + async (variables: V): Promise => { + setIsLoading(true); + setError(null); + + try { + const result: T = await latest.current(variables); + + setData(result); + callbacks.current.onSuccess?.(result); + + return result; + } catch (caught) { + const failure: Error = toError(caught); + + setError(failure); + callbacks.current.onError?.(failure); + throw failure; + } finally { + setIsLoading(false); + } + }, + [callbacks, latest], + ); + + const mutate: (variables: V) => void = useCallback( + (variables: V): void => { + mutateAsync(variables).catch(() => undefined); + }, + [mutateAsync], + ); + + return {data, error, isLoading, mutate, mutateAsync}; +}; + +export default useApiMutation; diff --git a/playgrounds/react/src/lib/useApiQuery.ts b/playgrounds/react/src/lib/useApiQuery.ts new file mode 100644 index 00000000..7e71d800 --- /dev/null +++ b/playgrounds/react/src/lib/useApiQuery.ts @@ -0,0 +1,67 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {MutableRefObject, useCallback, useEffect, useRef, useState} from 'react'; +import toError from './toError'; +import useLatest from './useLatest'; + +// The playground's own data layer for the SDK's management API functions. An app would use TanStack +// Query, SWR, or its own store; this is the smallest stand-in that gives the cards a +// {data, error, isLoading, refetch} result for a call such as +// `getGroups({...useManagementRequestConfig('groups'), limit: 5})`. + +export interface ApiQuery { + data: T | undefined; + error: Error | null; + isLoading: boolean; + /** Runs the request now, also while disabled. Resolves with the data, or undefined when it failed. */ + refetch: () => Promise; +} + +export interface ApiQueryOptions { + /** Runs the request on mount and whenever `key` changes. Defaults to true. */ + enabled?: boolean; + /** What the request is for, such as an ID; a change runs it again while enabled. */ + key?: string; +} + +/** Runs `request` and keeps its result. Only the response to the latest run is kept. */ +const useApiQuery = (request: () => Promise, {enabled = true, key = ''}: ApiQueryOptions = {}): ApiQuery => { + const [data, setData] = useState(undefined); + const [error, setError] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const latest: MutableRefObject<() => Promise> = useLatest(request); + const runs: MutableRefObject = useRef(0); + + const refetch: () => Promise = useCallback(async (): Promise => { + runs.current += 1; + const run: number = runs.current; + + setIsLoading(true); + + try { + const result: T = await latest.current(); + + if (run === runs.current) { + setData(result); + setError(null); + } + + return result; + } catch (caught) { + if (run === runs.current) setError(toError(caught)); + + return undefined; + } finally { + if (run === runs.current) setIsLoading(false); + } + }, [latest]); + + useEffect(() => { + if (enabled) refetch().catch(() => undefined); + }, [enabled, key, refetch]); + + return {data, error, isLoading, refetch}; +}; + +export default useApiQuery; diff --git a/playgrounds/react/src/lib/useLatest.ts b/playgrounds/react/src/lib/useLatest.ts new file mode 100644 index 00000000..19756921 --- /dev/null +++ b/playgrounds/react/src/lib/useLatest.ts @@ -0,0 +1,17 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {MutableRefObject, useEffect, useRef} from 'react'; + +/** Holds the latest value of a callback, so a stable function can call it. */ +const useLatest = (value: F): MutableRefObject => { + const ref: MutableRefObject = useRef(value); + + useEffect(() => { + ref.current = value; + }); + + return ref; +}; + +export default useLatest; diff --git a/playgrounds/react/src/pages/Hooks.tsx b/playgrounds/react/src/pages/Hooks.tsx index feb215e0..60898449 100644 --- a/playgrounds/react/src/pages/Hooks.tsx +++ b/playgrounds/react/src/pages/Hooks.tsx @@ -2,22 +2,25 @@ // SPDX-License-Identifier: Apache-2.0 import { + getAgents, + getApplications, + getUsers, + type ManagementRequestConfig, useFlow, - useGetAgents, - useGetApplications, - useGetUsers, useI18n, + useManagementRequestConfig, useTheme, useThunderID, useTranslation, useUser, } from '@thunderid/react'; import {ReactElement, useState} from 'react'; -import ManagementHooks from './ManagementHooks'; +import ManagementApi from './ManagementApi'; import EventLog from '../components/EventLog'; import {HookCard, QueryCard} from '../components/HookCard'; import {describeError, track} from '../lib/events'; import {sdkSignIn, sdkSignOut} from '../lib/sdk'; +import useApiQuery from '../lib/useApiQuery'; const Hooks = (): ReactElement => { const sdk = useThunderID(); @@ -26,9 +29,12 @@ const Hooks = (): ReactElement => { const theme = useTheme(); const flow = useFlow(); const {t} = useTranslation(); - const users = useGetUsers({limit: 5}, {enabled: false}); - const applications = useGetApplications({limit: 5}, {enabled: false}); - const agents = useGetAgents({limit: 5}, {enabled: false}); + const usersConfig: ManagementRequestConfig = useManagementRequestConfig('users'); + const applicationsConfig: ManagementRequestConfig = useManagementRequestConfig('applications'); + const agentsConfig: ManagementRequestConfig = useManagementRequestConfig('agents'); + const users = useApiQuery(() => getUsers({...usersConfig, limit: 5}), {enabled: false}); + const applications = useApiQuery(() => getApplications({...applicationsConfig, limit: 5}), {enabled: false}); + const agents = useApiQuery(() => getAgents({...agentsConfig, limit: 5}), {enabled: false}); const [error, setError] = useState(null); const call = (label: string, run: () => Promise): void => { @@ -44,7 +50,8 @@ const Hooks = (): ReactElement => {

Hooks

- Live values from every hook @thunderid/react exposes. Functions are shown + Live values from every hook @thunderid/react exposes, then the management + API functions called with useManagementRequestConfig. Functions are shown as signatures; use the buttons to call them.

@@ -155,10 +162,10 @@ const Hooks = (): ReactElement => { signature="FlowContextValue: current step, title, messages for embedded flows" value={flow} /> - - - - + + + + diff --git a/playgrounds/react/src/pages/ManagementHooks.tsx b/playgrounds/react/src/pages/ManagementApi.tsx similarity index 54% rename from playgrounds/react/src/pages/ManagementHooks.tsx rename to playgrounds/react/src/pages/ManagementApi.tsx index b6bc3e07..f4688f3a 100644 --- a/playgrounds/react/src/pages/ManagementHooks.tsx +++ b/playgrounds/react/src/pages/ManagementApi.tsx @@ -1,45 +1,53 @@ // Copyright 2026 The ThunderID Authors // SPDX-License-Identifier: Apache-2.0 +import {getUserTypes} from '@thunderid/browser'; import { type AgentListResponse, + type CreateGroupRequest, + type CreateRoleRequest, type Group, + type ManagementRequestConfig, type Role, - useAddGroupMembers, - useAddRoleAssignments, - useCreateGroup, - useCreateRole, - useDeleteGroup, - useDeleteRole, - useGetAgentGroups, - useGetAgentRoles, - useGetAgentTypes, - useGetGroup, - useGetGroupMembers, - useGetGroups, - useGetRole, - useGetRoleAssignments, - useGetRoles, - useGetUserGroups, - useGetUserTypes, + type RoleAssignmentInput, + addGroupMembers, + addRoleAssignments, + createGroup, + createRole, + deleteGroup, + deleteRole, + getAgentGroups, + getAgentRoles, + getAgentTypes, + getGroup, + getGroupMembers, + getGroups, + getRole, + getRoleAssignments, + getRoles, + getUserGroups, + removeGroupMembers, + removeRoleAssignments, + updateGroup, + updateRole, useGrantedPermissions, - useRemoveGroupMembers, - useRemoveRoleAssignments, + useManagementRequestConfig, useThunderID, - useUpdateGroup, - useUpdateRole, useUser, } from '@thunderid/react'; import {ChangeEvent, ReactElement, useState} from 'react'; import {HookCard, QueryCard} from '../components/HookCard'; import {describeError, track} from '../lib/events'; import {PERMISSION_PROBES} from '../lib/management'; +import requireId from '../lib/requireId'; import {asText} from '../lib/sdk'; +import useApiMutation from '../lib/useApiMutation'; +import useApiQuery from '../lib/useApiQuery'; interface IdFieldProps { label: string; onChange: (value: string) => void; - /** Fills the field, for example from the first item of a list hook. */ + /** Fills the field, for example from the first item of a list call. */ onFill?: () => void; fillLabel?: string; value: string; @@ -63,7 +71,9 @@ const IdField = ({fillLabel = 'Use first', label, onChange, onFill = undefined, ); -const ID_SIGNATURE = '(id, {limit}, {enabled}) => {data, error, isLoading, refetch}'; +const ID_SIGNATURE = '({...config, , limit}) => Promise'; + +type Members = {id: string; type: 'user'}[]; const mutationState = (mutation: {data: unknown; error: Error | null; isLoading: boolean}): unknown => ({ data: mutation.data, @@ -72,10 +82,12 @@ const mutationState = (mutation: {data: unknown; error: Error | null; isLoading: }); /** - * The group, role, type, and membership hooks, plus useGrantedPermissions. Reads start disabled and - * run from Fetch; writes only run from their buttons and act on a scratch group or role. + * The group, role, type, and membership API functions, plus useGrantedPermissions. Each card calls an + * SDK management API function with the config from useManagementRequestConfig(collection), through the + * playground's own data layer (lib/useApiQuery.ts and lib/useApiMutation.ts). Reads run from Fetch; writes only run from their + * buttons and act on a scratch group or role. */ -const ManagementHooks = ({agents}: {agents: AgentListResponse | undefined}): ReactElement => { +const ManagementApi = ({agents}: {agents: AgentListResponse | undefined}): ReactElement => { const sdk = useThunderID(); const {flattenedProfile} = useUser(); const granted = useGrantedPermissions(); @@ -91,28 +103,79 @@ const ManagementHooks = ({agents}: {agents: AgentListResponse | undefined}): Rea const [scratchRole, setScratchRole] = useState(null); const [error, setError] = useState(null); - const groups = useGetGroups({limit: 5}, {enabled: false}); - const roles = useGetRoles({limit: 5}, {enabled: false}); - const userTypes = useGetUserTypes({limit: 10}, {enabled: false}); - const agentTypes = useGetAgentTypes({limit: 10}, {enabled: false}); - const group = useGetGroup(groupId || undefined, {enabled: false}); - const groupMembers = useGetGroupMembers(groupId || undefined, {limit: 10}, {enabled: false}); - const role = useGetRole(roleId || undefined, {enabled: false}); - const roleAssignments = useGetRoleAssignments(roleId || undefined, {limit: 10}, {enabled: false}); - const userGroups = useGetUserGroups(userId || undefined, {limit: 10}, {enabled: false}); - const agentGroups = useGetAgentGroups(agentId || undefined, {limit: 10}, {enabled: false}); - const agentRoles = useGetAgentRoles(agentId || undefined, {limit: 10}, {enabled: false}); + const groupsConfig: ManagementRequestConfig = useManagementRequestConfig('groups'); + const rolesConfig: ManagementRequestConfig = useManagementRequestConfig('roles'); + const usersConfig: ManagementRequestConfig = useManagementRequestConfig('users'); + const agentsConfig: ManagementRequestConfig = useManagementRequestConfig('agents'); + const userTypesConfig: ManagementRequestConfig = useManagementRequestConfig('userTypes'); + const agentTypesConfig: ManagementRequestConfig = useManagementRequestConfig('agentTypes'); + const manual: {enabled: boolean} = {enabled: false}; - const createGroup = useCreateGroup(); - const updateGroup = useUpdateGroup(); - const addGroupMembers = useAddGroupMembers(); - const removeGroupMembers = useRemoveGroupMembers(); - const deleteGroup = useDeleteGroup(); - const createRole = useCreateRole(); - const updateRole = useUpdateRole(); - const addRoleAssignments = useAddRoleAssignments(); - const removeRoleAssignments = useRemoveRoleAssignments(); - const deleteRole = useDeleteRole(); + const groups = useApiQuery(() => getGroups({...groupsConfig, limit: 5}), manual); + const roles = useApiQuery(() => getRoles({...rolesConfig, limit: 5}), manual); + const userTypes = useApiQuery(() => getUserTypes({...userTypesConfig, limit: 10}), manual); + const agentTypes = useApiQuery(() => getAgentTypes({...agentTypesConfig, limit: 10}), manual); + const group = useApiQuery(() => getGroup({...groupsConfig, groupId: requireId(groupId, 'groupId')}), manual); + const groupMembers = useApiQuery( + () => getGroupMembers({...groupsConfig, groupId: requireId(groupId, 'groupId'), limit: 10}), + manual, + ); + const role = useApiQuery(() => getRole({...rolesConfig, roleId: requireId(roleId, 'roleId')}), manual); + const roleAssignments = useApiQuery( + () => getRoleAssignments({...rolesConfig, limit: 10, roleId: requireId(roleId, 'roleId')}), + manual, + ); + const userGroups = useApiQuery( + () => getUserGroups({...usersConfig, limit: 10, userId: requireId(userId, 'userId')}), + manual, + ); + const agentGroups = useApiQuery( + () => getAgentGroups({...agentsConfig, agentId: requireId(agentId, 'agentId'), limit: 10}), + manual, + ); + const agentRoles = useApiQuery( + () => getAgentRoles({...agentsConfig, agentId: requireId(agentId, 'agentId'), limit: 10}), + manual, + ); + + const createGroupCall = useApiMutation((payload: CreateGroupRequest) => createGroup({...groupsConfig, payload})); + const updateGroupCall = useApiMutation((target: Group) => + updateGroup({ + ...groupsConfig, + groupId: target.id, + payload: {description: 'Renamed by the playground', name: `${target.name}-renamed`, ouId: target.ouId}, + }), + ); + const addGroupMembersCall = useApiMutation(({groupId: id, members}: {groupId: string; members: Members}) => + addGroupMembers({...groupsConfig, groupId: id, members}), + ); + const removeGroupMembersCall = useApiMutation(({groupId: id, members}: {groupId: string; members: Members}) => + removeGroupMembers({...groupsConfig, groupId: id, members}), + ); + const deleteGroupCall = useApiMutation((id: string) => deleteGroup({...groupsConfig, groupId: id})); + const createRoleCall = useApiMutation((payload: CreateRoleRequest) => createRole({...rolesConfig, payload})); + const updateRoleCall = useApiMutation((target: Role) => + updateRole({ + ...rolesConfig, + current: target, + payload: { + description: 'Renamed by the playground', + name: `${target.name}-renamed`, + ouId: target.ouId, + permissions: target.permissions ?? [], + }, + roleId: target.id, + }), + ); + const addRoleAssignmentsCall = useApiMutation( + ({assignments, roleId: id}: {assignments: RoleAssignmentInput[]; roleId: string}) => + addRoleAssignments({...rolesConfig, assignments, roleId: id}), + ); + const removeRoleAssignmentsCall = useApiMutation( + ({assignments, roleId: id}: {assignments: RoleAssignmentInput[]; roleId: string}) => + removeRoleAssignments({...rolesConfig, assignments, roleId: id}), + ); + const deleteRoleCall = useApiMutation((id: string) => deleteRole({...rolesConfig, roleId: id})); const run = (label: string, call: () => Promise): void => { setError(null); @@ -131,6 +194,20 @@ const ManagementHooks = ({agents}: {agents: AgentListResponse | undefined}): Rea return ( <> +
+
+

Management API functions

+

+ The SDK's management API functions (getGroups(),{' '} + createGroup(), ...) called with the config from{' '} + useManagementRequestConfig(collection), through the playground's own + small data layer (src/lib/useApiQuery.ts and{' '} + src/lib/useApiMutation.ts). An app would use TanStack Query, SWR, or its + own store in its place. +

+
+
+ {error && (
{error} @@ -151,12 +228,23 @@ const ManagementHooks = ({agents}: {agents: AgentListResponse | undefined}): Rea {!sdk.isSignedIn &&

Sign in: permissions come from the access token scope claim.

} - - - - + + + + + + - + setGroupId(groups.data?.groups[0]?.id ?? '') : undefined} /> - -

Uses the groupId above{groupId ? `: ${groupId}` : '. Fetch useGetGroups() first.'}

+ +

Uses the groupId above{groupId ? `: ${groupId}` : '. Fetch getGroups() first.'}

- + -

Uses the roleId above{roleId ? `: ${roleId}` : '. Fetch useGetRoles() first.'}

+

Uses the roleId above{roleId ? `: ${roleId}` : '. Fetch getRoles() first.'}

- + - + setAgentId(agents?.agents[0]?.id ?? '') : undefined} /> - -

Uses the agentId above{agentId ? `: ${agentId}` : '. Fetch useGetAgents() first.'}

+ +

Uses the agentId above{agentId ? `: ${agentId}` : '. Fetch getAgents() first.'}

@@ -224,7 +312,7 @@ const ManagementHooks = ({agents}: {agents: AgentListResponse | undefined}): Rea disabled={needsOu || !!scratchGroup} onClick={() => run('createGroup', async () => - setScratchGroup(await createGroup.mutateAsync({name: scratchName('group'), ouId})), + setScratchGroup(await createGroupCall.mutateAsync({name: scratchName('group'), ouId})), ) } > @@ -238,16 +326,7 @@ const ManagementHooks = ({agents}: {agents: AgentListResponse | undefined}): Rea onClick={() => run('updateGroup', async () => { if (!scratchGroup) return; - setScratchGroup( - await updateGroup.mutateAsync({ - data: { - description: 'Renamed by the playground', - name: `${scratchGroup.name}-renamed`, - ouId: scratchGroup.ouId, - }, - groupId: scratchGroup.id, - }), - ); + setScratchGroup(await updateGroupCall.mutateAsync(scratchGroup)); }) } > @@ -261,7 +340,7 @@ const ManagementHooks = ({agents}: {agents: AgentListResponse | undefined}): Rea onClick={() => run('addGroupMembers', async () => { if (scratchGroup) - await addGroupMembers.mutateAsync({ + await addGroupMembersCall.mutateAsync({ groupId: scratchGroup.id, members: [{id: await me(), type: 'user'}], }); @@ -278,7 +357,7 @@ const ManagementHooks = ({agents}: {agents: AgentListResponse | undefined}): Rea onClick={() => run('removeGroupMembers', async () => { if (scratchGroup) - await removeGroupMembers.mutateAsync({ + await removeGroupMembersCall.mutateAsync({ groupId: scratchGroup.id, members: [{id: await me(), type: 'user'}], }); @@ -296,7 +375,7 @@ const ManagementHooks = ({agents}: {agents: AgentListResponse | undefined}): Rea onClick={() => run('deleteGroup', async () => { if (!scratchGroup) return; - await deleteGroup.mutateAsync(scratchGroup.id); + await deleteGroupCall.mutateAsync(scratchGroup.id); setScratchGroup(null); }) } @@ -321,14 +400,14 @@ const ManagementHooks = ({agents}: {agents: AgentListResponse | undefined}): Rea @@ -339,7 +418,7 @@ const ManagementHooks = ({agents}: {agents: AgentListResponse | undefined}): Rea disabled={needsOu || !!scratchRole} onClick={() => run('createRole', async () => - setScratchRole(await createRole.mutateAsync({name: scratchName('role'), ouId})), + setScratchRole(await createRoleCall.mutateAsync({name: scratchName('role'), ouId})), ) } > @@ -353,17 +432,7 @@ const ManagementHooks = ({agents}: {agents: AgentListResponse | undefined}): Rea onClick={() => run('updateRole', async () => { if (!scratchRole) return; - setScratchRole( - await updateRole.mutateAsync({ - data: { - description: 'Renamed by the playground', - name: `${scratchRole.name}-renamed`, - ouId: scratchRole.ouId, - permissions: scratchRole.permissions ?? [], - }, - roleId: scratchRole.id, - }), - ); + setScratchRole(await updateRoleCall.mutateAsync(scratchRole)); }) } > @@ -377,7 +446,7 @@ const ManagementHooks = ({agents}: {agents: AgentListResponse | undefined}): Rea onClick={() => run('addRoleAssignments', async () => { if (scratchRole) - await addRoleAssignments.mutateAsync({ + await addRoleAssignmentsCall.mutateAsync({ assignments: [{id: await me(), type: 'user'}], roleId: scratchRole.id, }); @@ -394,7 +463,7 @@ const ManagementHooks = ({agents}: {agents: AgentListResponse | undefined}): Rea onClick={() => run('removeRoleAssignments', async () => { if (scratchRole) - await removeRoleAssignments.mutateAsync({ + await removeRoleAssignmentsCall.mutateAsync({ assignments: [{id: await me(), type: 'user'}], roleId: scratchRole.id, }); @@ -412,7 +481,7 @@ const ManagementHooks = ({agents}: {agents: AgentListResponse | undefined}): Rea onClick={() => run('deleteRole', async () => { if (!scratchRole) return; - await deleteRole.mutateAsync(scratchRole.id); + await deleteRoleCall.mutateAsync(scratchRole.id); setScratchRole(null); }) } @@ -431,4 +500,4 @@ const ManagementHooks = ({agents}: {agents: AgentListResponse | undefined}): Rea ); }; -export default ManagementHooks; +export default ManagementApi; diff --git a/playgrounds/react/src/pages/explorer/CustomRenderWidgets.tsx b/playgrounds/react/src/pages/explorer/CustomRenderWidgets.tsx index eadfb414..c84b16cc 100644 --- a/playgrounds/react/src/pages/explorer/CustomRenderWidgets.tsx +++ b/playgrounds/react/src/pages/explorer/CustomRenderWidgets.tsx @@ -9,8 +9,10 @@ import Card from '@mui/material/Card'; import CardActions from '@mui/material/CardActions'; import CardContent from '@mui/material/CardContent'; import CardHeader from '@mui/material/CardHeader'; +import MuiCheckbox from '@mui/material/Checkbox'; import Chip from '@mui/material/Chip'; import CssBaseline from '@mui/material/CssBaseline'; +import FormControlLabel from '@mui/material/FormControlLabel'; import MenuItem from '@mui/material/MenuItem'; import Stack from '@mui/material/Stack'; import {ThemeProvider, createTheme} from '@mui/material/styles'; @@ -22,6 +24,8 @@ import { type AdminSearchValue, type BaseAgentCreateRenderProps, type BaseAgentDetailsRenderProps, + type BaseApplicationCreateRenderProps, + type BaseApplicationDetailsRenderProps, type BaseGroupCreateRenderProps, type BaseRolePermissionsRenderProps, type BaseUserCreateRenderProps, @@ -406,6 +410,113 @@ export const AgentCard = (details: BaseAgentDetailsRenderProps): ReactElement => ); +/** A client secret shown once, with the button that confirms it was copied. */ +const SecretAlert = ({onDismiss = undefined, secret}: {onDismiss?: () => void; secret: string}): ReactElement => ( + + Done + + ) + } + > + Copy the client secret now: {secret} + +); + +/** The application form's render props drawn with MUI inputs and checkboxes. */ +export const ApplicationForm = (form: BaseApplicationCreateRenderProps): ReactElement => + form.clientSecret ? ( + + ) : ( + + form.handleInputChange('name', event.target.value)} + /> + form.handleInputChange('redirectUris', event.target.value)} + /> + + {form.grantTypeOptions.map((option: {label: string; value: string}) => ( + + form.handleGrantTypesChange( + event.target.checked + ? [...form.values.grantTypes, option.value] + : form.values.grantTypes.filter((value: string) => value !== option.value), + ) + } + /> + } + /> + ))} + + form.handlePublicClientChange(event.target.checked)} + /> + } + /> + + ); + +/** The application details' render props drawn as an MUI card. */ +export const ApplicationCard = (details: BaseApplicationDetailsRenderProps): ReactElement => ( + + {details.error && {details.error}} + {details.clientSecret && } + {details.application?.description && {details.application.description}} + Grant types + ({id: grant, label: grant}))} + /> + Redirect URIs + ({id: uri, label: uri}))} /> + {details.onRegenerateSecret && ( + + + + )} + +); + /** The role permissions' render props drawn as one MUI card per resource server. */ export const PermissionCards = ({ error, diff --git a/playgrounds/react/src/pages/explorer/adminSamples.ts b/playgrounds/react/src/pages/explorer/adminSamples.ts index 3e8db3f5..e602ea90 100644 --- a/playgrounds/react/src/pages/explorer/adminSamples.ts +++ b/playgrounds/react/src/pages/explorer/adminSamples.ts @@ -3,6 +3,8 @@ import type { Agent, + Application, + BasicApplication, BasicAgent, DataTableColumn, Group, @@ -72,6 +74,33 @@ export const SAMPLE_AGENTS: BasicAgent[] = [ export const SAMPLE_AGENT: Agent = {...SAMPLE_AGENTS[0], owner: 'usr-1'}; +export const SAMPLE_APPLICATIONS: BasicApplication[] = [ + {clientId: 'portal_spa', description: 'Customer portal', id: 'app-1', name: 'Customer portal', template: 'browser'}, + {clientId: 'billing_api', id: 'app-2', name: 'Billing service', template: 'm2m'}, + {clientId: 'field_app', id: 'app-3', name: 'Field app', template: 'mobile'}, + {clientId: 'ops_console', id: 'app-4', name: 'Ops console', template: 'fullstack'}, +]; + +export const SAMPLE_APPLICATION: Application = { + description: 'Customer portal', + id: 'app-1', + inboundAuthConfig: [ + { + config: { + clientId: 'portal_spa', + grantTypes: ['authorization_code', 'refresh_token'], + redirectUris: ['https://portal.example.com/callback'], + responseTypes: ['code'], + }, + type: 'oauth2', + }, + ], + name: 'Customer portal', + ouId: OU.ouId, + template: 'browser', + url: 'https://portal.example.com', +}; + export const SAMPLE_MEMBERSHIPS: MembershipGroup[] = SAMPLE_GROUPS.slice(0, 2).map(({id, name, ouId}: Group) => ({ id, name, diff --git a/playgrounds/react/src/pages/explorer/adminStories.tsx b/playgrounds/react/src/pages/explorer/adminStories.tsx index f1d71c46..2c0855b1 100644 --- a/playgrounds/react/src/pages/explorer/adminStories.tsx +++ b/playgrounds/react/src/pages/explorer/adminStories.tsx @@ -6,10 +6,19 @@ import { AgentCreate, AgentDetails, AgentList, + type Application, + ApplicationCreate, + type ApplicationCreateSubmission, + ApplicationDetails, + ApplicationList, + type ApplicationUpdateSubmission, Attribution, BaseAgentCreate, BaseAgentDetails, BaseAgentList, + BaseApplicationCreate, + BaseApplicationDetails, + BaseApplicationList, BaseGroupCreate, BaseGroupList, BaseRoleCreate, @@ -19,6 +28,7 @@ import { BaseUserDetails, BaseUserList, type BasicAgent, + type BasicApplication, Card, type Group, GroupCreate, @@ -39,6 +49,8 @@ import { RESOURCE_SERVER_NAMES, SAMPLE_AGENT, SAMPLE_AGENTS, + SAMPLE_APPLICATION, + SAMPLE_APPLICATIONS, SAMPLE_GROUPS, SAMPLE_MEMBERSHIPS, SAMPLE_PERMISSIONS, @@ -1028,6 +1040,215 @@ const STORIES: ComponentDef[] = [ {args: {source: 'live'}, id: 'live', name: 'Live (needs agentId)'}, ], }, + { + code: (args: Args) => listCode(args, 'ApplicationList', 'applications', 'selectedApplicationId'), + controls: LIST_CONTROLS, + defaults: LIST_DEFAULTS, + group: 'Admin: applications', + id: 'applicationlist', + live: true, + name: 'ApplicationList', + render: (args: Args, action: Action) => + isLive(args) ? ( + + + + ) : ( + + args={args} + action={action} + rows={SAMPLE_APPLICATIONS} + getId={(application: BasicApplication) => application.id} + render={({offset, onPageChange, onSelect, rows, selectedId, total}) => ( + + )} + /> + ), + stories: listStories('Live (GET /applications)'), + }, + { + code: (args: Args) => + isLive(args) + ? { + imports: ['ApplicationCreate'], + jsx: jsx('ApplicationCreate', pick(args, 'title', 'ouId'), {extra: ['onCreated={handleCreated}']}), + } + : { + imports: ['BaseApplicationCreate'], + jsx: jsx('BaseApplicationCreate', pick(args, 'title', 'isSubmitting', 'error'), { + extra: [ + 'onSubmit={handleSubmit}', + 'onCancel={close}', + ...(bool(args, 'showSecret') ? ['clientSecret={createdSecret}', 'onSecretDismiss={clearSecret}'] : []), + ], + }), + }, + controls: [ + SOURCE_CONTROL, + TITLE, + { + control: 'bool', + description: 'Base only: disables the form while saving.', + name: 'isSubmitting', + type: 'boolean', + }, + ERROR, + { + control: 'bool', + description: 'Base only: passes clientSecret, which shows the secret once in place of the form.', + name: 'showSecret', + type: 'string | null', + }, + OU_ID, + ], + defaults: {error: '', isSubmitting: false, ouId: '', showSecret: false, source: 'base', title: ''}, + group: 'Admin: applications', + id: 'applicationcreate', + live: true, + name: 'ApplicationCreate', + render: (args: Args, action: Action) => ( + + {isLive(args) ? ( + action('onCreated', application.id)} + onCancel={() => action('onCancel')} + /> + ) : ( + action('onSecretDismiss')} + onSubmit={(application: ApplicationCreateSubmission) => action('onSubmit', JSON.stringify(application))} + onCancel={() => action('onCancel')} + /> + )} + + ), + stories: [ + {id: 'base', name: 'Empty form'}, + {args: {showSecret: true}, id: 'secret', name: 'Client secret shown once'}, + {args: {error: 'An application with this name already exists.'}, id: 'error', name: 'Error'}, + {args: {source: 'live'}, id: 'live', name: 'Live (POST /applications)'}, + ], + }, + { + code: (args: Args) => + isLive(args) + ? { + imports: ['ApplicationDetails'], + jsx: jsx('ApplicationDetails', pick(args, 'applicationId', 'editable', 'deletable', 'secretRegenerable'), { + extra: ['onDeleted={backToList}'], + }), + } + : { + imports: ['BaseApplicationDetails'], + jsx: jsx('BaseApplicationDetails', pick(args, 'isLoading', 'error'), { + extra: [ + 'application={application}', + ...(bool(args, 'editable') ? ['onUpdate={saveApplication}'] : []), + ...(bool(args, 'deletable') ? ['onDelete={deleteApplication}'] : []), + ...(bool(args, 'secretRegenerable') + ? [ + 'onRegenerateSecret={regenerateSecret}', + 'clientSecret={newSecret}', + 'onSecretDismiss={clearSecret}', + ] + : []), + ], + }), + }, + controls: [ + SOURCE_CONTROL, + idControl('applicationId'), + {control: 'bool', description: 'Shows the edit button (onUpdate).', name: 'editable', type: 'boolean'}, + {control: 'bool', description: 'Shows the delete button (onDelete).', name: 'deletable', type: 'boolean'}, + { + control: 'bool', + description: 'Shows the regenerate client secret button (onRegenerateSecret).', + name: 'secretRegenerable', + type: 'boolean', + }, + { + control: 'bool', + description: 'Base only: passes clientSecret, a regenerated secret shown once.', + name: 'showSecret', + type: 'string | null', + }, + LOADING, + ERROR, + ], + defaults: { + applicationId: '', + deletable: true, + editable: true, + error: '', + isLoading: false, + secretRegenerable: true, + showSecret: false, + source: 'base', + }, + group: 'Admin: applications', + id: 'applicationdetails', + live: true, + name: 'ApplicationDetails', + render: (args: Args, action: Action) => { + if (isLive(args)) { + if (!str(args, 'applicationId')) return ; + + return ( + + action('onDeleted')} + /> + + ); + } + + return ( + + action('onSecretDismiss')} + onUpdate={ + bool(args, 'editable') + ? (changes: ApplicationUpdateSubmission) => action('onUpdate', JSON.stringify(changes)) + : undefined + } + onDelete={bool(args, 'deletable') ? () => action('onDelete', SAMPLE_APPLICATION.id) : undefined} + onRegenerateSecret={ + bool(args, 'secretRegenerable') ? () => action('onRegenerateSecret', SAMPLE_APPLICATION.id) : undefined + } + /> + + ); + }, + stories: [ + {id: 'base', name: 'Sample application'}, + {args: {deletable: false, editable: false, secretRegenerable: false}, id: 'readonly', name: 'Read only'}, + {args: {showSecret: true}, id: 'secret', name: 'Regenerated secret'}, + {args: {isLoading: true}, id: 'loading', name: 'Loading'}, + {args: {source: 'live'}, id: 'live', name: 'Live (needs applicationId)'}, + ], + }, ]; /** The admin stories, each with a "Custom (MUI)" story that renders it through its render props. */ diff --git a/playgrounds/react/src/pages/explorer/customRenderStories.tsx b/playgrounds/react/src/pages/explorer/customRenderStories.tsx index e60730a4..e701c81a 100644 --- a/playgrounds/react/src/pages/explorer/customRenderStories.tsx +++ b/playgrounds/react/src/pages/explorer/customRenderStories.tsx @@ -7,6 +7,9 @@ import { BaseAgentCreate, BaseAgentDetails, BaseAgentList, + BaseApplicationCreate, + BaseApplicationDetails, + BaseApplicationList, BaseGroupCreate, BaseGroupList, type BaseGroupMembersRenderProps, @@ -18,6 +21,7 @@ import { BaseUserDetails, BaseUserList, type BasicAgent, + type BasicApplication, type Group, type ManagedUser, type RoleSummary, @@ -27,6 +31,8 @@ import { RESOURCE_SERVER_NAMES, SAMPLE_AGENT, SAMPLE_AGENTS, + SAMPLE_APPLICATION, + SAMPLE_APPLICATIONS, SAMPLE_GROUPS, SAMPLE_MEMBERSHIPS, SAMPLE_PERMISSIONS, @@ -38,6 +44,8 @@ import {GroupMembersStory, Paged, RoleAssignmentsStory, SampleSearch} from './Ad import { AgentCard, AgentForm, + ApplicationCard, + ApplicationForm, IdentityGrid, ListGrid, Mui, @@ -153,6 +161,63 @@ const CODE: Record Snippet> = { otherImports: [MUI_CARD], }), agentlist: () => listSnippet('AgentList', 'agents'), + applicationcreate: () => ({ + imports: ['ApplicationCreate'], + jsx: ` navigate(application.id)}> + {({values, grantTypeOptions, handleInputChange, handleGrantTypesChange, handleSubmit, isValid, isSubmitting, clientSecret, onSecretDismiss}) => + clientSecret ? ( + Done}> + Copy the client secret now: {clientSecret} + + ) : ( + + handleInputChange('name', e.target.value)} /> + handleInputChange('redirectUris', e.target.value)} /> + {grantTypeOptions.map((option) => ( + + handleGrantTypesChange( + e.target.checked + ? [...values.grantTypes, option.value] + : values.grantTypes.filter((value) => value !== option.value), + ) + } + /> + } + /> + ))} + + + ) + } +`, + otherImports: ["import {Alert, Button, Checkbox, FormControlLabel, Stack, TextField} from '@mui/material';"], + }), + applicationdetails: () => ({ + imports: ['ApplicationDetails'], + jsx: ` + {({application, oauth, clientSecret, onSecretDismiss, onDelete, onRegenerateSecret}) => ( + + + + {clientSecret && } + {oauth?.redirectUris?.map((uri) => )} + + {onRegenerateSecret && } + {onDelete && } + + )} +`, + otherImports: [MUI_CARD], + }), + applicationlist: () => listSnippet('ApplicationList', 'applications'), groupcreate: () => formSnippet('GroupCreate', ['name', 'description']), grouplist: () => listSnippet('GroupList', 'groups'), groupmembers: () => identitySnippet('GroupMembers', 'groupId', 'members', 'onRemove'), @@ -334,6 +399,41 @@ const RENDER: Record ReactNode> = { valueOf: (agent: BasicAgent, attribute: string) => (attribute === 'name' ? agent.name : undefined), }, ), + applicationcreate: (_args: Args, action: Action) => ( + + action('onSubmit', JSON.stringify(application))} + onCancel={() => action('onCancel')} + > + {ApplicationForm} + + + ), + applicationdetails: (_args: Args, action: Action) => ( + + action('onDelete', SAMPLE_APPLICATION.id)} + onRegenerateSecret={() => action('onRegenerateSecret', SAMPLE_APPLICATION.id)} + > + {ApplicationCard} + + + ), + applicationlist: (args: Args, action: Action) => + pagedList(args, action, SAMPLE_APPLICATIONS, (state: PagedListState) => ( + + {state.grid} + + )), groupcreate: (_args: Args, action: Action) => ( { + if (!id) throw new Error(`No ${name} yet`); + + return id; +}; + +export default requireId; diff --git a/playgrounds/vue/src/lib/toError.ts b/playgrounds/vue/src/lib/toError.ts new file mode 100644 index 00000000..a962215e --- /dev/null +++ b/playgrounds/vue/src/lib/toError.ts @@ -0,0 +1,7 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +/** Normalizes a thrown value to an `Error`. */ +const toError = (caught: unknown): Error => (caught instanceof Error ? caught : new Error(String(caught))); + +export default toError; diff --git a/playgrounds/vue/src/lib/useApiMutation.ts b/playgrounds/vue/src/lib/useApiMutation.ts new file mode 100644 index 00000000..dfc3686e --- /dev/null +++ b/playgrounds/vue/src/lib/useApiMutation.ts @@ -0,0 +1,54 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {Ref, ref, shallowRef, ShallowRef} from 'vue'; +import toError from './toError'; + +export interface ApiMutation { + data: ShallowRef; + error: Ref; + isLoading: Ref; + /** Runs the request; rejects when it fails. */ + mutateAsync: (variables: V) => Promise; +} + +export interface ApiMutationOptions { + onError?: (error: Error) => void; + onSuccess?: (data: T) => unknown; +} + +/** Runs `request` on demand, such as a create or delete, and keeps the state of the last run. */ +const useApiMutation = ( + request: (variables: V) => Promise, + options: ApiMutationOptions = {}, +): ApiMutation => { + const data: ShallowRef = shallowRef(undefined); + const error: Ref = ref(null); + const isLoading: Ref = ref(false); + + const mutateAsync = async (variables: V): Promise => { + isLoading.value = true; + error.value = null; + + try { + const result: T = await request(variables); + + data.value = result; + await options.onSuccess?.(result); + + return result; + } catch (caught) { + const failure: Error = toError(caught); + + error.value = failure; + options.onError?.(failure); + throw failure; + } finally { + isLoading.value = false; + } + }; + + return {data, error, isLoading, mutateAsync}; +}; + +export default useApiMutation; diff --git a/playgrounds/vue/src/lib/useApiQuery.ts b/playgrounds/vue/src/lib/useApiQuery.ts new file mode 100644 index 00000000..092c5a78 --- /dev/null +++ b/playgrounds/vue/src/lib/useApiQuery.ts @@ -0,0 +1,69 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {Ref, ref, shallowRef, ShallowRef, watch} from 'vue'; +import toError from './toError'; + +// The playground's own data layer for the SDK's management API functions. An app would use TanStack +// Query, Pinia Colada, or its own store; this is the smallest stand-in that gives the cards a +// {data, error, isLoading, refetch} result for a call such as +// `getGroups({...useManagementRequestConfig('groups'), limit: 5})`. + +export interface ApiQuery { + data: ShallowRef; + error: Ref; + isLoading: Ref; + /** Runs the request now, also while disabled. Resolves with the data, or undefined when it failed. */ + refetch: () => Promise; +} + +export interface ApiQueryOptions { + /** Runs the request when it turns true, and again whenever `key` changes. Defaults to always. */ + enabled?: () => boolean; + /** What the request is for, such as an ID read from another query. */ + key?: () => unknown; +} + +/** Runs `request` and keeps its result. Only the response to the latest run is kept. */ +const useApiQuery = (request: () => Promise, options: ApiQueryOptions = {}): ApiQuery => { + const data: ShallowRef = shallowRef(undefined); + const error: Ref = ref(null); + const isLoading: Ref = ref(false); + let runs = 0; + + const refetch = async (): Promise => { + runs += 1; + const run: number = runs; + + isLoading.value = true; + + try { + const result: T = await request(); + + if (run === runs) { + data.value = result; + error.value = null; + } + + return result; + } catch (caught) { + if (run === runs) error.value = toError(caught); + + return undefined; + } finally { + if (run === runs) isLoading.value = false; + } + }; + + watch( + () => [options.enabled?.() ?? true, options.key?.()], + ([enabled]: unknown[]) => { + if (enabled) refetch().catch(() => undefined); + }, + {immediate: true}, + ); + + return {data, error, isLoading, refetch}; +}; + +export default useApiQuery; diff --git a/playgrounds/vue/src/pages/Composables.vue b/playgrounds/vue/src/pages/Composables.vue index 3397cd16..4694a367 100644 --- a/playgrounds/vue/src/pages/Composables.vue +++ b/playgrounds/vue/src/pages/Composables.vue @@ -2,71 +2,68 @@ // Copyright 2026 The ThunderID Authors // SPDX-License-Identifier: Apache-2.0 -import type {EmbeddedFlowComponent, FetchSelectPage, SelectPage} from '@thunderid/browser'; +import {getUserTypes, type EmbeddedFlowComponent, type FetchSelectPage, type SelectPage} from '@thunderid/browser'; import { - ApiFetcher, + createAgent, + createApplication, + createGroup, + createRole, + createUser, + deleteAgent, + deleteApplication, + deleteGroup, + deleteRole, + deleteUser, + getAgent, + getAgentGroups, + getAgentRoles, + getAgents, + getAgentTypes, + getApplication, + getApplications, + getGroup, + getGroupMembers, getGroups, + getRole, + getRoleAssignments, + getRoles, + getUser, + getUserGroups, + getUsers, + addGroupMembers, + addRoleAssignments, GroupListResponse, - GroupQueryKeys, ManagementEndpoint, ManagementEndpointKey, - ResourceQueryOptions, - ResourceQueryResult, - useAddGroupMembers, - useAddRoleAssignments, - useCreateAgent, - useCreateApplication, - useCreateGroup, - useCreateRole, - useCreateUser, - useDeleteAgent, - useDeleteApplication, - useDeleteGroup, - useDeleteRole, - useDeleteUser, + ManagementRequestConfig, + removeGroupMembers, + removeRoleAssignments, + updateAgent, + updateApplication, + updateGroup, + updateRole, + updateUser, useFetchAuthFlows, useFetchUsers, useFetchUserTypes, useFlow, useFlowMeta, - useGetAgent, - useGetAgentGroups, - useGetAgentRoles, - useGetAgents, - useGetAgentTypes, - useGetApplication, - useGetApplications, - useGetGroup, - useGetGroupMembers, - useGetGroups, - useGetRole, - useGetRoleAssignments, - useGetRoles, - useGetUser, - useGetUserGroups, - useGetUsers, - useGetUserTypes, useGrantedPermissions, useI18n, useManagementEndpoint, - useRemoveGroupMembers, - useRemoveRoleAssignments, - useResourceMutation, - useResourceQuery, + useManagementRequestConfig, useTheme, useThunderID, - useUpdateAgent, - useUpdateApplication, - useUpdateGroup, - useUpdateRole, - useUpdateUser, useUser, } from '@thunderid/vue'; import {computed, ComputedRef, Ref, ref, shallowRef, ShallowRef} from 'vue'; import ComposableCard from '../components/ComposableCard.vue'; import EventLog from '../components/EventLog.vue'; import {describeError, track} from '../lib/events'; +import requireId from '../lib/requireId'; import {asText, toUser} from '../lib/sdk'; +import useApiMutation, {type ApiMutation} from '../lib/useApiMutation'; +import useApiQuery, {type ApiQuery, type ApiQueryOptions} from '../lib/useApiQuery'; interface FetcherState { data: SelectPage | null; @@ -137,31 +134,46 @@ const i18nValue: ComputedRef> = computed(() => ({ }, })); -// ── Management composables ── -// Queries wait for a session (`enabled`), so a signed-out page makes no requests. Refetch runs one -// anyway, which shows the error a signed-out or unauthorized call gets. +// ── Management API functions ── +// Each query calls an SDK management API function with the config from useManagementRequestConfig(), +// through the playground's own data layer (lib/useApiQuery.ts and lib/useApiMutation.ts). Queries wait for a session +// (`enabled`), so a signed-out page makes no requests. Refetch runs one anyway, which shows the error a +// signed-out or unauthorized call gets. interface QueryEntry { name: string; - query: ResourceQueryResult; + query: ApiQuery; signature: string; } -interface MutationLike { - error: Readonly>; - isLoading: Readonly>; -} +/** + * The collections this page exercises. + */ +type PlaygroundCollection = Extract< + ManagementEndpointKey, + 'agentTypes' | 'agents' | 'applications' | 'groups' | 'roles' | 'userTypes' | 'users' +>; + +const config: Record = { + agentTypes: useManagementRequestConfig('agentTypes'), + agents: useManagementRequestConfig('agents'), + applications: useManagementRequestConfig('applications'), + groups: useManagementRequestConfig('groups'), + roles: useManagementRequestConfig('roles'), + userTypes: useManagementRequestConfig('userTypes'), + users: useManagementRequestConfig('users'), +}; -const signedIn: ResourceQueryOptions = {enabled: () => isSignedIn.value}; +const signedIn: ApiQueryOptions = {enabled: () => isSignedIn.value}; const page = {limit: 5}; -const groups = useGetGroups(page, signedIn); -const roles = useGetRoles(page, signedIn); -const agents = useGetAgents(page, signedIn); -const users = useGetUsers(page, signedIn); -const applications = useGetApplications(page, signedIn); +const groups = useApiQuery(() => getGroups({...config.groups, ...page}), signedIn); +const roles = useApiQuery(() => getRoles({...config.roles, ...page}), signedIn); +const agents = useApiQuery(() => getAgents({...config.agents, ...page}), signedIn); +const users = useApiQuery(() => getUsers({...config.users, ...page}), signedIn); +const applications = useApiQuery(() => getApplications({...config.applications, ...page}), signedIn); -// The single-resource and membership composables follow the first row of each list. +// The single-resource and membership calls follow the first row of each list. const firstGroupId = (): string | undefined => groups.data.value?.groups[0]?.id; const firstRoleId = (): string | undefined => roles.data.value?.roles[0]?.id; const firstAgentId = (): string | undefined => agents.data.value?.agents[0]?.id; @@ -170,141 +182,192 @@ const firstApplicationId = (): string | undefined => applications.data.value?.ap // ThunderID issues the user's ID as the `sub` claim. const signedInUserId = (): string | undefined => asText(toUser(sdk.user.value)?.['sub']) || undefined; -const groupsEndpoint: ManagementEndpoint = useManagementEndpoint('groups'); -// A custom query on the same primitives the management composables use. -const groupCount = useResourceQuery( - () => ['playground', 'group-count'], - (fetcher: ApiFetcher) => - getGroups({...groupsEndpoint, fetcher, limit: 1}).then((result: GroupListResponse) => result.totalResults), +/** Waits for a session and for the ID, and runs again when the ID changes. */ +const follows = (id: () => string | undefined): ApiQueryOptions => ({ + enabled: () => isSignedIn.value && !!id(), + key: id, +}); + +// A derived value on the same data layer: the group count, read from a one-row page. +const groupCount = useApiQuery( + () => getGroups({...config.groups, limit: 1}).then((result: GroupListResponse) => result.totalResults), signedIn, ); const QUERIES: QueryEntry[] = [ { - name: 'useGetUsers()', + name: 'getUsers()', query: users, - signature: '(params?: {limit, offset, filter}, options?) => ResourceQueryResult', + signature: "({...useManagementRequestConfig('users'), limit, offset, filter}) => Promise", + }, + { + name: 'getUser(firstUserId)', + query: useApiQuery( + () => getUser({...config.users, userId: requireId(firstUserId(), 'user')}), + follows(firstUserId), + ), + signature: '({...config, userId}) => Promise', + }, + { + name: 'getUserGroups(signedInUserId)', + query: useApiQuery( + () => getUserGroups({...config.users, ...page, userId: requireId(signedInUserId(), 'signed-in user')}), + follows(signedInUserId), + ), + signature: '({...config, userId, limit, offset}) => Promise', + }, + { + name: 'getUserTypes()', + query: useApiQuery(() => getUserTypes({...config.userTypes, ...page}), signedIn), + signature: "({...useManagementRequestConfig('userTypes'), limit, offset}) => Promise", + }, + { + name: 'getGroups()', + query: groups, + signature: "({...useManagementRequestConfig('groups'), limit, offset}) => Promise", + }, + { + name: 'getGroup(firstGroupId)', + query: useApiQuery( + () => getGroup({...config.groups, groupId: requireId(firstGroupId(), 'group')}), + follows(firstGroupId), + ), + signature: '({...config, groupId}) => Promise', + }, + { + name: 'getGroupMembers(firstGroupId)', + query: useApiQuery( + () => getGroupMembers({...config.groups, ...page, groupId: requireId(firstGroupId(), 'group')}), + follows(firstGroupId), + ), + signature: '({...config, groupId, limit, offset}) => Promise', }, { - name: 'useGetUser(firstUserId)', - query: useGetUser(firstUserId, signedIn), - signature: '(userId) => ResourceQueryResult', + name: 'getRoles()', + query: roles, + signature: "({...useManagementRequestConfig('roles'), limit, offset}) => Promise", }, { - name: 'useGetUserGroups(signedInUserId)', - query: useGetUserGroups(signedInUserId, page, signedIn), - signature: '(userId, params?) => ResourceQueryResult', + name: 'getRole(firstRoleId)', + query: useApiQuery( + () => getRole({...config.roles, roleId: requireId(firstRoleId(), 'role')}), + follows(firstRoleId), + ), + signature: '({...config, roleId}) => Promise', }, - {name: 'useGetUserTypes()', query: useGetUserTypes(page, signedIn), signature: '(params?) => ResourceQueryResult'}, - {name: 'useGetGroups()', query: groups, signature: '(params?: {limit, offset}) => ResourceQueryResult'}, { - name: 'useGetGroup(firstGroupId)', - query: useGetGroup(firstGroupId, signedIn), - signature: '(groupId) => ResourceQueryResult', + name: 'getRoleAssignments(firstRoleId)', + query: useApiQuery( + () => getRoleAssignments({...config.roles, ...page, roleId: requireId(firstRoleId(), 'role')}), + follows(firstRoleId), + ), + signature: '({...config, roleId, limit, offset, type}) => Promise', }, { - name: 'useGetGroupMembers(firstGroupId)', - query: useGetGroupMembers(firstGroupId, page, signedIn), - signature: '(groupId, params?) => ResourceQueryResult', + name: 'getAgents()', + query: agents, + signature: "({...useManagementRequestConfig('agents'), limit, offset}) => Promise", }, - {name: 'useGetRoles()', query: roles, signature: '(params?: {limit, offset}) => ResourceQueryResult'}, { - name: 'useGetRole(firstRoleId)', - query: useGetRole(firstRoleId, signedIn), - signature: '(roleId) => ResourceQueryResult', + name: 'getAgent(firstAgentId)', + query: useApiQuery( + () => getAgent({...config.agents, agentId: requireId(firstAgentId(), 'agent')}), + follows(firstAgentId), + ), + signature: '({...config, agentId}) => Promise', }, { - name: 'useGetRoleAssignments(firstRoleId)', - query: useGetRoleAssignments(firstRoleId, page, signedIn), - signature: '(roleId, params?: {limit, offset, type}) => ResourceQueryResult', + name: 'getAgentGroups(firstAgentId)', + query: useApiQuery( + () => getAgentGroups({...config.agents, ...page, agentId: requireId(firstAgentId(), 'agent')}), + follows(firstAgentId), + ), + signature: '({...config, agentId, limit, offset}) => Promise', }, - {name: 'useGetAgents()', query: agents, signature: '(params?: {limit, offset}) => ResourceQueryResult'}, { - name: 'useGetAgent(firstAgentId)', - query: useGetAgent(firstAgentId, signedIn), - signature: '(agentId) => ResourceQueryResult', + name: 'getAgentRoles(firstAgentId)', + query: useApiQuery( + () => getAgentRoles({...config.agents, ...page, agentId: requireId(firstAgentId(), 'agent')}), + follows(firstAgentId), + ), + signature: '({...config, agentId, limit, offset}) => Promise', }, { - name: 'useGetAgentGroups(firstAgentId)', - query: useGetAgentGroups(firstAgentId, page, signedIn), - signature: '(agentId, params?) => ResourceQueryResult', + name: 'getAgentTypes()', + query: useApiQuery(() => getAgentTypes({...config.agentTypes, ...page}), signedIn), + signature: "({...useManagementRequestConfig('agentTypes'), limit, offset}) => Promise", }, { - name: 'useGetAgentRoles(firstAgentId)', - query: useGetAgentRoles(firstAgentId, page, signedIn), - signature: '(agentId, params?) => ResourceQueryResult', + name: 'getApplications()', + query: applications, + signature: "({...useManagementRequestConfig('applications'), limit, offset}) => Promise", }, - {name: 'useGetAgentTypes()', query: useGetAgentTypes(page, signedIn), signature: '(params?) => ResourceQueryResult'}, - {name: 'useGetApplications()', query: applications, signature: '(params?) => ResourceQueryResult'}, { - name: 'useGetApplication(firstApplicationId)', - query: useGetApplication(firstApplicationId, signedIn), - signature: '(applicationId) => ResourceQueryResult', + name: 'getApplication(firstApplicationId)', + query: useApiQuery( + () => getApplication({...config.applications, applicationId: requireId(firstApplicationId(), 'application')}), + follows(firstApplicationId), + ), + signature: '({...config, applicationId}) => Promise', }, { - name: "useResourceQuery(['playground', 'group-count'])", + name: 'getGroups({limit: 1}).totalResults', query: groupCount, - signature: '(queryKey, queryFn: (fetcher) => Promise, options?) => ResourceQueryResult', + signature: 'A derived value: the group count, read from a one-row page', }, ]; -const queryValue = (query: ResourceQueryResult): Record => ({ +const queryValue = (query: ApiQuery): Record => ({ data: query.data.value, error: query.error.value?.message ?? null, isLoading: query.isLoading.value, refetch: query.refetch, }); -const refetch = (name: string, query: ResourceQueryResult): void => +const refetch = (name: string, query: ApiQuery): void => call(name, async () => { await query.refetch(); // refetch() resolves undefined on failure instead of rejecting; surface the error. if (query.error.value) throw query.error.value; }); -// Mutations write data, so the page only shows their state; the admin components in the explorer -// drive them. -const MUTATIONS: [string, MutationLike][] = [ - ['useCreateUser', useCreateUser()], - ['useUpdateUser', useUpdateUser()], - ['useDeleteUser', useDeleteUser()], - ['useCreateGroup', useCreateGroup()], - ['useUpdateGroup', useUpdateGroup()], - ['useDeleteGroup', useDeleteGroup()], - ['useAddGroupMembers', useAddGroupMembers()], - ['useRemoveGroupMembers', useRemoveGroupMembers()], - ['useCreateRole', useCreateRole()], - ['useUpdateRole', useUpdateRole()], - ['useDeleteRole', useDeleteRole()], - ['useAddRoleAssignments', useAddRoleAssignments()], - ['useRemoveRoleAssignments', useRemoveRoleAssignments()], - ['useCreateAgent', useCreateAgent()], - ['useUpdateAgent', useUpdateAgent()], - ['useDeleteAgent', useDeleteAgent()], - ['useCreateApplication', useCreateApplication()], - ['useUpdateApplication', useUpdateApplication()], - ['useDeleteApplication', useDeleteApplication()], +// The write functions, by the collection whose config they take. They write data, so the page lists +// them without running them; the admin components in the explorer drive them. +type WriteFunction = (...args: never[]) => Promise; + +const MUTATIONS: [PlaygroundCollection, Record][] = [ + ['users', {createUser, deleteUser, updateUser}], + ['groups', {addGroupMembers, createGroup, deleteGroup, removeGroupMembers, updateGroup}], + ['roles', {addRoleAssignments, createRole, deleteRole, removeRoleAssignments, updateRole}], + ['agents', {createAgent, deleteAgent, updateAgent}], + ['applications', {createApplication, deleteApplication, updateApplication}], ]; -// A custom mutation on the shared primitive. It writes nothing: it resolves locally and invalidates -// the group queries, so pressing it refetches every mounted group list above. -const touchGroups = useResourceMutation( - (label: string) => Promise.resolve(label), - () => [[GroupQueryKeys.GROUPS], [GroupQueryKeys.GROUP_MEMBERS]], -); +// A mutation on the same data layer. It writes nothing: it resolves locally and then refetches the +// group queries, which is how an app keeps its own lists fresh after a write. +const touchGroups: ApiMutation = useApiMutation((label: string) => Promise.resolve(label), { + onSuccess: () => + Promise.all( + QUERIES.filter((entry: QueryEntry) => entry.name.includes('Group')).map((entry: QueryEntry) => + entry.query.refetch(), + ), + ), +}); -const mutationsValue: ComputedRef> = computed(() => - Object.fromEntries( - [...MUTATIONS, ['useResourceMutation', touchGroups] as [string, MutationLike]].map( - ([name, mutation]: [string, MutationLike]) => [ - name, - mutation.error.value ? `error: ${mutation.error.value.message}` : mutation.isLoading.value ? 'running' : 'idle', - ], +const mutationsValue: ComputedRef> = computed(() => ({ + ...Object.fromEntries( + MUTATIONS.flatMap(([key, writes]: [PlaygroundCollection, Record]) => + Object.keys(writes).map((name: string) => [`${name}()`, `({...useManagementRequestConfig('${key}'), ...})`]), ), ), -); + refetchGroups: touchGroups.error.value + ? `error: ${touchGroups.error.value.message}` + : touchGroups.isLoading.value + ? 'running' + : 'idle', +})); -const ENDPOINT_KEYS: ManagementEndpointKey[] = [ +const ENDPOINT_KEYS: PlaygroundCollection[] = [ 'applications', 'users', 'userTypes', @@ -315,7 +378,11 @@ const ENDPOINT_KEYS: ManagementEndpointKey[] = [ ]; const endpointsValue: Record = Object.fromEntries( - ENDPOINT_KEYS.map((key: ManagementEndpointKey) => [key, useManagementEndpoint(key)]), + ENDPOINT_KEYS.map((key: PlaygroundCollection) => [key, useManagementEndpoint(key)]), +); + +const requestConfigValue: Record = Object.fromEntries( + ENDPOINT_KEYS.map((key: PlaygroundCollection) => [key, config[key]]), ); const granted = useGrantedPermissions(); @@ -346,8 +413,9 @@ const themeValue: ComputedRef> = computed(() => {

Composables

- Live values from every composable @thunderid/vue exposes. Refs are unwrapped, - functions are shown as signatures; use the buttons to call them. + Live values from every composable @thunderid/vue exposes, then the management + API functions called with useManagementRequestConfig. Refs are unwrapped, functions are shown as + signatures; use the buttons to call them.

@@ -470,11 +538,13 @@ const themeValue: ComputedRef> = computed(() => {
-

Management

+

Management API functions

- The management composables, useGrantedPermissions, and - useManagementEndpoint. Queries wait for a session and follow the first row of - each list; Refetch runs one anyway and shows its error. + The SDK's management API functions (getGroups(), createGroup(), ...) called with the + config from useManagementRequestConfig(collection), through the playground's + own small data layer (src/lib/useApiQuery.ts and src/lib/useApiMutation.ts), plus + useGrantedPermissions and useManagementEndpoint. + Queries wait for a session and follow the first row of each list; Refetch runs one anyway and shows its error.

@@ -494,6 +564,12 @@ const themeValue: ComputedRef> = computed(() => { :value="endpointsValue" /> + + > = computed(() => {

- Mutations write data, so they run from the admin components in Components. The custom - useResourceMutation writes nothing and invalidates the group queries. + Writes change data, so they run from the admin components in Components. Refetch groups runs a local mutation + that writes nothing and then refetches the group queries, the way an app refreshes its own data after a write.

diff --git a/playgrounds/vue/src/pages/capabilities.ts b/playgrounds/vue/src/pages/capabilities.ts index 4bf431c8..b84931dd 100644 --- a/playgrounds/vue/src/pages/capabilities.ts +++ b/playgrounds/vue/src/pages/capabilities.ts @@ -785,7 +785,7 @@ export const CAPABILITIES: Capability[] = [ section: 'Management', }, { - api: 'getUserTypes() · useGetUserTypes()', + api: 'getUserTypes()', id: 'userTypes', name: 'User types', run: async (context: CapabilityContext) => { diff --git a/playgrounds/vue/src/pages/explorer/FirstResource.ts b/playgrounds/vue/src/pages/explorer/FirstResource.ts index 0b67cce0..be3d92d7 100644 --- a/playgrounds/vue/src/pages/explorer/FirstResource.ts +++ b/playgrounds/vue/src/pages/explorer/FirstResource.ts @@ -1,15 +1,16 @@ // Copyright 2026 The ThunderID Authors // SPDX-License-Identifier: Apache-2.0 -import type {ResourceQueryResult} from '@thunderid/vue'; import {Component, defineComponent, h, PropType, VNode} from 'vue'; import {describeError} from '../../lib/events'; +import type {ApiQuery} from '../../lib/useApiQuery'; const notice = (message: string): VNode => h('div', {class: 'pg-warning', role: 'status'}, message); /** - * Runs a live story against the first resource a list composable returns, so the details stories work - * without typing an ID. An ID set in Controls wins. + * Runs a live story against the first resource a list API function returns (called with + * useManagementRequestConfig), so the details stories work without typing an ID. An ID set in Controls + * wins. */ const FirstResource: Component = defineComponent({ name: 'FirstResource', @@ -17,17 +18,17 @@ const FirstResource: Component = defineComponent({ explicit: {default: '', type: String}, noun: {required: true, type: String}, pick: {required: true, type: Function as PropType<(data: unknown) => string | undefined>}, - query: {required: true, type: Function as PropType<() => ResourceQueryResult>}, + query: {required: true, type: Function as PropType<() => ApiQuery>}, render: {required: true, type: Function as PropType<(id: string) => VNode>}, }, setup(props: { explicit: string; noun: string; pick: (data: unknown) => string | undefined; - query: () => ResourceQueryResult; + query: () => ApiQuery; render: (id: string) => VNode; }): () => VNode { - const page: ResourceQueryResult = props.query(); + const page: ApiQuery = props.query(); return (): VNode => { if (props.explicit) return props.render(props.explicit); diff --git a/playgrounds/vue/src/pages/explorer/PrimeRenderers.ts b/playgrounds/vue/src/pages/explorer/PrimeRenderers.ts index 2f4975ba..29f29962 100644 --- a/playgrounds/vue/src/pages/explorer/PrimeRenderers.ts +++ b/playgrounds/vue/src/pages/explorer/PrimeRenderers.ts @@ -4,6 +4,8 @@ import type {ManagedUser, MembershipGroup} from '@thunderid/browser'; import { type AdminListRenderProps, + type BaseApplicationCreateRenderProps, + type BaseApplicationDetailsRenderProps, type BaseGroupCreateRenderProps, type BaseUserDetailsRenderProps, type DataTableColumn, @@ -12,11 +14,13 @@ import { import Avatar from 'primevue/avatar'; import Button from 'primevue/button'; import Card from 'primevue/card'; +import Checkbox from 'primevue/checkbox'; 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 Password from 'primevue/password'; import Textarea from 'primevue/textarea'; import {Component, defineComponent, h, onBeforeUnmount, VNode, VNodeChild, watchEffect} from 'vue'; @@ -162,3 +166,137 @@ export const userCard = (state: BaseUserDetailsRenderProps): VNode => ]), }, ); + +/** A secret the SDK hands over once, drawn as a PrimeVue warning with a masked, revealable field. */ +const secretNotice = (secret: string, onDismiss?: () => void): VNode => + h('div', {class: 'pg-prime-stack'}, [ + h(Message, {severity: 'warn'}, {default: () => 'Copy the client secret now. It will not be shown again.'}), + h(Password, {feedback: false, modelValue: secret, readonly: true, toggleMask: true}), + onDismiss ? h(Button, {label: 'Done', onClick: onDismiss}) : null, + ]); + +/** The application form's default scoped slot drawn with PrimeVue inputs and checkboxes. */ +export const applicationForm = (state: BaseApplicationCreateRenderProps): VNode => + h( + Card, + {}, + { + content: () => + state.clientSecret + ? secretNotice(state.clientSecret, state.onSecretDismiss) + : 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', 'Redirect URIs (comma separated)'), + h(InputText, { + modelValue: state.values.redirectUris, + 'onUpdate:modelValue': (value: string | undefined) => + state.handleInputChange('redirectUris', value ?? ''), + }), + ]), + h( + 'div', + {class: 'pg-prime-chips'}, + state.grantTypeOptions.map((option: {label: string; value: string}) => + h('label', {class: 'pg-prime-title', key: option.value}, [ + h(Checkbox, { + binary: true, + modelValue: state.values.grantTypes.includes(option.value), + 'onUpdate:modelValue': (checked: boolean) => + state.handleGrantTypesChange( + checked + ? [...state.values.grantTypes, option.value] + : state.values.grantTypes.filter((value: string) => value !== option.value), + ), + }), + option.label, + ]), + ), + ), + h('label', {class: 'pg-prime-title'}, [ + h(Checkbox, { + binary: true, + modelValue: state.values.publicClient, + 'onUpdate:modelValue': (checked: boolean) => state.handlePublicClientChange(checked), + }), + 'Public client', + ]), + ]), + footer: () => + state.clientSecret + ? null + : 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 application', + loading: state.isSubmitting, + onClick: state.handleSubmit, + }), + ]), + subtitle: () => "Rendered with PrimeVue from the SDK's default scoped slot", + title: () => 'New application', + }, + ); + +/** + * The application details' default scoped slot drawn as a PrimeVue card. The slot hands over only the + * actions the app allowed; the SDK leaves the confirmation of a destructive action to the custom UI. + */ +export const applicationCard = (state: BaseApplicationDetailsRenderProps): VNode => + h( + Card, + {}, + { + content: () => + h('div', {class: 'pg-prime-stack'}, [ + state.error ? h(Message, {severity: 'error'}, {default: () => state.error}) : null, + state.clientSecret ? secretNotice(state.clientSecret, state.onSecretDismiss) : null, + h( + 'div', + {class: 'pg-prime-chips'}, + (state.oauth?.grantTypes ?? []).map((grantType: string) => + h(Chip, {class: 'pg-prime-group', key: grantType, label: grantType}), + ), + ), + h( + 'div', + {class: 'pg-prime-chips'}, + (state.oauth?.redirectUris ?? []).map((uri: string) => h(Chip, {key: uri, label: uri})), + ), + ]), + footer: () => + h('div', {class: 'pg-prime-actions'}, [ + state.onRegenerateSecret + ? h(Button, { + label: 'Regenerate secret', + loading: state.isRegeneratingSecret, + onClick: state.onRegenerateSecret, + severity: 'secondary', + text: true, + }) + : null, + state.onDelete + ? h(Button, { + label: 'Delete', + loading: state.isDeleting, + onClick: state.onDelete, + severity: 'danger', + text: true, + }) + : null, + ]), + subtitle: () => + state.oauth?.clientId ? `Client ID ${state.oauth.clientId}` : (state.application?.template ?? ''), + title: () => (state.isLoading && !state.application ? 'Loading…' : (state.application?.name ?? '')), + }, + ); diff --git a/playgrounds/vue/src/pages/explorer/adminStories.ts b/playgrounds/vue/src/pages/explorer/adminStories.ts index dac2168e..cecac645 100644 --- a/playgrounds/vue/src/pages/explorer/adminStories.ts +++ b/playgrounds/vue/src/pages/explorer/adminStories.ts @@ -6,10 +6,17 @@ import { AgentCreate, AgentDetails, AgentList, + Application, + ApplicationCreate, + ApplicationDetails, + ApplicationList, Attribution, BaseAgentCreate, BaseAgentDetails, BaseAgentList, + BaseApplicationCreate, + BaseApplicationDetails, + BaseApplicationList, BaseGroupCreate, BaseGroupList, BaseGroupMembers, @@ -21,6 +28,7 @@ import { BaseUserDetails, BaseUserList, BasicAgent, + BasicApplication, Card, Group, GroupCreate, @@ -39,10 +47,12 @@ import { RoleResourcePermissions, RoleSummary, Typography, - useGetAgents, - useGetGroups, - useGetRoles, - useGetUsers, + getAgents, + getApplications, + getGroups, + getRoles, + getUsers, + useManagementRequestConfig, UserCreate, UserDetails, UserList, @@ -54,6 +64,7 @@ import ListHost from './ListHost'; import {bool, handler, num, omit, quoted, SOURCE_CONTROL, str, tpl} from './storyKit'; import type {Action, Args, ComponentDef, PropControl, Snippet} from './types'; import {narrow} from './widgets'; +import useApiQuery from '../../lib/useApiQuery'; // Stories for the management (admin) components, the DataTable primitive they share, and // Attribution. Base stories render sample data; live stories call the management API with the @@ -115,6 +126,35 @@ const SAMPLE_AGENT: Agent = { owner: 'usr_8f2c41', }; +const SAMPLE_APPLICATIONS: BasicApplication[] = [ + {clientId: 'portal-web', id: 'app_portal', name: 'Customer portal', template: 'browser'}, + {clientId: 'billing-api', id: 'app_billing', name: 'Billing service', template: 'm2m'}, + {clientId: 'acme-mobile', id: 'app_mobile', name: 'Acme mobile', template: 'mobile'}, +]; + +const SAMPLE_APPLICATION: Application = { + description: 'Self-service portal for customers', + id: 'app_portal', + inboundAuthConfig: [ + { + config: { + clientId: 'portal-web', + grantTypes: ['authorization_code', 'refresh_token'], + redirectUris: ['https://portal.acme.io/callback'], + responseTypes: ['code'], + }, + type: 'oauth2', + }, + ], + name: 'Customer portal', + ouId: OU, + template: 'browser', + url: 'https://portal.acme.io', +}; + +/** A secret shown in the "secret shown once" stories. Sample only, never a real credential. */ +const SAMPLE_SECRET = 'sample-secret-5f1c9e2a'; + const SAMPLE_MEMBERSHIPS: MembershipGroup[] = SAMPLE_GROUPS.slice(0, 2).map(({id, name, ouId}: Group) => ({ id, name, @@ -197,8 +237,12 @@ interface ListSpec { live: Component; liveTag: string; name: string; - /** Composable whose page the Base snippet renders, for the Code panel. */ - pageComposable: string; + /** Collection whose request config the Base snippet takes, for the Code panel. */ + collection: string; + /** API function whose page the Base snippet renders, for the Code panel. */ + pageFunction: string; + /** Type of the page the API function returns. */ + pageType: string; rows: {id: string}[]; rowsKey: string; rowsProp: string; @@ -228,11 +272,31 @@ const listStory = (spec: ListSpec): ComponentDef => { } return { - imports: [spec.baseTag, spec.pageComposable], + imports: [spec.baseTag, spec.pageFunction, spec.pageType, 'useManagementRequestConfig'], script: [ + `const config = useManagementRequestConfig('${spec.collection}');`, 'const offset = ref(0);', ...(bool(args, 'selectable') ? ['const selectedId = ref();'] : []), - `const {data, error, isLoading} = ${spec.pageComposable}(() => ({limit: ${num(args, 'limit')}, offset: offset.value}));`, + `const data = ref<${spec.pageType}>();`, + 'const error = ref(null);', + 'const isLoading = ref(false);', + '', + '// Any data layer works here, such as TanStack Query; this one is a plain watch.', + 'watch(', + ' offset,', + ' async (next: number) => {', + ' isLoading.value = true;', + ' try {', + ` data.value = await ${spec.pageFunction}({...config, limit: ${num(args, 'limit')}, offset: next});`, + ' error.value = null;', + ' } catch (caught) {', + ' error.value = caught as Error;', + ' } finally {', + ' isLoading.value = false;', + ' }', + ' },', + ' {immediate: true},', + ');', ], template: tpl(spec.baseTag, omit(shared, 'limit'), { extra: [ @@ -250,7 +314,7 @@ const listStory = (spec: ListSpec): ComponentDef => { ...extra, ], }), - vueImports: ['ref'], + vueImports: ['ref', 'watch'], }; }, controls: [ @@ -540,7 +604,9 @@ export const ADMIN_COMPONENTS: ComponentDef[] = [ live: UserList, liveTag: 'UserList', name: 'UserList', - pageComposable: 'useGetUsers', + collection: 'users', + pageFunction: 'getUsers', + pageType: 'ManagedUserListResponse', rows: SAMPLE_USERS, rowsKey: 'users', rowsProp: 'users', @@ -568,11 +634,28 @@ export const ADMIN_COMPONENTS: ComponentDef[] = [ }), } : { - imports: ['BaseUserDetails', 'useGetUser', 'useGetUserGroups'], + imports: [ + 'BaseUserDetails', + 'getUser', + 'getUserGroups', + 'ManagedUser', + 'MembershipGroupListResponse', + 'useManagementRequestConfig', + ], script: [ + "const config = useManagementRequestConfig('users');", `const userId = '${SAMPLE_USERS[0].id}';`, - 'const {data: user, isLoading} = useGetUser(userId);', - 'const {data: groups} = useGetUserGroups(userId);', + 'const user = ref();', + 'const groups = ref();', + 'const isLoading = ref(true);', + '', + 'onMounted(async () => {', + ' [user.value, groups.value] = await Promise.all([', + ' getUser({...config, userId}),', + ' getUserGroups({...config, userId}),', + ' ]);', + ' isLoading.value = false;', + '});', ...(bool(args, 'editable') ? ['const handleUpdate = (attributes: Record): void => {};'] : []), @@ -591,6 +674,7 @@ export const ADMIN_COMPONENTS: ComponentDef[] = [ ], }, ), + vueImports: ['onMounted', 'ref'], }, controls: [ SOURCE_CONTROL, @@ -617,7 +701,7 @@ export const ADMIN_COMPONENTS: ComponentDef[] = [ explicit: str(args, 'userId'), noun: 'user', pick: firstOf('users'), - query: () => useGetUsers({limit: 1}), + query: () => useApiQuery(() => getUsers({...useManagementRequestConfig('users'), limit: 1})), render: (userId: string): VNode => h(UserDetails, { deletable: bool(args, 'deletable'), @@ -652,7 +736,9 @@ export const ADMIN_COMPONENTS: ComponentDef[] = [ live: GroupList, liveTag: 'GroupList', name: 'GroupList', - pageComposable: 'useGetGroups', + collection: 'groups', + pageFunction: 'getGroups', + pageType: 'GroupListResponse', rows: SAMPLE_GROUPS, rowsKey: 'groups', rowsProp: 'groups', @@ -727,7 +813,7 @@ export const ADMIN_COMPONENTS: ComponentDef[] = [ explicit: str(args, 'groupId'), noun: 'group', pick: firstOf('groups'), - query: () => useGetGroups({limit: 1}), + query: () => useApiQuery(() => getGroups({...useManagementRequestConfig('groups'), limit: 1})), render: (groupId: string): VNode => h(GroupMembers, {...heading(args), editable: bool(args, 'editable'), groupId, key: groupId}), }) @@ -760,7 +846,9 @@ export const ADMIN_COMPONENTS: ComponentDef[] = [ live: RoleList, liveTag: 'RoleList', name: 'RoleList', - pageComposable: 'useGetRoles', + collection: 'roles', + pageFunction: 'getRoles', + pageType: 'RoleListResponse', rows: SAMPLE_ROLES, rowsKey: 'roles', rowsProp: 'roles', @@ -840,7 +928,7 @@ export const ADMIN_COMPONENTS: ComponentDef[] = [ explicit: str(args, 'roleId'), noun: 'role', pick: firstOf('roles'), - query: () => useGetRoles({limit: 1}), + query: () => useApiQuery(() => getRoles({...useManagementRequestConfig('roles'), limit: 1})), render: (roleId: string): VNode => h(RoleAssignments, {...heading(args), editable: bool(args, 'editable'), key: roleId, roleId}), }) @@ -880,9 +968,16 @@ export const ADMIN_COMPONENTS: ComponentDef[] = [ script: bool(args, 'named') ? [`const names = ${literal(RESOURCE_SERVER_NAMES)};`] : [], } : { - imports: ['BaseRolePermissions', 'useGetRole'], + imports: ['BaseRolePermissions', 'getRole', 'Role', 'useManagementRequestConfig'], script: [ - `const {data: role, isLoading} = useGetRole('${SAMPLE_ROLES[0].id}');`, + "const config = useManagementRequestConfig('roles');", + 'const role = ref();', + 'const isLoading = ref(true);', + '', + 'onMounted(async () => {', + ` role.value = await getRole({...config, roleId: '${SAMPLE_ROLES[0].id}'});`, + ' isLoading.value = false;', + '});', ...(bool(args, 'named') ? [`const names = ${literal(RESOURCE_SERVER_NAMES)};`] : []), ], template: tpl('BaseRolePermissions', omit(args, 'source', 'roleId', 'named', 'isLoading'), { @@ -892,6 +987,7 @@ export const ADMIN_COMPONENTS: ComponentDef[] = [ ...(bool(args, 'named') ? [':resource-server-names="names"'] : []), ], }), + vueImports: ['onMounted', 'ref'], }, controls: [ SOURCE_CONTROL, @@ -920,7 +1016,7 @@ export const ADMIN_COMPONENTS: ComponentDef[] = [ explicit: str(args, 'roleId'), noun: 'role', pick: firstOf('roles'), - query: () => useGetRoles({limit: 1}), + query: () => useApiQuery(() => getRoles({...useManagementRequestConfig('roles'), limit: 1})), render: (roleId: string): VNode => h(RolePermissions, {...heading(args), key: roleId, resourceServerNames: names, roleId}), }) @@ -946,7 +1042,9 @@ export const ADMIN_COMPONENTS: ComponentDef[] = [ live: AgentList, liveTag: 'AgentList', name: 'AgentList', - pageComposable: 'useGetAgents', + collection: 'agents', + pageFunction: 'getAgents', + pageType: 'AgentListResponse', rows: SAMPLE_AGENTS, rowsKey: 'agents', rowsProp: 'agents', @@ -974,12 +1072,32 @@ export const ADMIN_COMPONENTS: ComponentDef[] = [ }), } : { - imports: ['BaseAgentDetails', 'useGetAgent', 'useGetAgentGroups', 'useGetAgentRoles'], + imports: [ + 'Agent', + 'AgentRoleListResponse', + 'BaseAgentDetails', + 'getAgent', + 'getAgentGroups', + 'getAgentRoles', + 'MembershipGroupListResponse', + 'useManagementRequestConfig', + ], script: [ + "const config = useManagementRequestConfig('agents');", `const agentId = '${SAMPLE_AGENTS[0].id}';`, - 'const {data: agent, isLoading} = useGetAgent(agentId);', - 'const {data: groups} = useGetAgentGroups(agentId);', - 'const {data: roles} = useGetAgentRoles(agentId);', + 'const agent = ref();', + 'const groups = ref();', + 'const roles = ref();', + 'const isLoading = ref(true);', + '', + 'onMounted(async () => {', + ' [agent.value, groups.value, roles.value] = await Promise.all([', + ' getAgent({...config, agentId}),', + ' getAgentGroups({...config, agentId}),', + ' getAgentRoles({...config, agentId}),', + ' ]);', + ' isLoading.value = false;', + '});', ...(bool(args, 'deletable') ? handler('handleDelete') : []), ], template: tpl( @@ -995,6 +1113,7 @@ export const ADMIN_COMPONENTS: ComponentDef[] = [ ], }, ), + vueImports: ['onMounted', 'ref'], }, controls: [ SOURCE_CONTROL, @@ -1020,7 +1139,7 @@ export const ADMIN_COMPONENTS: ComponentDef[] = [ explicit: str(args, 'agentId'), noun: 'agent', pick: firstOf('agents'), - query: () => useGetAgents({limit: 1}), + query: () => useApiQuery(() => getAgents({...useManagementRequestConfig('agents'), limit: 1})), render: (agentId: string): VNode => h(AgentDetails, { agentId, @@ -1045,4 +1164,267 @@ export const ADMIN_COMPONENTS: ComponentDef[] = [ {args: {source: 'live'}, id: 'live', name: 'Live (first agent)'}, ], }, + listStory({ + base: BaseApplicationList, + baseTag: 'BaseApplicationList', + id: 'applicationlist', + live: ApplicationList, + liveTag: 'ApplicationList', + name: 'ApplicationList', + collection: 'applications', + pageFunction: 'getApplications', + pageType: 'ApplicationListResponse', + rows: SAMPLE_APPLICATIONS, + rowsKey: 'applications', + rowsProp: 'applications', + selectedProp: 'selectedApplicationId', + }), + { + code: (args: Args): Snippet => { + const shared: Args = omit(args, 'source', 'cancellable', 'isSubmitting', 'error', 'ouId', 'showSecret'); + const cancel: string[] = bool(args, 'cancellable') ? ['@cancel="handleCancel"'] : []; + const cancelScript: string[] = bool(args, 'cancellable') ? handler('handleCancel') : []; + + if (isLive(args)) { + return { + imports: ['ApplicationCreate', 'Application'], + script: ['const handleCreated = (application: Application): void => {};', ...cancelScript], + template: tpl( + 'ApplicationCreate', + {...shared, ouId: str(args, 'ouId')}, + {extra: ['@created="handleCreated"', ...cancel]}, + ), + }; + } + + return { + imports: ['ApplicationCreateSubmission', 'BaseApplicationCreate'], + script: [ + '// Set from the created application, then cleared once the user has copied it.', + 'const clientSecret = ref(null);', + 'const handleSubmit = (submission: ApplicationCreateSubmission): void => {};', + ...cancelScript, + ], + template: tpl('BaseApplicationCreate', omit(args, 'source', 'cancellable', 'ouId', 'showSecret'), { + extra: [ + ':client-secret="clientSecret"', + '@secret-dismiss="clientSecret = null"', + '@submit="handleSubmit"', + ...cancel, + ], + }), + vueImports: ['ref'], + }; + }, + controls: [ + SOURCE_CONTROL, + ...titleControls, + {control: 'bool', description: 'Shows a cancel button (`@cancel`).', name: 'cancellable', type: 'boolean'}, + {control: 'bool', description: 'Base only: shows the busy state.', name: 'isSubmitting', type: 'boolean'}, + {control: 'text', description: 'Base only: form error, already translated.', name: 'error', type: 'string'}, + { + control: 'bool', + description: 'Base only: passes clientSecret, so the form gives way to the secret shown once.', + name: 'showSecret', + type: 'boolean', + }, + { + control: 'text', + description: "Live only: organization unit; empty uses the signed-in user's.", + name: 'ouId', + type: 'string', + }, + ], + defaults: { + cancellable: true, + description: '', + error: '', + isSubmitting: false, + ouId: '', + showSecret: false, + source: 'base', + title: '', + }, + group: 'Admin', + id: 'applicationcreate', + live: true, + name: 'ApplicationCreate', + render: (args: Args, action: Action): VNode => { + const onCancel: (() => void) | undefined = bool(args, 'cancellable') ? (): void => action('cancel') : undefined; + + return narrow( + 480, + isLive(args) + ? h(ApplicationCreate, { + ...heading(args), + onCancel, + onCreated: (created: Application) => action('created', quoted(created.id)), + ouId: str(args, 'ouId') || undefined, + }) + : h(BaseApplicationCreate, { + ...heading(args), + clientSecret: bool(args, 'showSecret') ? SAMPLE_SECRET : null, + error: str(args, 'error') || null, + isSubmitting: bool(args, 'isSubmitting'), + onCancel, + onSecretDismiss: (): void => action('secret-dismiss'), + onSubmit: (submission: Record) => action('submit', JSON.stringify(submission)), + }), + ); + }, + stories: [ + {id: 'base', name: 'Form'}, + {args: {showSecret: true}, id: 'secret', name: 'Client secret shown once'}, + {args: {error: 'An application with this name already exists.'}, id: 'error', name: 'With error'}, + {args: {source: 'live'}, id: 'live', name: 'Live (POST /applications)'}, + ], + }, + { + code: (args: Args): Snippet => { + const flags: string[] = ['editable', 'deletable', 'secretRegenerable'].filter((flag: string) => bool(args, flag)); + + if (isLive(args)) { + return { + imports: ['ApplicationDetails'], + script: handler('handleDeleted'), + template: tpl( + 'ApplicationDetails', + {}, + { + extra: [ + `application-id="${str(args, 'applicationId') || SAMPLE_APPLICATIONS[0].id}"`, + ...flags.map((flag: string) => flag.replace(/[A-Z]/g, (char: string) => `-${char.toLowerCase()}`)), + '@deleted="handleDeleted"', + ], + }, + ), + }; + } + + return { + imports: [ + 'Application', + 'ApplicationUpdateSubmission', + 'BaseApplicationDetails', + 'getApplication', + 'useManagementRequestConfig', + ], + script: [ + "const config = useManagementRequestConfig('applications');", + `const applicationId = '${SAMPLE_APPLICATIONS[0].id}';`, + 'const application = ref();', + 'const clientSecret = ref(null);', + 'const isLoading = ref(true);', + '', + 'onMounted(async () => {', + ' application.value = await getApplication({...config, applicationId});', + ' isLoading.value = false;', + '});', + ...(bool(args, 'editable') + ? ['const handleUpdate = (changes: ApplicationUpdateSubmission): void => {};'] + : []), + ...(bool(args, 'deletable') ? handler('handleDelete') : []), + ...(bool(args, 'secretRegenerable') ? handler('handleRegenerateSecret') : []), + ], + template: tpl( + 'BaseApplicationDetails', + {}, + { + extra: [ + ':application="application"', + ':client-secret="clientSecret"', + ':is-loading="isLoading"', + ...(bool(args, 'editable') ? ['@update="handleUpdate"'] : []), + ...(bool(args, 'deletable') ? ['@delete="handleDelete"'] : []), + ...(bool(args, 'secretRegenerable') ? ['@regenerate-secret="handleRegenerateSecret"'] : []), + '@secret-dismiss="clientSecret = null"', + ], + }, + ), + vueImports: ['onMounted', 'ref'], + }; + }, + controls: [ + SOURCE_CONTROL, + {control: 'bool', description: 'Offers editing (`@update` / `editable`).', name: 'editable', type: 'boolean'}, + {control: 'bool', description: 'Offers deletion (`@delete` / `deletable`).', name: 'deletable', type: 'boolean'}, + { + control: 'bool', + description: 'Offers client secret regeneration (`@regenerate-secret` / `secret-regenerable`).', + name: 'secretRegenerable', + type: 'boolean', + }, + { + control: 'bool', + description: 'Base only: passes clientSecret, the new secret shown once.', + name: 'showSecret', + type: 'boolean', + }, + idControl('applicationId', 'application'), + ...baseStateControls, + ], + defaults: { + applicationId: '', + deletable: true, + editable: true, + error: '', + isLoading: false, + secretRegenerable: true, + showSecret: false, + source: 'base', + }, + group: 'Admin', + id: 'applicationdetails', + live: true, + name: 'ApplicationDetails', + render: (args: Args, action: Action): VNode => + narrow( + 640, + isLive(args) + ? h(FirstResource, { + explicit: str(args, 'applicationId'), + noun: 'application', + pick: firstOf('applications'), + query: () => + useApiQuery(() => getApplications({...useManagementRequestConfig('applications'), limit: 1})), + render: (applicationId: string): VNode => + h(ApplicationDetails, { + applicationId, + deletable: bool(args, 'deletable'), + editable: bool(args, 'editable'), + key: applicationId, + onDeleted: () => action('deleted', quoted(applicationId)), + secretRegenerable: bool(args, 'secretRegenerable'), + }), + }) + : h(BaseApplicationDetails, { + application: SAMPLE_APPLICATION, + clientSecret: bool(args, 'showSecret') ? SAMPLE_SECRET : null, + error: str(args, 'error') || null, + isLoading: bool(args, 'isLoading'), + onDelete: bool(args, 'deletable') + ? (): void => action('delete', quoted(SAMPLE_APPLICATION.id)) + : undefined, + onRegenerateSecret: bool(args, 'secretRegenerable') + ? (): void => action('regenerate-secret', quoted(SAMPLE_APPLICATION.id)) + : undefined, + onSecretDismiss: (): void => action('secret-dismiss'), + onUpdate: bool(args, 'editable') + ? (changes: Record): void => action('update', JSON.stringify(changes)) + : undefined, + }), + ), + stories: [ + {id: 'base', name: 'Sample application'}, + { + args: {deletable: false, editable: false, secretRegenerable: false}, + id: 'readonly', + name: 'Read only', + }, + {args: {showSecret: true}, id: 'secret', name: 'Regenerated secret'}, + {args: {isLoading: true}, id: 'loading', name: 'Loading'}, + {args: {error: 'The application could not be deleted.'}, id: 'error', name: 'With error'}, + {args: {source: 'live'}, id: 'live', name: 'Live (first application)'}, + ], + }, ]; diff --git a/playgrounds/vue/src/pages/explorer/customRenderStories.ts b/playgrounds/vue/src/pages/explorer/customRenderStories.ts index b7c74f80..9cfbe0e7 100644 --- a/playgrounds/vue/src/pages/explorer/customRenderStories.ts +++ b/playgrounds/vue/src/pages/explorer/customRenderStories.ts @@ -1,9 +1,16 @@ // Copyright 2026 The ThunderID Authors // SPDX-License-Identifier: Apache-2.0 -import type {ManagedUser, MembershipGroup} from '@thunderid/browser'; +import type {Application, ManagedUser, MembershipGroup} from '@thunderid/browser'; import { type AdminListRenderProps, + ApplicationCreate, + type ApplicationCreateSubmission, + ApplicationDetails, + BaseApplicationCreate, + type BaseApplicationCreateRenderProps, + BaseApplicationDetails, + type BaseApplicationDetailsRenderProps, BaseGroupCreate, type BaseGroupCreateRenderProps, BaseUserDetails, @@ -14,7 +21,7 @@ import { UserList, } from '@thunderid/vue'; import {Component, defineComponent, h, PropType, Ref, ref, VNode} from 'vue'; -import {groupForm, PrimeTheme, userCard, userTable} from './PrimeRenderers'; +import {applicationCard, applicationForm, groupForm, PrimeTheme, userCard, userTable} from './PrimeRenderers'; import {bool, num, quoted, SOURCE_CONTROL, str} from './storyKit'; import type {Action, Args, ComponentDef} from './types'; import {narrow} from './widgets'; @@ -43,6 +50,27 @@ const SAMPLE_MEMBERSHIPS: MembershipGroup[] = [ {id: 'grp_sec', name: 'Security', ouId: OU}, ]; +const SAMPLE_APPLICATION: Application = { + id: 'app_portal', + inboundAuthConfig: [ + { + config: { + clientId: 'portal-web', + grantTypes: ['authorization_code', 'refresh_token'], + redirectUris: ['https://portal.acme.io/callback'], + responseTypes: ['code'], + }, + type: 'oauth2', + }, + ], + name: 'Customer portal', + ouId: OU, + template: 'browser', +}; + +/** A secret shown in the "secret shown once" stories. Sample only, never a real credential. */ +const SAMPLE_SECRET = 'sample-secret-5f1c9e2a'; + const isLive = (args: Args): boolean => str(args, 'source') === 'live'; const themed = (width: number, child: VNode): VNode => narrow(width, h(PrimeTheme, null, {default: () => child})); @@ -130,6 +158,51 @@ const CARD_TEMPLATE = ` `; +const APPLICATION_FORM_TEMPLATE = ` + +`; + +const APPLICATION_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. @@ -288,4 +361,126 @@ export const CUSTOM_RENDER_COMPONENTS: ComponentDef[] = [ {args: {source: 'live'}, id: 'live', name: 'Live (needs userId)'}, ], }, + { + code: () => ({ + imports: ['ApplicationCreate'], + otherImports: [ + "import Button from 'primevue/button';", + "import InputText from 'primevue/inputtext';", + "import Message from 'primevue/message';", + "import MultiSelect from 'primevue/multiselect';", + "import Password from 'primevue/password';", + ], + template: APPLICATION_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'}, + {control: 'bool', description: 'Base only: passes clientSecret.', name: 'showSecret', type: 'boolean'}, + ], + defaults: {error: '', isSubmitting: false, showSecret: false, source: 'base'}, + group: 'Custom rendering', + id: 'custom-applicationcreate-form', + live: true, + name: 'ApplicationCreate → PrimeVue form', + render: (args: Args, action: Action): VNode => + themed( + 480, + isLive(args) + ? h( + ApplicationCreate, + { + onCancel: (): void => action('cancel'), + onCreated: (application: {id: string}): void => action('created', quoted(application.id)), + }, + {default: (state: BaseApplicationCreateRenderProps) => applicationForm(state)}, + ) + : h( + BaseApplicationCreate, + { + clientSecret: bool(args, 'showSecret') ? SAMPLE_SECRET : null, + error: str(args, 'error') || null, + isSubmitting: bool(args, 'isSubmitting'), + onCancel: (): void => action('cancel'), + onSecretDismiss: (): void => action('secret-dismiss'), + onSubmit: (application: ApplicationCreateSubmission): void => + action('submit', JSON.stringify(application)), + }, + {default: (state: BaseApplicationCreateRenderProps) => applicationForm(state)}, + ), + ), + stories: [ + {id: 'base', name: 'Sample'}, + {args: {showSecret: true}, id: 'secret', name: 'Client secret shown once'}, + {args: {source: 'live'}, id: 'live', name: 'Live (needs sign-in)'}, + ], + }, + { + code: () => ({ + imports: ['ApplicationDetails'], + otherImports: [ + "import Button from 'primevue/button';", + "import Card from 'primevue/card';", + "import Chip from 'primevue/chip';", + "import Password from 'primevue/password';", + ], + template: APPLICATION_CARD_TEMPLATE, + }), + controls: [ + SOURCE_CONTROL, + {control: 'text', description: 'Live only: the application to load.', name: 'applicationId', type: 'string'}, + {control: 'bool', description: 'Passes onDelete.', name: 'deletable', type: 'boolean'}, + {control: 'bool', description: 'Passes onRegenerateSecret.', name: 'secretRegenerable', type: 'boolean'}, + {control: 'bool', description: 'Base only: passes clientSecret.', name: 'showSecret', type: 'boolean'}, + ], + defaults: {applicationId: '', deletable: true, secretRegenerable: true, showSecret: false, source: 'base'}, + group: 'Custom rendering', + id: 'custom-applicationdetails-card', + live: true, + name: 'ApplicationDetails → PrimeVue card', + render: (args: Args, action: Action): VNode => { + if (isLive(args) && !str(args, 'applicationId')) { + return narrow( + 420, + h('p', 'Set applicationId in Controls to an ID from your server (copy one from the ApplicationList story).'), + ); + } + + return themed( + 480, + isLive(args) + ? h( + ApplicationDetails, + { + applicationId: str(args, 'applicationId'), + deletable: bool(args, 'deletable'), + onDeleted: (): void => action('deleted'), + secretRegenerable: bool(args, 'secretRegenerable'), + }, + {default: (state: BaseApplicationDetailsRenderProps) => applicationCard(state)}, + ) + : h( + BaseApplicationDetails, + { + application: SAMPLE_APPLICATION, + clientSecret: bool(args, 'showSecret') ? SAMPLE_SECRET : null, + onDelete: bool(args, 'deletable') + ? (): void => action('delete', quoted(SAMPLE_APPLICATION.id)) + : undefined, + onRegenerateSecret: bool(args, 'secretRegenerable') + ? (): void => action('regenerate-secret', quoted(SAMPLE_APPLICATION.id)) + : undefined, + onSecretDismiss: (): void => action('secret-dismiss'), + }, + {default: (state: BaseApplicationDetailsRenderProps) => applicationCard(state)}, + ), + ); + }, + stories: [ + {id: 'base', name: 'Sample application'}, + {args: {showSecret: true}, id: 'secret', name: 'Regenerated secret'}, + {args: {source: 'live'}, id: 'live', name: 'Live (needs applicationId)'}, + ], + }, ]; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fc1250ba..7178bdba 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -368,8 +368,8 @@ importers: specifier: 'catalog:' version: 24.7.2 better-auth: - specifier: ^1.7.2 - version: 1.7.2(next@15.5.28(@babel/core@7.29.7(supports-color@10.2.2))(@playwright/test@1.60.0)(@types/node@24.7.2)(babel-plugin-macros@3.1.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vitest@4.1.11)(vue@3.5.43(typescript@5.9.3)) + specifier: ^1.7.13 + version: 1.7.13(next@15.5.28(@babel/core@7.29.7(supports-color@10.2.2))(@playwright/test@1.60.0)(@types/node@24.7.2)(babel-plugin-macros@3.1.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vitest@4.1.11)(vue@3.5.43(typescript@5.9.3)) eslint: specifier: 'catalog:' version: 9.39.4(jiti@2.7.0)(supports-color@10.2.2) @@ -1106,7 +1106,7 @@ importers: version: link:../../packages/vue nuxt: specifier: 3.21.11 - version: 3.21.11(@oxc-project/types@0.154.0)(@parcel/watcher@2.5.6)(@types/node@24.7.2)(@vue/compiler-sfc@3.5.43)(cac@6.7.14)(db0@0.3.4)(esbuild@0.28.1)(eslint@9.39.4(jiti@2.7.0)(supports-color@10.2.2))(ioredis@5.11.1(supports-color@10.2.2))(lightningcss@1.33.0)(magicast@0.5.3)(optionator@0.9.4)(rolldown@1.2.14)(rollup-plugin-visualizer@7.0.1(rolldown@1.2.14)(rollup@4.62.2))(rollup@4.62.2)(srvx@0.11.22)(supports-color@10.2.2)(terser@5.48.0)(typescript@5.9.3)(vite@8.2.4(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))(vue-tsc@3.3.13(typescript@5.9.3))(yaml@2.9.0) + version: 3.21.11(@oxc-project/types@0.154.0)(@parcel/watcher@2.5.6)(@types/node@24.7.2)(@vue/compiler-sfc@3.5.43)(cac@6.7.14)(db0@0.3.4)(esbuild@0.28.1)(eslint@9.39.4(jiti@2.7.0)(supports-color@10.2.2))(ioredis@5.11.1(supports-color@10.2.2))(lightningcss@1.33.0)(magicast@0.5.4)(optionator@0.9.4)(rolldown@1.2.14)(rollup-plugin-visualizer@7.0.1(rolldown@1.2.14)(rollup@4.62.2))(rollup@4.62.2)(supports-color@10.2.2)(terser@5.48.0)(typescript@5.9.3)(vite@8.2.4(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))(vue-tsc@3.3.13(typescript@5.9.3))(yaml@2.9.0) primevue: specifier: ^4.5.5 version: 4.5.5(vue@3.5.43(typescript@5.9.3)) @@ -1447,64 +1447,61 @@ packages: resolution: {integrity: sha512-K8ponJDxBwDHigkeFqaqT5wLGl4bTlwMafR8k7b5CPxr6Ww+UG9ls8Yx6Tcpboxu97eeGVEEyKcHmEyOwN1vSw==} engines: {node: ^22.18.0 || >=24.11.0} - '@better-auth/core@1.7.2': - resolution: {integrity: sha512-j0nM4ygsWbF/fcYRoKtDn8gn8uLXkmC+075HqSqsJEAV828cJR9bvYBCUQ1zmxNyRBk6Iz/qXsA0Zm2oksiOTg==} + '@better-auth/core@1.7.13': + resolution: {integrity: sha512-h7zk+hwNxNxva2I49ELg1b3yNVG5CneluIp860DYcikWWZlCd3Kh6zYiDcY0Bvt/meRl0XBoQhjp7WlNk4a9fQ==} peerDependencies: - '@better-auth/utils': 0.4.2 - '@better-fetch/fetch': 1.3.1 - '@cloudflare/workers-types': '>=4' + '@better-auth/utils': ^0.4.2 || ^0.5.0 + '@better-fetch/fetch': 1.3.3 '@opentelemetry/api': ^1.9.0 - better-call: 1.4.0 + better-call: 1.4.4 jose: ^6.1.0 kysely: ^0.28.5 || ^0.29.0 nanostores: ^1.0.1 peerDependenciesMeta: - '@cloudflare/workers-types': - optional: true '@opentelemetry/api': optional: true - '@better-auth/drizzle-adapter@1.7.2': - resolution: {integrity: sha512-A5wE10PIv3aS5LGePecEHntQylKy6OOF17B4dqlE0DwJeqU/IOBSd7/LZhMop9cNJ3WFjKMpazVSf91yYM/NFg==} + '@better-auth/drizzle-adapter@1.7.13': + resolution: {integrity: sha512-BCA2uJQJ9/GrIt/RvnDIVBlo7r1ZgkAICLG20wxjxLIvSLVVlpuZhjNUFf8FtaHYmRXOCYL566hZ114MAvl4lg==} peerDependencies: - '@better-auth/core': ^1.7.2 - '@better-auth/utils': 0.4.2 + '@better-auth/core': ^1.7.13 + '@better-auth/utils': ^0.4.2 || ^0.5.0 drizzle-orm: ^0.45.2 || >=1.0.0-rc.1 <2.0.0 peerDependenciesMeta: drizzle-orm: optional: true - '@better-auth/kysely-adapter@1.7.2': - resolution: {integrity: sha512-LYdSRLOvZiF+6S0UThu+wE/Qxsq9P2jQs7ZKkY6BIBJqUjYyxVDmi8HFcantBvWWW1/BeQCSsD7YVDG4gICMIQ==} + '@better-auth/kysely-adapter@1.7.13': + resolution: {integrity: sha512-Pw6roVatSW+B3McdTJv+sS0V5EnwSPS3v/IcYCLcoWTy9LaMSUa/jmGZQarQJ0jAawtv+PFTcYQKXS1Glvjpbg==} peerDependencies: - '@better-auth/core': ^1.7.2 - '@better-auth/utils': 0.4.2 + '@better-auth/core': ^1.7.13 + '@better-auth/utils': ^0.4.2 || ^0.5.0 kysely: ^0.28.17 || ^0.29.0 peerDependenciesMeta: kysely: optional: true - '@better-auth/memory-adapter@1.7.2': - resolution: {integrity: sha512-0q1SXMzm5esH9L0xVuM6IxCk59E4G+3HySX4My9gvEwqtmUobykn+iuc/si3Y4xwUO7JODqQ5o+/pPcLDDMIrA==} + '@better-auth/memory-adapter@1.7.13': + resolution: {integrity: sha512-9rnOCaXgeiXNTr4EZOAI75xyt+pg7+ILhuY/AP4fpBVnrA9B5RZNg6wrPVUwo9YU5HsK/Ho1Ov4ikAOUicJixQ==} peerDependencies: - '@better-auth/core': ^1.7.2 - '@better-auth/utils': 0.4.2 + '@better-auth/core': ^1.7.13 + '@better-auth/utils': ^0.4.2 || ^0.5.0 - '@better-auth/mongo-adapter@1.7.2': - resolution: {integrity: sha512-4879SmUWHUs0OYlvHoCFbycZ7i1bqytkcgAUdt9RLQMvZ5H3LRMTgax2YVlGZEXgwNjY/X7xAoXOecWLhlQWeA==} + '@better-auth/mongo-adapter@1.7.13': + resolution: {integrity: sha512-U/wnes25UBiKA7Kfz8tiimSs8BF1+h05ro99arzFis186XwRpFdD/YvtErmcXH5jMVAvIoCUZNmso+SNQQtsgA==} peerDependencies: - '@better-auth/core': ^1.7.2 - '@better-auth/utils': 0.4.2 + '@better-auth/core': ^1.7.13 + '@better-auth/utils': ^0.4.2 || ^0.5.0 mongodb: ^6.0.0 || ^7.0.0 peerDependenciesMeta: mongodb: optional: true - '@better-auth/prisma-adapter@1.7.2': - resolution: {integrity: sha512-mXTr/83WrNWLrvzIjtgDgdu9iXhOcSG1+qBQOAKlbGSFiOB+z4IMRneQ2wmMOiB8mKY9qGkClVUjKRFXqtHnFQ==} + '@better-auth/prisma-adapter@1.7.13': + resolution: {integrity: sha512-401uXNuw8TR/u/dRasmLPRhDW+eqr6NUX6o0D7bq9gCTf4vP4DK1vYSOmG0SJJt5KC1mM1g4n4HtQI2Tq2eLZw==} peerDependencies: - '@better-auth/core': ^1.7.2 - '@better-auth/utils': 0.4.2 + '@better-auth/core': ^1.7.13 + '@better-auth/utils': ^0.4.2 || ^0.5.0 '@prisma/client': ^5.0.0 || ^6.0.0 || ^7.0.0 prisma: ^5.0.0 || ^6.0.0 || ^7.0.0 peerDependenciesMeta: @@ -1513,22 +1510,25 @@ packages: prisma: optional: true - '@better-auth/telemetry@1.7.2': - resolution: {integrity: sha512-LcWu+O0zrxYDQj8E36vfkJwGPW4k9ZDA/rCo0zST6ihzL+juR7pBowoZIM9E6tK0Vit52mf6412bGT4XM4eTjQ==} + '@better-auth/telemetry@1.7.13': + resolution: {integrity: sha512-+v9bIV837pBPLWAB1MLpqY+4MDgetiqv1A2tw3gFYseiz/+2FxfAt0FXbpYHGf9fEaGtPaPWMvBE9dNyIE74MQ==} peerDependencies: - '@better-auth/core': ^1.7.2 - '@better-auth/utils': 0.4.2 - '@better-fetch/fetch': 1.3.1 - - '@better-auth/utils@0.4.2': - resolution: {integrity: sha512-AUxrvu+HaaODsUyzDxFgwd/8RZ1yZaYo42LXKSrU2oGgR38pS1ij8nqQKNgtTWoYGpNevNXtCfgTy6loHveW9A==} + '@better-auth/core': ^1.7.13 + '@better-auth/utils': ^0.4.2 || ^0.5.0 + '@better-fetch/fetch': 1.3.3 '@better-auth/utils@0.5.0': resolution: {integrity: sha512-BL8W4EfIZFwlu0r54m3v1ztjDhu6dDe/amLTm0xybmbZaNgYUqhD3SjpAsnq0q8YD6/ki4iwIgxJNLP/N3TxiA==} + '@better-auth/utils@0.5.1': + resolution: {integrity: sha512-WETqw5vQ52uQ/xwWR4E8rTgT6fDEr/wnS+otDNkBCa/69281FCF32OKDdQUcahMCnjsvqXBNiEvAQHFB0EVRRg==} + '@better-fetch/fetch@1.3.1': resolution: {integrity: sha512-ABkD1WhyfPZprKRQI3bhATjeiFuNWC9PXhfGWqL+sg/gKrM977oFrYkdb4msM3hgUGonr7KlOsOFT5TU2rht9g==} + '@better-fetch/fetch@1.3.3': + resolution: {integrity: sha512-U6SlDt/GUWtK2JAxS/NhdoYTEim+EL1G5byz2TUj83KjLZBUpYzb5SmX0aXaCnbGkAjFKnV/o/0+cyXMbEcqqg==} + '@blazediff/core@1.9.1': resolution: {integrity: sha512-ehg3jIkYKulZh+8om/O25vkvSsXXwC+skXmyA87FFx6A/45eqOkZsBltMw/TVteb0mloiGT8oGRTcjRAz66zaA==} @@ -2575,10 +2575,6 @@ packages: '@one-ini/wasm@0.1.1': resolution: {integrity: sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==} - '@opentelemetry/semantic-conventions@1.43.0': - resolution: {integrity: sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==} - engines: {node: '>=14'} - '@oxc-minify/binding-android-arm-eabi@0.143.0': resolution: {integrity: sha512-/UEp7vlZSeB5kkcspO93fec1NnPfdkF0JmKUSKm6ZLBhkh+Ua6hHmGU5tBJYF3LeuDDVpe+RIjBlmSu4d0kmKg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -4385,15 +4381,14 @@ packages: engines: {node: '>=6.0.0'} hasBin: true - better-auth@1.7.2: - resolution: {integrity: sha512-gKapKBEvYIGcMxi74RjQ7EbFLiqyQt58vdoJmL1qAlWSkY1Bc2Vqshl524/3u1NxauiOU03M/Ebh762Brmac9A==} + better-auth@1.7.13: + resolution: {integrity: sha512-ncevUrVAWmYbC6Jjxadeh2QLBGMbsfenu8pwgAHJRexXJ4ifPCMbT4e4u79QKPuGpR5Wjw9yPReedVoh6m/qBg==} peerDependencies: '@lynx-js/react': '*' '@prisma/client': ^5.0.0 || ^6.0.0 || ^7.0.0 - '@sveltejs/kit': ^2.0.0 + '@sveltejs/kit': ^2.0.0 || ^3.0.0 '@tanstack/react-start': ^1.0.0 '@tanstack/solid-start': ^1.0.0 - better-sqlite3: ^12.0.0 drizzle-kit: '>=0.31.4 || >=1.0.0-beta.1' drizzle-orm: ^0.45.2 || >=1.0.0-rc.1 <2.0.0 mongodb: ^6.0.0 || ^7.0.0 @@ -4405,7 +4400,7 @@ packages: react-dom: ^18.0.0 || ^19.0.0 solid-js: ^1.0.0 svelte: ^4.0.0 || ^5.0.0 - vitest: ^2.0.0 || ^3.0.0 || ^4.0.0 + vitest: ^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0 vue: ^3.0.0 peerDependenciesMeta: '@lynx-js/react': @@ -4418,8 +4413,6 @@ packages: optional: true '@tanstack/solid-start': optional: true - better-sqlite3: - optional: true drizzle-kit: optional: true drizzle-orm: @@ -4447,8 +4440,8 @@ packages: vue: optional: true - better-call@1.4.0: - resolution: {integrity: sha512-bBKOT4vv1kZLDgxVePdilk/Jwkn+dtRRsmi3DzHcDP+WnswyVl6dR59l2HEeP/0cB+bDoopASAesWDPIdd/zZA==} + better-call@1.4.4: + resolution: {integrity: sha512-Gq4XdGFy4dmUxFEAX2nj0ycFcFbD9ZWh8YROouZoyXeF1M11TpYbplk0pDByTFF8Wz+MYa6W9052c9m7dD+mfw==} peerDependencies: zod: ^4.0.0 peerDependenciesMeta: @@ -8341,61 +8334,62 @@ snapshots: '@babel/helper-string-parser': 8.0.0 '@babel/helper-validator-identifier': 8.0.2 - '@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.6.7))(jose@6.2.3)(kysely@0.29.5)(nanostores@1.5.3)': + '@better-auth/core@1.7.13(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(better-call@1.4.4(zod@4.6.7))(jose@6.2.3)(kysely@0.29.5)(nanostores@1.5.3)': dependencies: - '@better-auth/utils': 0.4.2 - '@better-fetch/fetch': 1.3.1 - '@opentelemetry/semantic-conventions': 1.43.0 + '@better-auth/utils': 0.5.1 + '@better-fetch/fetch': 1.3.3 '@standard-schema/spec': 1.1.0 - better-call: 1.4.0(zod@4.6.7) + better-call: 1.4.4(zod@4.6.7) jose: 6.2.3 kysely: 0.29.5 nanostores: 1.5.3 zod: 4.6.7 - '@better-auth/drizzle-adapter@1.7.2(@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.6.7))(jose@6.2.3)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.4.2)': + '@better-auth/drizzle-adapter@1.7.13(@better-auth/core@1.7.13(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(better-call@1.4.4(zod@4.6.7))(jose@6.2.3)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.5.1)': dependencies: - '@better-auth/core': 1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.6.7))(jose@6.2.3)(kysely@0.29.5)(nanostores@1.5.3) - '@better-auth/utils': 0.4.2 + '@better-auth/core': 1.7.13(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(better-call@1.4.4(zod@4.6.7))(jose@6.2.3)(kysely@0.29.5)(nanostores@1.5.3) + '@better-auth/utils': 0.5.1 - '@better-auth/kysely-adapter@1.7.2(@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.6.7))(jose@6.2.3)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.4.2)(kysely@0.29.5)': + '@better-auth/kysely-adapter@1.7.13(@better-auth/core@1.7.13(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(better-call@1.4.4(zod@4.6.7))(jose@6.2.3)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.5.1)(kysely@0.29.5)': dependencies: - '@better-auth/core': 1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.6.7))(jose@6.2.3)(kysely@0.29.5)(nanostores@1.5.3) - '@better-auth/utils': 0.4.2 + '@better-auth/core': 1.7.13(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(better-call@1.4.4(zod@4.6.7))(jose@6.2.3)(kysely@0.29.5)(nanostores@1.5.3) + '@better-auth/utils': 0.5.1 optionalDependencies: kysely: 0.29.5 - '@better-auth/memory-adapter@1.7.2(@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.6.7))(jose@6.2.3)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.4.2)': + '@better-auth/memory-adapter@1.7.13(@better-auth/core@1.7.13(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(better-call@1.4.4(zod@4.6.7))(jose@6.2.3)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.5.1)': dependencies: - '@better-auth/core': 1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.6.7))(jose@6.2.3)(kysely@0.29.5)(nanostores@1.5.3) - '@better-auth/utils': 0.4.2 + '@better-auth/core': 1.7.13(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(better-call@1.4.4(zod@4.6.7))(jose@6.2.3)(kysely@0.29.5)(nanostores@1.5.3) + '@better-auth/utils': 0.5.1 - '@better-auth/mongo-adapter@1.7.2(@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.6.7))(jose@6.2.3)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.4.2)': + '@better-auth/mongo-adapter@1.7.13(@better-auth/core@1.7.13(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(better-call@1.4.4(zod@4.6.7))(jose@6.2.3)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.5.1)': dependencies: - '@better-auth/core': 1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.6.7))(jose@6.2.3)(kysely@0.29.5)(nanostores@1.5.3) - '@better-auth/utils': 0.4.2 + '@better-auth/core': 1.7.13(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(better-call@1.4.4(zod@4.6.7))(jose@6.2.3)(kysely@0.29.5)(nanostores@1.5.3) + '@better-auth/utils': 0.5.1 - '@better-auth/prisma-adapter@1.7.2(@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.6.7))(jose@6.2.3)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.4.2)': + '@better-auth/prisma-adapter@1.7.13(@better-auth/core@1.7.13(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(better-call@1.4.4(zod@4.6.7))(jose@6.2.3)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.5.1)': dependencies: - '@better-auth/core': 1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.6.7))(jose@6.2.3)(kysely@0.29.5)(nanostores@1.5.3) - '@better-auth/utils': 0.4.2 + '@better-auth/core': 1.7.13(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(better-call@1.4.4(zod@4.6.7))(jose@6.2.3)(kysely@0.29.5)(nanostores@1.5.3) + '@better-auth/utils': 0.5.1 - '@better-auth/telemetry@1.7.2(@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.6.7))(jose@6.2.3)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)': + '@better-auth/telemetry@1.7.13(@better-auth/core@1.7.13(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(better-call@1.4.4(zod@4.6.7))(jose@6.2.3)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)': dependencies: - '@better-auth/core': 1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.6.7))(jose@6.2.3)(kysely@0.29.5)(nanostores@1.5.3) - '@better-auth/utils': 0.4.2 - '@better-fetch/fetch': 1.3.1 + '@better-auth/core': 1.7.13(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(better-call@1.4.4(zod@4.6.7))(jose@6.2.3)(kysely@0.29.5)(nanostores@1.5.3) + '@better-auth/utils': 0.5.1 + '@better-fetch/fetch': 1.3.3 - '@better-auth/utils@0.4.2': + '@better-auth/utils@0.5.0': dependencies: '@noble/hashes': 2.4.0 - '@better-auth/utils@0.5.0': + '@better-auth/utils@0.5.1': dependencies: '@noble/hashes': 2.4.0 '@better-fetch/fetch@1.3.1': {} + '@better-fetch/fetch@1.3.3': {} + '@blazediff/core@1.9.1': {} '@bomb.sh/tab@0.0.19(cac@6.7.14)(citty@0.2.2)': @@ -8443,30 +8437,6 @@ snapshots: '@csstools/css-tokenizer@4.0.0': {} - '@dxup/nuxt@0.5.6(esbuild@0.28.1)(magicast@0.5.3)(oxc-parser@0.143.0)(rolldown@1.2.14)(rollup@4.62.2)(vite@8.2.4(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))': - dependencies: - '@dxup/unimport': 0.1.2 - '@nuxt/kit': 4.5.2(magic-string@1.1.0)(magicast@0.5.3)(oxc-parser@0.143.0)(rolldown@1.2.14)(unplugin@3.3.0(esbuild@0.28.1)(rolldown@1.2.14)(rollup@4.62.2)(vite@8.2.4(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))) - '@vue/compiler-dom': 3.5.41 - chokidar: 5.0.0 - knitwork: 1.3.0 - magic-string: 1.1.0 - pathe: 2.0.3 - tinyglobby: 0.2.17 - unplugin: 3.3.0(esbuild@0.28.1)(rolldown@1.2.14)(rollup@4.62.2)(vite@8.2.4(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)) - transitivePeerDependencies: - - '@farmfe/core' - - '@rspack/core' - - bun-types-no-globals - - esbuild - - magicast - - oxc-parser - - rolldown - - rollup - - unloader - - vite - - webpack - '@dxup/nuxt@0.5.6(esbuild@0.28.1)(magicast@0.5.4)(oxc-parser@0.143.0)(rolldown@1.2.14)(rollup@4.62.2)(vite@8.2.4(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))': dependencies: '@dxup/unimport': 0.1.2 @@ -9191,44 +9161,6 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.20.1 - '@nuxt/cli@3.37.0(@nuxt/schema@3.21.11)(cac@6.7.14)(magicast@0.5.3)(supports-color@10.2.2)': - dependencies: - '@bomb.sh/tab': 0.0.19(cac@6.7.14)(citty@0.2.2) - '@clack/prompts': 1.7.0 - c12: 3.3.4(magicast@0.5.3) - citty: 0.2.2 - confbox: 0.2.4 - consola: 3.4.2 - debug: 4.4.3(supports-color@10.2.2) - defu: 6.1.7 - exsolve: 1.1.1 - fuse.js: 7.4.2 - fzf: 0.5.2 - giget: 3.3.0 - jiti: 2.7.0 - listhen: 1.10.0(srvx@0.11.22) - nypm: 0.6.9 - ofetch: 1.5.1 - ohash: 2.0.11 - pathe: 2.0.3 - perfect-debounce: 2.1.0 - pkg-types: 2.3.1 - scule: 1.3.0 - semver: 7.8.5 - srvx: 0.11.22 - std-env: 4.2.0 - tinyclip: 0.1.15 - tinyexec: 1.2.4 - ufo: 1.6.4 - youch: 4.1.1 - optionalDependencies: - '@nuxt/schema': 3.21.11 - transitivePeerDependencies: - - cac - - commander - - magicast - - supports-color - '@nuxt/cli@3.37.0(@nuxt/schema@3.21.11)(cac@6.7.14)(magicast@0.5.4)(supports-color@10.2.2)': dependencies: '@bomb.sh/tab': 0.0.19(cac@6.7.14)(citty@0.2.2) @@ -9434,32 +9366,6 @@ snapshots: - utf-8-validate - vue - '@nuxt/kit@3.21.11(magicast@0.5.3)': - dependencies: - c12: 3.3.4(magicast@0.5.3) - consola: 3.4.2 - defu: 6.1.7 - destr: 2.0.5 - errx: 0.1.2 - exsolve: 1.1.1 - ignore: 7.0.6 - jiti: 2.7.0 - klona: 2.0.6 - knitwork: 1.3.0 - mlly: 1.8.2 - ohash: 2.0.11 - pathe: 2.0.3 - pkg-types: 2.3.1 - rc9: 3.0.1 - scule: 1.3.0 - semver: 7.8.5 - tinyglobby: 0.2.17 - ufo: 1.6.4 - unctx: 2.5.0 - untyped: 2.0.0 - transitivePeerDependencies: - - magicast - '@nuxt/kit@3.21.11(magicast@0.5.4)': dependencies: c12: 3.3.4(magicast@0.5.4) @@ -9516,36 +9422,6 @@ snapshots: - rolldown - unplugin - '@nuxt/kit@4.5.2(magic-string@1.1.0)(magicast@0.5.3)(oxc-parser@0.143.0)(rolldown@1.2.14)(unplugin@3.3.0(esbuild@0.28.1)(rolldown@1.2.14)(rollup@4.62.2)(vite@8.2.4(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)))': - dependencies: - c12: 3.3.4(magicast@0.5.3) - consola: 3.4.2 - defu: 6.1.7 - destr: 2.0.5 - errx: 0.1.2 - exsolve: 1.1.1 - ignore: 7.0.6 - jiti: 2.7.0 - klona: 2.0.6 - mlly: 1.8.2 - nostics: 1.2.0 - ohash: 2.0.11 - pathe: 2.0.3 - pkg-types: 2.3.1 - rc9: 3.0.1 - scule: 1.3.0 - tinyglobby: 0.2.17 - ufo: 1.6.4 - unctx: 3.0.0(magic-string@1.1.0)(oxc-parser@0.143.0)(rolldown@1.2.14)(unplugin@3.3.0(esbuild@0.28.1)(rolldown@1.2.14)(rollup@4.62.2)(vite@8.2.4(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))) - untyped: 2.0.0 - verkit: 0.3.2 - transitivePeerDependencies: - - magic-string - - magicast - - oxc-parser - - rolldown - - unplugin - '@nuxt/kit@4.5.2(magic-string@1.1.0)(magicast@0.5.4)(oxc-parser@0.143.0)(rolldown@1.2.14)(unplugin@3.3.0(esbuild@0.28.1)(rolldown@1.2.14)(rollup@4.62.2)(vite@8.2.4(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)))': dependencies: c12: 3.3.4(magicast@0.5.4) @@ -9599,10 +9475,10 @@ snapshots: - vue - vue-tsc - '@nuxt/nitro-server@3.21.11(db0@0.3.4)(ioredis@5.11.1(supports-color@10.2.2))(magicast@0.5.3)(nuxt@3.21.11(@oxc-project/types@0.154.0)(@parcel/watcher@2.5.6)(@types/node@24.7.2)(@vue/compiler-sfc@3.5.43)(cac@6.7.14)(db0@0.3.4)(esbuild@0.28.1)(eslint@9.39.4(jiti@2.7.0)(supports-color@10.2.2))(ioredis@5.11.1(supports-color@10.2.2))(lightningcss@1.33.0)(magicast@0.5.3)(optionator@0.9.4)(rolldown@1.2.14)(rollup-plugin-visualizer@7.0.1(rolldown@1.2.14)(rollup@4.62.2))(rollup@4.62.2)(srvx@0.11.22)(supports-color@10.2.2)(terser@5.48.0)(typescript@5.9.3)(vite@8.2.4(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))(vue-tsc@3.3.13(typescript@5.9.3))(yaml@2.9.0))(oxc-parser@0.143.0)(rolldown@1.2.14)(srvx@0.11.22)(supports-color@10.2.2)(typescript@5.9.3)(vite@8.2.4(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))': + '@nuxt/nitro-server@3.21.11(db0@0.3.4)(ioredis@5.11.1(supports-color@10.2.2))(magicast@0.5.4)(nuxt@3.21.11(@oxc-project/types@0.154.0)(@parcel/watcher@2.5.6)(@types/node@24.7.2)(@vue/compiler-sfc@3.5.43)(cac@6.7.14)(db0@0.3.4)(esbuild@0.28.1)(eslint@9.39.4(jiti@2.7.0)(supports-color@10.2.2))(ioredis@5.11.1(supports-color@10.2.2))(lightningcss@1.33.0)(magicast@0.5.4)(optionator@0.9.4)(rolldown@1.2.14)(rollup-plugin-visualizer@7.0.1(rolldown@1.2.14)(rollup@4.62.2))(rollup@4.62.2)(srvx@0.11.22)(supports-color@10.2.2)(terser@5.48.0)(typescript@5.9.3)(vite@8.2.4(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))(vue-tsc@3.3.13(typescript@5.9.3))(yaml@2.9.0))(oxc-parser@0.143.0)(rolldown@1.2.14)(srvx@0.11.22)(supports-color@10.2.2)(typescript@5.9.3)(vite@8.2.4(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))': dependencies: '@nuxt/devalue': 2.0.2 - '@nuxt/kit': 3.21.11(magicast@0.5.3) + '@nuxt/kit': 3.21.11(magicast@0.5.4) '@unhead/vue': 2.1.17(vue@3.5.43(typescript@5.9.3)) '@vue/shared': 3.5.41 consola: 3.4.2 @@ -9617,7 +9493,7 @@ snapshots: klona: 2.0.6 mocked-exports: 0.1.1 nitropack: 2.13.4(oxc-parser@0.143.0)(rolldown@1.2.14)(srvx@0.11.22)(supports-color@10.2.2)(vite@8.2.4(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)) - nuxt: 3.21.11(@oxc-project/types@0.154.0)(@parcel/watcher@2.5.6)(@types/node@24.7.2)(@vue/compiler-sfc@3.5.43)(cac@6.7.14)(db0@0.3.4)(esbuild@0.28.1)(eslint@9.39.4(jiti@2.7.0)(supports-color@10.2.2))(ioredis@5.11.1(supports-color@10.2.2))(lightningcss@1.33.0)(magicast@0.5.3)(optionator@0.9.4)(rolldown@1.2.14)(rollup-plugin-visualizer@7.0.1(rolldown@1.2.14)(rollup@4.62.2))(rollup@4.62.2)(srvx@0.11.22)(supports-color@10.2.2)(terser@5.48.0)(typescript@5.9.3)(vite@8.2.4(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))(vue-tsc@3.3.13(typescript@5.9.3))(yaml@2.9.0) + nuxt: 3.21.11(@oxc-project/types@0.154.0)(@parcel/watcher@2.5.6)(@types/node@24.7.2)(@vue/compiler-sfc@3.5.43)(cac@6.7.14)(db0@0.3.4)(esbuild@0.28.1)(eslint@9.39.4(jiti@2.7.0)(supports-color@10.2.2))(ioredis@5.11.1(supports-color@10.2.2))(lightningcss@1.33.0)(magicast@0.5.4)(optionator@0.9.4)(rolldown@1.2.14)(rollup-plugin-visualizer@7.0.1(rolldown@1.2.14)(rollup@4.62.2))(rollup@4.62.2)(srvx@0.11.22)(supports-color@10.2.2)(terser@5.48.0)(typescript@5.9.3)(vite@8.2.4(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))(vue-tsc@3.3.13(typescript@5.9.3))(yaml@2.9.0) ohash: 2.0.11 pathe: 2.0.3 pkg-types: 2.3.1 @@ -9673,7 +9549,7 @@ snapshots: - webpack - xml2js - '@nuxt/nitro-server@3.21.11(db0@0.3.4)(ioredis@5.11.1(supports-color@10.2.2))(magicast@0.5.4)(nuxt@3.21.11(@oxc-project/types@0.154.0)(@parcel/watcher@2.5.6)(@types/node@24.7.2)(@vue/compiler-sfc@3.5.43)(cac@6.7.14)(db0@0.3.4)(esbuild@0.28.1)(eslint@9.39.4(jiti@2.7.0)(supports-color@10.2.2))(ioredis@5.11.1(supports-color@10.2.2))(lightningcss@1.33.0)(magicast@0.5.4)(optionator@0.9.4)(rolldown@1.2.14)(rollup-plugin-visualizer@7.0.1(rolldown@1.2.14)(rollup@4.62.2))(rollup@4.62.2)(srvx@0.11.22)(supports-color@10.2.2)(terser@5.48.0)(typescript@5.9.3)(vite@8.2.4(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))(vue-tsc@3.3.13(typescript@5.9.3))(yaml@2.9.0))(oxc-parser@0.143.0)(rolldown@1.2.14)(srvx@0.11.22)(supports-color@10.2.2)(typescript@5.9.3)(vite@8.2.4(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))': + '@nuxt/nitro-server@3.21.11(db0@0.3.4)(ioredis@5.11.1(supports-color@10.2.2))(magicast@0.5.4)(nuxt@3.21.11(@oxc-project/types@0.154.0)(@parcel/watcher@2.5.6)(@types/node@24.7.2)(@vue/compiler-sfc@3.5.43)(cac@6.7.14)(db0@0.3.4)(esbuild@0.28.1)(eslint@9.39.4(jiti@2.7.0)(supports-color@10.2.2))(ioredis@5.11.1(supports-color@10.2.2))(lightningcss@1.33.0)(magicast@0.5.4)(optionator@0.9.4)(rolldown@1.2.14)(rollup-plugin-visualizer@7.0.1(rolldown@1.2.14)(rollup@4.62.2))(rollup@4.62.2)(supports-color@10.2.2)(terser@5.48.0)(typescript@5.9.3)(vite@8.2.4(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))(vue-tsc@3.3.13(typescript@5.9.3))(yaml@2.9.0))(oxc-parser@0.143.0)(rolldown@1.2.14)(supports-color@10.2.2)(typescript@5.9.3)(vite@8.2.4(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))': dependencies: '@nuxt/devalue': 2.0.2 '@nuxt/kit': 3.21.11(magicast@0.5.4) @@ -9691,7 +9567,7 @@ snapshots: klona: 2.0.6 mocked-exports: 0.1.1 nitropack: 2.13.4(oxc-parser@0.143.0)(rolldown@1.2.14)(srvx@0.11.22)(supports-color@10.2.2)(vite@8.2.4(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)) - nuxt: 3.21.11(@oxc-project/types@0.154.0)(@parcel/watcher@2.5.6)(@types/node@24.7.2)(@vue/compiler-sfc@3.5.43)(cac@6.7.14)(db0@0.3.4)(esbuild@0.28.1)(eslint@9.39.4(jiti@2.7.0)(supports-color@10.2.2))(ioredis@5.11.1(supports-color@10.2.2))(lightningcss@1.33.0)(magicast@0.5.4)(optionator@0.9.4)(rolldown@1.2.14)(rollup-plugin-visualizer@7.0.1(rolldown@1.2.14)(rollup@4.62.2))(rollup@4.62.2)(srvx@0.11.22)(supports-color@10.2.2)(terser@5.48.0)(typescript@5.9.3)(vite@8.2.4(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))(vue-tsc@3.3.13(typescript@5.9.3))(yaml@2.9.0) + nuxt: 3.21.11(@oxc-project/types@0.154.0)(@parcel/watcher@2.5.6)(@types/node@24.7.2)(@vue/compiler-sfc@3.5.43)(cac@6.7.14)(db0@0.3.4)(esbuild@0.28.1)(eslint@9.39.4(jiti@2.7.0)(supports-color@10.2.2))(ioredis@5.11.1(supports-color@10.2.2))(lightningcss@1.33.0)(magicast@0.5.4)(optionator@0.9.4)(rolldown@1.2.14)(rollup-plugin-visualizer@7.0.1(rolldown@1.2.14)(rollup@4.62.2))(rollup@4.62.2)(supports-color@10.2.2)(terser@5.48.0)(typescript@5.9.3)(vite@8.2.4(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))(vue-tsc@3.3.13(typescript@5.9.3))(yaml@2.9.0) ohash: 2.0.11 pathe: 2.0.3 pkg-types: 2.3.1 @@ -9755,15 +9631,6 @@ snapshots: pkg-types: 2.3.1 std-env: 4.2.0 - '@nuxt/telemetry@2.8.0(@nuxt/kit@3.21.11(magicast@0.5.3))': - dependencies: - '@nuxt/kit': 3.21.11(magicast@0.5.3) - citty: 0.2.2 - consola: 3.4.2 - ofetch: 2.0.0-alpha.3 - rc9: 3.0.1 - std-env: 4.2.0 - '@nuxt/telemetry@2.8.0(@nuxt/kit@3.21.11(magicast@0.5.4))': dependencies: '@nuxt/kit': 3.21.11(magicast@0.5.4) @@ -9822,9 +9689,9 @@ snapshots: - typescript - yaml - '@nuxt/vite-builder@3.21.11(@types/node@24.7.2)(eslint@9.39.4(jiti@2.7.0)(supports-color@10.2.2))(lightningcss@1.33.0)(magicast@0.5.3)(nuxt@3.21.11(@oxc-project/types@0.154.0)(@parcel/watcher@2.5.6)(@types/node@24.7.2)(@vue/compiler-sfc@3.5.43)(cac@6.7.14)(db0@0.3.4)(esbuild@0.28.1)(eslint@9.39.4(jiti@2.7.0)(supports-color@10.2.2))(ioredis@5.11.1(supports-color@10.2.2))(lightningcss@1.33.0)(magicast@0.5.3)(optionator@0.9.4)(rolldown@1.2.14)(rollup-plugin-visualizer@7.0.1(rolldown@1.2.14)(rollup@4.62.2))(rollup@4.62.2)(srvx@0.11.22)(supports-color@10.2.2)(terser@5.48.0)(typescript@5.9.3)(vite@8.2.4(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))(vue-tsc@3.3.13(typescript@5.9.3))(yaml@2.9.0))(optionator@0.9.4)(rolldown@1.2.14)(rollup-plugin-visualizer@7.0.1(rolldown@1.2.14)(rollup@4.62.2))(rollup@4.62.2)(supports-color@10.2.2)(terser@5.48.0)(typescript@5.9.3)(vue-tsc@3.3.13(typescript@5.9.3))(vue@3.5.43(typescript@5.9.3))(yaml@2.9.0)': + '@nuxt/vite-builder@3.21.11(@types/node@24.7.2)(eslint@9.39.4(jiti@2.7.0)(supports-color@10.2.2))(lightningcss@1.33.0)(magicast@0.5.4)(nuxt@3.21.11(@oxc-project/types@0.154.0)(@parcel/watcher@2.5.6)(@types/node@24.7.2)(@vue/compiler-sfc@3.5.43)(cac@6.7.14)(db0@0.3.4)(esbuild@0.28.1)(eslint@9.39.4(jiti@2.7.0)(supports-color@10.2.2))(ioredis@5.11.1(supports-color@10.2.2))(lightningcss@1.33.0)(magicast@0.5.4)(optionator@0.9.4)(rolldown@1.2.14)(rollup-plugin-visualizer@7.0.1(rolldown@1.2.14)(rollup@4.62.2))(rollup@4.62.2)(srvx@0.11.22)(supports-color@10.2.2)(terser@5.48.0)(typescript@5.9.3)(vite@8.2.4(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))(vue-tsc@3.3.13(typescript@5.9.3))(yaml@2.9.0))(optionator@0.9.4)(rolldown@1.2.14)(rollup-plugin-visualizer@7.0.1(rolldown@1.2.14)(rollup@4.62.2))(rollup@4.62.2)(supports-color@10.2.2)(terser@5.48.0)(typescript@5.9.3)(vue-tsc@3.3.13(typescript@5.9.3))(vue@3.5.43(typescript@5.9.3))(yaml@2.9.0)': dependencies: - '@nuxt/kit': 3.21.11(magicast@0.5.3) + '@nuxt/kit': 3.21.11(magicast@0.5.4) '@rollup/plugin-replace': 6.0.3(rollup@4.62.2) '@vitejs/plugin-vue': 6.0.8(vite@7.3.7(@types/node@24.7.2)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.48.0)(yaml@2.9.0))(vue@3.5.43(typescript@5.9.3)) '@vitejs/plugin-vue-jsx': 5.1.6(supports-color@10.2.2)(vite@7.3.7(@types/node@24.7.2)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.48.0)(yaml@2.9.0))(vue@3.5.43(typescript@5.9.3)) @@ -9842,7 +9709,7 @@ snapshots: magic-string: 0.30.21 mlly: 1.8.2 mocked-exports: 0.1.1 - nuxt: 3.21.11(@oxc-project/types@0.154.0)(@parcel/watcher@2.5.6)(@types/node@24.7.2)(@vue/compiler-sfc@3.5.43)(cac@6.7.14)(db0@0.3.4)(esbuild@0.28.1)(eslint@9.39.4(jiti@2.7.0)(supports-color@10.2.2))(ioredis@5.11.1(supports-color@10.2.2))(lightningcss@1.33.0)(magicast@0.5.3)(optionator@0.9.4)(rolldown@1.2.14)(rollup-plugin-visualizer@7.0.1(rolldown@1.2.14)(rollup@4.62.2))(rollup@4.62.2)(srvx@0.11.22)(supports-color@10.2.2)(terser@5.48.0)(typescript@5.9.3)(vite@8.2.4(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))(vue-tsc@3.3.13(typescript@5.9.3))(yaml@2.9.0) + nuxt: 3.21.11(@oxc-project/types@0.154.0)(@parcel/watcher@2.5.6)(@types/node@24.7.2)(@vue/compiler-sfc@3.5.43)(cac@6.7.14)(db0@0.3.4)(esbuild@0.28.1)(eslint@9.39.4(jiti@2.7.0)(supports-color@10.2.2))(ioredis@5.11.1(supports-color@10.2.2))(lightningcss@1.33.0)(magicast@0.5.4)(optionator@0.9.4)(rolldown@1.2.14)(rollup-plugin-visualizer@7.0.1(rolldown@1.2.14)(rollup@4.62.2))(rollup@4.62.2)(srvx@0.11.22)(supports-color@10.2.2)(terser@5.48.0)(typescript@5.9.3)(vite@8.2.4(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))(vue-tsc@3.3.13(typescript@5.9.3))(yaml@2.9.0) nypm: 0.6.9 ohash: 2.0.11 pathe: 2.0.3 @@ -9884,7 +9751,7 @@ snapshots: - vue-tsc - yaml - '@nuxt/vite-builder@3.21.11(@types/node@24.7.2)(eslint@9.39.4(jiti@2.7.0)(supports-color@10.2.2))(lightningcss@1.33.0)(magicast@0.5.4)(nuxt@3.21.11(@oxc-project/types@0.154.0)(@parcel/watcher@2.5.6)(@types/node@24.7.2)(@vue/compiler-sfc@3.5.43)(cac@6.7.14)(db0@0.3.4)(esbuild@0.28.1)(eslint@9.39.4(jiti@2.7.0)(supports-color@10.2.2))(ioredis@5.11.1(supports-color@10.2.2))(lightningcss@1.33.0)(magicast@0.5.4)(optionator@0.9.4)(rolldown@1.2.14)(rollup-plugin-visualizer@7.0.1(rolldown@1.2.14)(rollup@4.62.2))(rollup@4.62.2)(srvx@0.11.22)(supports-color@10.2.2)(terser@5.48.0)(typescript@5.9.3)(vite@8.2.4(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))(vue-tsc@3.3.13(typescript@5.9.3))(yaml@2.9.0))(optionator@0.9.4)(rolldown@1.2.14)(rollup-plugin-visualizer@7.0.1(rolldown@1.2.14)(rollup@4.62.2))(rollup@4.62.2)(supports-color@10.2.2)(terser@5.48.0)(typescript@5.9.3)(vue-tsc@3.3.13(typescript@5.9.3))(vue@3.5.43(typescript@5.9.3))(yaml@2.9.0)': + '@nuxt/vite-builder@3.21.11(@types/node@24.7.2)(eslint@9.39.4(jiti@2.7.0)(supports-color@10.2.2))(lightningcss@1.33.0)(magicast@0.5.4)(nuxt@3.21.11(@oxc-project/types@0.154.0)(@parcel/watcher@2.5.6)(@types/node@24.7.2)(@vue/compiler-sfc@3.5.43)(cac@6.7.14)(db0@0.3.4)(esbuild@0.28.1)(eslint@9.39.4(jiti@2.7.0)(supports-color@10.2.2))(ioredis@5.11.1(supports-color@10.2.2))(lightningcss@1.33.0)(magicast@0.5.4)(optionator@0.9.4)(rolldown@1.2.14)(rollup-plugin-visualizer@7.0.1(rolldown@1.2.14)(rollup@4.62.2))(rollup@4.62.2)(supports-color@10.2.2)(terser@5.48.0)(typescript@5.9.3)(vite@8.2.4(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))(vue-tsc@3.3.13(typescript@5.9.3))(yaml@2.9.0))(optionator@0.9.4)(rolldown@1.2.14)(rollup-plugin-visualizer@7.0.1(rolldown@1.2.14)(rollup@4.62.2))(rollup@4.62.2)(supports-color@10.2.2)(terser@5.48.0)(typescript@5.9.3)(vue-tsc@3.3.13(typescript@5.9.3))(vue@3.5.43(typescript@5.9.3))(yaml@2.9.0)': dependencies: '@nuxt/kit': 3.21.11(magicast@0.5.4) '@rollup/plugin-replace': 6.0.3(rollup@4.62.2) @@ -9904,7 +9771,7 @@ snapshots: magic-string: 0.30.21 mlly: 1.8.2 mocked-exports: 0.1.1 - nuxt: 3.21.11(@oxc-project/types@0.154.0)(@parcel/watcher@2.5.6)(@types/node@24.7.2)(@vue/compiler-sfc@3.5.43)(cac@6.7.14)(db0@0.3.4)(esbuild@0.28.1)(eslint@9.39.4(jiti@2.7.0)(supports-color@10.2.2))(ioredis@5.11.1(supports-color@10.2.2))(lightningcss@1.33.0)(magicast@0.5.4)(optionator@0.9.4)(rolldown@1.2.14)(rollup-plugin-visualizer@7.0.1(rolldown@1.2.14)(rollup@4.62.2))(rollup@4.62.2)(srvx@0.11.22)(supports-color@10.2.2)(terser@5.48.0)(typescript@5.9.3)(vite@8.2.4(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))(vue-tsc@3.3.13(typescript@5.9.3))(yaml@2.9.0) + nuxt: 3.21.11(@oxc-project/types@0.154.0)(@parcel/watcher@2.5.6)(@types/node@24.7.2)(@vue/compiler-sfc@3.5.43)(cac@6.7.14)(db0@0.3.4)(esbuild@0.28.1)(eslint@9.39.4(jiti@2.7.0)(supports-color@10.2.2))(ioredis@5.11.1(supports-color@10.2.2))(lightningcss@1.33.0)(magicast@0.5.4)(optionator@0.9.4)(rolldown@1.2.14)(rollup-plugin-visualizer@7.0.1(rolldown@1.2.14)(rollup@4.62.2))(rollup@4.62.2)(supports-color@10.2.2)(terser@5.48.0)(typescript@5.9.3)(vite@8.2.4(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))(vue-tsc@3.3.13(typescript@5.9.3))(yaml@2.9.0) nypm: 0.6.9 ohash: 2.0.11 pathe: 2.0.3 @@ -9948,8 +9815,6 @@ snapshots: '@one-ini/wasm@0.1.1': {} - '@opentelemetry/semantic-conventions@1.43.0': {} - '@oxc-minify/binding-android-arm-eabi@0.143.0': optional: true @@ -11558,20 +11423,20 @@ snapshots: baseline-browser-mapping@2.11.13: {} - better-auth@1.7.2(next@15.5.28(@babel/core@7.29.7(supports-color@10.2.2))(@playwright/test@1.60.0)(@types/node@24.7.2)(babel-plugin-macros@3.1.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vitest@4.1.11)(vue@3.5.43(typescript@5.9.3)): - dependencies: - '@better-auth/core': 1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.6.7))(jose@6.2.3)(kysely@0.29.5)(nanostores@1.5.3) - '@better-auth/drizzle-adapter': 1.7.2(@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.6.7))(jose@6.2.3)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.4.2) - '@better-auth/kysely-adapter': 1.7.2(@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.6.7))(jose@6.2.3)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.4.2)(kysely@0.29.5) - '@better-auth/memory-adapter': 1.7.2(@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.6.7))(jose@6.2.3)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.4.2) - '@better-auth/mongo-adapter': 1.7.2(@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.6.7))(jose@6.2.3)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.4.2) - '@better-auth/prisma-adapter': 1.7.2(@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.6.7))(jose@6.2.3)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.4.2) - '@better-auth/telemetry': 1.7.2(@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.6.7))(jose@6.2.3)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1) - '@better-auth/utils': 0.4.2 - '@better-fetch/fetch': 1.3.1 + better-auth@1.7.13(next@15.5.28(@babel/core@7.29.7(supports-color@10.2.2))(@playwright/test@1.60.0)(@types/node@24.7.2)(babel-plugin-macros@3.1.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vitest@4.1.11)(vue@3.5.43(typescript@5.9.3)): + dependencies: + '@better-auth/core': 1.7.13(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(better-call@1.4.4(zod@4.6.7))(jose@6.2.3)(kysely@0.29.5)(nanostores@1.5.3) + '@better-auth/drizzle-adapter': 1.7.13(@better-auth/core@1.7.13(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(better-call@1.4.4(zod@4.6.7))(jose@6.2.3)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.5.1) + '@better-auth/kysely-adapter': 1.7.13(@better-auth/core@1.7.13(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(better-call@1.4.4(zod@4.6.7))(jose@6.2.3)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.5.1)(kysely@0.29.5) + '@better-auth/memory-adapter': 1.7.13(@better-auth/core@1.7.13(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(better-call@1.4.4(zod@4.6.7))(jose@6.2.3)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.5.1) + '@better-auth/mongo-adapter': 1.7.13(@better-auth/core@1.7.13(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(better-call@1.4.4(zod@4.6.7))(jose@6.2.3)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.5.1) + '@better-auth/prisma-adapter': 1.7.13(@better-auth/core@1.7.13(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(better-call@1.4.4(zod@4.6.7))(jose@6.2.3)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.5.1) + '@better-auth/telemetry': 1.7.13(@better-auth/core@1.7.13(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(better-call@1.4.4(zod@4.6.7))(jose@6.2.3)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3) + '@better-auth/utils': 0.5.1 + '@better-fetch/fetch': 1.3.3 '@noble/ciphers': 2.4.0 '@noble/hashes': 2.4.0 - better-call: 1.4.0(zod@4.6.7) + better-call: 1.4.4(zod@4.6.7) defu: 6.1.5 jose: 6.2.3 kysely: 0.29.5 @@ -11584,10 +11449,9 @@ snapshots: vitest: 4.1.11(@types/node@24.7.2)(@vitest/browser-playwright@4.1.11)(jsdom@27.0.1(supports-color@10.2.2))(vite@8.2.4(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)) vue: 3.5.43(typescript@5.9.3) transitivePeerDependencies: - - '@cloudflare/workers-types' - '@opentelemetry/api' - better-call@1.4.0(zod@4.6.7): + better-call@1.4.4(zod@4.6.7): dependencies: '@better-auth/utils': 0.5.0 '@better-fetch/fetch': 1.3.1 @@ -13669,16 +13533,16 @@ snapshots: dependencies: boolbase: 1.0.0 - nuxt@3.21.11(@oxc-project/types@0.154.0)(@parcel/watcher@2.5.6)(@types/node@24.7.2)(@vue/compiler-sfc@3.5.43)(cac@6.7.14)(db0@0.3.4)(esbuild@0.28.1)(eslint@9.39.4(jiti@2.7.0)(supports-color@10.2.2))(ioredis@5.11.1(supports-color@10.2.2))(lightningcss@1.33.0)(magicast@0.5.3)(optionator@0.9.4)(rolldown@1.2.14)(rollup-plugin-visualizer@7.0.1(rolldown@1.2.14)(rollup@4.62.2))(rollup@4.62.2)(srvx@0.11.22)(supports-color@10.2.2)(terser@5.48.0)(typescript@5.9.3)(vite@8.2.4(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))(vue-tsc@3.3.13(typescript@5.9.3))(yaml@2.9.0): + nuxt@3.21.11(@oxc-project/types@0.154.0)(@parcel/watcher@2.5.6)(@types/node@24.7.2)(@vue/compiler-sfc@3.5.43)(cac@6.7.14)(db0@0.3.4)(esbuild@0.28.1)(eslint@9.39.4(jiti@2.7.0)(supports-color@10.2.2))(ioredis@5.11.1(supports-color@10.2.2))(lightningcss@1.33.0)(magicast@0.5.4)(optionator@0.9.4)(rolldown@1.2.14)(rollup-plugin-visualizer@7.0.1(rolldown@1.2.14)(rollup@4.62.2))(rollup@4.62.2)(srvx@0.11.22)(supports-color@10.2.2)(terser@5.48.0)(typescript@5.9.3)(vite@8.2.4(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))(vue-tsc@3.3.13(typescript@5.9.3))(yaml@2.9.0): dependencies: - '@dxup/nuxt': 0.5.6(esbuild@0.28.1)(magicast@0.5.3)(oxc-parser@0.143.0)(rolldown@1.2.14)(rollup@4.62.2)(vite@8.2.4(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)) - '@nuxt/cli': 3.37.0(@nuxt/schema@3.21.11)(cac@6.7.14)(magicast@0.5.3)(supports-color@10.2.2) + '@dxup/nuxt': 0.5.6(esbuild@0.28.1)(magicast@0.5.4)(oxc-parser@0.143.0)(rolldown@1.2.14)(rollup@4.62.2)(vite@8.2.4(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)) + '@nuxt/cli': 3.37.0(@nuxt/schema@3.21.11)(cac@6.7.14)(magicast@0.5.4)(supports-color@10.2.2) '@nuxt/devtools': 3.4.1(db0@0.3.4)(ioredis@5.11.1(supports-color@10.2.2))(magic-string@0.30.21)(oxc-parser@0.143.0)(rolldown@1.2.14)(supports-color@10.2.2)(unplugin@3.3.0(esbuild@0.28.1)(rolldown@1.2.14)(rollup@4.62.2)(vite@8.2.4(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)))(vite@8.2.4(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))(vue@3.5.43(typescript@5.9.3)) - '@nuxt/kit': 3.21.11(magicast@0.5.3) - '@nuxt/nitro-server': 3.21.11(db0@0.3.4)(ioredis@5.11.1(supports-color@10.2.2))(magicast@0.5.3)(nuxt@3.21.11(@oxc-project/types@0.154.0)(@parcel/watcher@2.5.6)(@types/node@24.7.2)(@vue/compiler-sfc@3.5.43)(cac@6.7.14)(db0@0.3.4)(esbuild@0.28.1)(eslint@9.39.4(jiti@2.7.0)(supports-color@10.2.2))(ioredis@5.11.1(supports-color@10.2.2))(lightningcss@1.33.0)(magicast@0.5.3)(optionator@0.9.4)(rolldown@1.2.14)(rollup-plugin-visualizer@7.0.1(rolldown@1.2.14)(rollup@4.62.2))(rollup@4.62.2)(srvx@0.11.22)(supports-color@10.2.2)(terser@5.48.0)(typescript@5.9.3)(vite@8.2.4(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))(vue-tsc@3.3.13(typescript@5.9.3))(yaml@2.9.0))(oxc-parser@0.143.0)(rolldown@1.2.14)(srvx@0.11.22)(supports-color@10.2.2)(typescript@5.9.3)(vite@8.2.4(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)) + '@nuxt/kit': 3.21.11(magicast@0.5.4) + '@nuxt/nitro-server': 3.21.11(db0@0.3.4)(ioredis@5.11.1(supports-color@10.2.2))(magicast@0.5.4)(nuxt@3.21.11(@oxc-project/types@0.154.0)(@parcel/watcher@2.5.6)(@types/node@24.7.2)(@vue/compiler-sfc@3.5.43)(cac@6.7.14)(db0@0.3.4)(esbuild@0.28.1)(eslint@9.39.4(jiti@2.7.0)(supports-color@10.2.2))(ioredis@5.11.1(supports-color@10.2.2))(lightningcss@1.33.0)(magicast@0.5.4)(optionator@0.9.4)(rolldown@1.2.14)(rollup-plugin-visualizer@7.0.1(rolldown@1.2.14)(rollup@4.62.2))(rollup@4.62.2)(srvx@0.11.22)(supports-color@10.2.2)(terser@5.48.0)(typescript@5.9.3)(vite@8.2.4(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))(vue-tsc@3.3.13(typescript@5.9.3))(yaml@2.9.0))(oxc-parser@0.143.0)(rolldown@1.2.14)(srvx@0.11.22)(supports-color@10.2.2)(typescript@5.9.3)(vite@8.2.4(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)) '@nuxt/schema': 3.21.11 - '@nuxt/telemetry': 2.8.0(@nuxt/kit@3.21.11(magicast@0.5.3)) - '@nuxt/vite-builder': 3.21.11(@types/node@24.7.2)(eslint@9.39.4(jiti@2.7.0)(supports-color@10.2.2))(lightningcss@1.33.0)(magicast@0.5.3)(nuxt@3.21.11(@oxc-project/types@0.154.0)(@parcel/watcher@2.5.6)(@types/node@24.7.2)(@vue/compiler-sfc@3.5.43)(cac@6.7.14)(db0@0.3.4)(esbuild@0.28.1)(eslint@9.39.4(jiti@2.7.0)(supports-color@10.2.2))(ioredis@5.11.1(supports-color@10.2.2))(lightningcss@1.33.0)(magicast@0.5.3)(optionator@0.9.4)(rolldown@1.2.14)(rollup-plugin-visualizer@7.0.1(rolldown@1.2.14)(rollup@4.62.2))(rollup@4.62.2)(srvx@0.11.22)(supports-color@10.2.2)(terser@5.48.0)(typescript@5.9.3)(vite@8.2.4(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))(vue-tsc@3.3.13(typescript@5.9.3))(yaml@2.9.0))(optionator@0.9.4)(rolldown@1.2.14)(rollup-plugin-visualizer@7.0.1(rolldown@1.2.14)(rollup@4.62.2))(rollup@4.62.2)(supports-color@10.2.2)(terser@5.48.0)(typescript@5.9.3)(vue-tsc@3.3.13(typescript@5.9.3))(vue@3.5.43(typescript@5.9.3))(yaml@2.9.0) + '@nuxt/telemetry': 2.8.0(@nuxt/kit@3.21.11(magicast@0.5.4)) + '@nuxt/vite-builder': 3.21.11(@types/node@24.7.2)(eslint@9.39.4(jiti@2.7.0)(supports-color@10.2.2))(lightningcss@1.33.0)(magicast@0.5.4)(nuxt@3.21.11(@oxc-project/types@0.154.0)(@parcel/watcher@2.5.6)(@types/node@24.7.2)(@vue/compiler-sfc@3.5.43)(cac@6.7.14)(db0@0.3.4)(esbuild@0.28.1)(eslint@9.39.4(jiti@2.7.0)(supports-color@10.2.2))(ioredis@5.11.1(supports-color@10.2.2))(lightningcss@1.33.0)(magicast@0.5.4)(optionator@0.9.4)(rolldown@1.2.14)(rollup-plugin-visualizer@7.0.1(rolldown@1.2.14)(rollup@4.62.2))(rollup@4.62.2)(srvx@0.11.22)(supports-color@10.2.2)(terser@5.48.0)(typescript@5.9.3)(vite@8.2.4(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))(vue-tsc@3.3.13(typescript@5.9.3))(yaml@2.9.0))(optionator@0.9.4)(rolldown@1.2.14)(rollup-plugin-visualizer@7.0.1(rolldown@1.2.14)(rollup@4.62.2))(rollup@4.62.2)(supports-color@10.2.2)(terser@5.48.0)(typescript@5.9.3)(vue-tsc@3.3.13(typescript@5.9.3))(vue@3.5.43(typescript@5.9.3))(yaml@2.9.0) '@unhead/vue': 2.1.17(vue@3.5.43(typescript@5.9.3)) '@vue/shared': 3.5.41 c12: 3.3.4(magicast@0.5.4) @@ -13800,16 +13664,16 @@ snapshots: - xml2js - yaml - nuxt@3.21.11(@oxc-project/types@0.154.0)(@parcel/watcher@2.5.6)(@types/node@24.7.2)(@vue/compiler-sfc@3.5.43)(cac@6.7.14)(db0@0.3.4)(esbuild@0.28.1)(eslint@9.39.4(jiti@2.7.0)(supports-color@10.2.2))(ioredis@5.11.1(supports-color@10.2.2))(lightningcss@1.33.0)(magicast@0.5.4)(optionator@0.9.4)(rolldown@1.2.14)(rollup-plugin-visualizer@7.0.1(rolldown@1.2.14)(rollup@4.62.2))(rollup@4.62.2)(srvx@0.11.22)(supports-color@10.2.2)(terser@5.48.0)(typescript@5.9.3)(vite@8.2.4(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))(vue-tsc@3.3.13(typescript@5.9.3))(yaml@2.9.0): + nuxt@3.21.11(@oxc-project/types@0.154.0)(@parcel/watcher@2.5.6)(@types/node@24.7.2)(@vue/compiler-sfc@3.5.43)(cac@6.7.14)(db0@0.3.4)(esbuild@0.28.1)(eslint@9.39.4(jiti@2.7.0)(supports-color@10.2.2))(ioredis@5.11.1(supports-color@10.2.2))(lightningcss@1.33.0)(magicast@0.5.4)(optionator@0.9.4)(rolldown@1.2.14)(rollup-plugin-visualizer@7.0.1(rolldown@1.2.14)(rollup@4.62.2))(rollup@4.62.2)(supports-color@10.2.2)(terser@5.48.0)(typescript@5.9.3)(vite@8.2.4(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))(vue-tsc@3.3.13(typescript@5.9.3))(yaml@2.9.0): dependencies: '@dxup/nuxt': 0.5.6(esbuild@0.28.1)(magicast@0.5.4)(oxc-parser@0.143.0)(rolldown@1.2.14)(rollup@4.62.2)(vite@8.2.4(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)) '@nuxt/cli': 3.37.0(@nuxt/schema@3.21.11)(cac@6.7.14)(magicast@0.5.4)(supports-color@10.2.2) '@nuxt/devtools': 3.4.1(db0@0.3.4)(ioredis@5.11.1(supports-color@10.2.2))(magic-string@0.30.21)(oxc-parser@0.143.0)(rolldown@1.2.14)(supports-color@10.2.2)(unplugin@3.3.0(esbuild@0.28.1)(rolldown@1.2.14)(rollup@4.62.2)(vite@8.2.4(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)))(vite@8.2.4(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))(vue@3.5.43(typescript@5.9.3)) '@nuxt/kit': 3.21.11(magicast@0.5.4) - '@nuxt/nitro-server': 3.21.11(db0@0.3.4)(ioredis@5.11.1(supports-color@10.2.2))(magicast@0.5.4)(nuxt@3.21.11(@oxc-project/types@0.154.0)(@parcel/watcher@2.5.6)(@types/node@24.7.2)(@vue/compiler-sfc@3.5.43)(cac@6.7.14)(db0@0.3.4)(esbuild@0.28.1)(eslint@9.39.4(jiti@2.7.0)(supports-color@10.2.2))(ioredis@5.11.1(supports-color@10.2.2))(lightningcss@1.33.0)(magicast@0.5.4)(optionator@0.9.4)(rolldown@1.2.14)(rollup-plugin-visualizer@7.0.1(rolldown@1.2.14)(rollup@4.62.2))(rollup@4.62.2)(srvx@0.11.22)(supports-color@10.2.2)(terser@5.48.0)(typescript@5.9.3)(vite@8.2.4(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))(vue-tsc@3.3.13(typescript@5.9.3))(yaml@2.9.0))(oxc-parser@0.143.0)(rolldown@1.2.14)(srvx@0.11.22)(supports-color@10.2.2)(typescript@5.9.3)(vite@8.2.4(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)) + '@nuxt/nitro-server': 3.21.11(db0@0.3.4)(ioredis@5.11.1(supports-color@10.2.2))(magicast@0.5.4)(nuxt@3.21.11(@oxc-project/types@0.154.0)(@parcel/watcher@2.5.6)(@types/node@24.7.2)(@vue/compiler-sfc@3.5.43)(cac@6.7.14)(db0@0.3.4)(esbuild@0.28.1)(eslint@9.39.4(jiti@2.7.0)(supports-color@10.2.2))(ioredis@5.11.1(supports-color@10.2.2))(lightningcss@1.33.0)(magicast@0.5.4)(optionator@0.9.4)(rolldown@1.2.14)(rollup-plugin-visualizer@7.0.1(rolldown@1.2.14)(rollup@4.62.2))(rollup@4.62.2)(supports-color@10.2.2)(terser@5.48.0)(typescript@5.9.3)(vite@8.2.4(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))(vue-tsc@3.3.13(typescript@5.9.3))(yaml@2.9.0))(oxc-parser@0.143.0)(rolldown@1.2.14)(supports-color@10.2.2)(typescript@5.9.3)(vite@8.2.4(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)) '@nuxt/schema': 3.21.11 '@nuxt/telemetry': 2.8.0(@nuxt/kit@3.21.11(magicast@0.5.4)) - '@nuxt/vite-builder': 3.21.11(@types/node@24.7.2)(eslint@9.39.4(jiti@2.7.0)(supports-color@10.2.2))(lightningcss@1.33.0)(magicast@0.5.4)(nuxt@3.21.11(@oxc-project/types@0.154.0)(@parcel/watcher@2.5.6)(@types/node@24.7.2)(@vue/compiler-sfc@3.5.43)(cac@6.7.14)(db0@0.3.4)(esbuild@0.28.1)(eslint@9.39.4(jiti@2.7.0)(supports-color@10.2.2))(ioredis@5.11.1(supports-color@10.2.2))(lightningcss@1.33.0)(magicast@0.5.4)(optionator@0.9.4)(rolldown@1.2.14)(rollup-plugin-visualizer@7.0.1(rolldown@1.2.14)(rollup@4.62.2))(rollup@4.62.2)(srvx@0.11.22)(supports-color@10.2.2)(terser@5.48.0)(typescript@5.9.3)(vite@8.2.4(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))(vue-tsc@3.3.13(typescript@5.9.3))(yaml@2.9.0))(optionator@0.9.4)(rolldown@1.2.14)(rollup-plugin-visualizer@7.0.1(rolldown@1.2.14)(rollup@4.62.2))(rollup@4.62.2)(supports-color@10.2.2)(terser@5.48.0)(typescript@5.9.3)(vue-tsc@3.3.13(typescript@5.9.3))(vue@3.5.43(typescript@5.9.3))(yaml@2.9.0) + '@nuxt/vite-builder': 3.21.11(@types/node@24.7.2)(eslint@9.39.4(jiti@2.7.0)(supports-color@10.2.2))(lightningcss@1.33.0)(magicast@0.5.4)(nuxt@3.21.11(@oxc-project/types@0.154.0)(@parcel/watcher@2.5.6)(@types/node@24.7.2)(@vue/compiler-sfc@3.5.43)(cac@6.7.14)(db0@0.3.4)(esbuild@0.28.1)(eslint@9.39.4(jiti@2.7.0)(supports-color@10.2.2))(ioredis@5.11.1(supports-color@10.2.2))(lightningcss@1.33.0)(magicast@0.5.4)(optionator@0.9.4)(rolldown@1.2.14)(rollup-plugin-visualizer@7.0.1(rolldown@1.2.14)(rollup@4.62.2))(rollup@4.62.2)(supports-color@10.2.2)(terser@5.48.0)(typescript@5.9.3)(vite@8.2.4(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))(vue-tsc@3.3.13(typescript@5.9.3))(yaml@2.9.0))(optionator@0.9.4)(rolldown@1.2.14)(rollup-plugin-visualizer@7.0.1(rolldown@1.2.14)(rollup@4.62.2))(rollup@4.62.2)(supports-color@10.2.2)(terser@5.48.0)(typescript@5.9.3)(vue-tsc@3.3.13(typescript@5.9.3))(vue@3.5.43(typescript@5.9.3))(yaml@2.9.0) '@unhead/vue': 2.1.17(vue@3.5.43(typescript@5.9.3)) '@vue/shared': 3.5.41 c12: 3.3.4(magicast@0.5.4)