From 083ff27fe5c7fd63c21d1a2b606b11fdbd71f998 Mon Sep 17 00:00:00 2001 From: Brion Date: Wed, 28 Oct 2026 14:19:07 +0530 Subject: [PATCH] Let admin components run administration flows and lifecycle actions The admin components called the management API directly, so an app that cleans up runtime state through administration flows (revoking grants and ending sessions) could not use them, and they had no way to suspend, unsuspend or unlock an identity. - `administrationMode` prop (default `native`) on UserDetails, GroupMembers, RoleAssignments and ApplicationDetails, passed to the delete, member removal, assignment removal and secret regeneration calls. - `lifecycleActions` prop on UserDetails, AgentDetails and ApplicationDetails. When on, the component reads the configured flows and offers only the actions the identity's status allows and whose flow is configured. Suspend and unsuspend confirm inline, a suspension of a user or agent takes an optional operator note, and unlock runs straight away. The Base variants and custom renders get `lifecycleActions`, `onLifecycleAction` and `isRunningLifecycleAction`. - The access status is typed on users and agents (`AccessStatus`) and shown as a translated label. - Core: `getAccountLifecycleActions`, `runAccountLifecycleAction` and `AccountLifecycleFlowConfigKey`, shared by React and Vue; new strings in every bundled locale. - React and Vue ports, with tests, and playground controls for the UserDetails story. Signed-off-by: Brion --- .../__tests__/accountLifecycleApi.test.ts | 95 +++++++ .../runAccountLifecycleAction.ts | 94 +++++++ .../src/constants/AdministrationConstants.ts | 25 ++ packages/javascript/src/i18n/models/i18n.ts | 12 + .../javascript/src/i18n/translations/en-US.ts | 13 + .../javascript/src/i18n/translations/fr-FR.ts | 14 + .../javascript/src/i18n/translations/hi-IN.ts | 14 + .../javascript/src/i18n/translations/ja-JP.ts | 14 + .../javascript/src/i18n/translations/pt-BR.ts | 14 + .../javascript/src/i18n/translations/pt-PT.ts | 14 + .../javascript/src/i18n/translations/si-LK.ts | 14 + .../javascript/src/i18n/translations/ta-IN.ts | 14 + .../javascript/src/i18n/translations/te-IN.ts | 14 + packages/javascript/src/index.ts | 13 + .../javascript/src/models/access-status.ts | 58 ++++ .../javascript/src/models/administration.ts | 10 + .../javascript/src/models/agent-resource.ts | 5 + .../javascript/src/models/managed-user.ts | 5 + .../getAccountLifecycleActions.test.ts | 49 ++++ .../src/utils/getAccountLifecycleActions.ts | 49 ++++ .../admin/AgentDetails/AgentDetails.tsx | 35 ++- .../admin/AgentDetails/BaseAgentDetails.tsx | 42 ++- .../ApplicationDetails/ApplicationDetails.tsx | 39 ++- .../BaseApplicationDetails.tsx | 42 +++ .../admin/GroupMembers/GroupMembers.tsx | 10 +- .../admin/RoleAssignments/RoleAssignments.tsx | 10 +- .../admin/UserDetails/BaseUserDetails.tsx | 46 +++- .../admin/UserDetails/UserDetails.tsx | 46 +++- .../admin/__tests__/adminComponents.test.tsx | 236 ++++++++++++++++ .../admin/shared/AccountLifecycleParts.tsx | 146 ++++++++++ .../hooks/management/useAccountLifecycle.ts | 105 +++++++ packages/react/src/index.ts | 11 + .../components/admin/admin-components.test.ts | 260 +++++++++++++++++- .../admin/AgentDetails/AgentDetails.ts | 22 +- .../admin/AgentDetails/BaseAgentDetails.ts | 52 +++- .../ApplicationDetails/ApplicationDetails.ts | 40 ++- .../BaseApplicationDetails.ts | 45 +++ .../admin/GroupMembers/GroupMembers.ts | 15 +- .../admin/RoleAssignments/RoleAssignments.ts | 15 +- .../admin/UserDetails/BaseUserDetails.ts | 49 +++- .../admin/UserDetails/UserDetails.ts | 39 ++- .../admin/shared/AccessStatusBadge.ts | 38 +++ .../admin/shared/AccountLifecycleButtons.ts | 144 ++++++++++ .../primitives/TextField/TextField.ts | 4 + .../management/useAccountLifecycle.ts | 109 ++++++++ packages/vue/src/index.ts | 11 + .../react/src/pages/explorer/adminStories.tsx | 69 ++++- .../vue/src/pages/explorer/adminStories.ts | 70 ++++- 48 files changed, 2257 insertions(+), 33 deletions(-) create mode 100644 packages/javascript/src/api/management/__tests__/accountLifecycleApi.test.ts create mode 100644 packages/javascript/src/api/management/administration/runAccountLifecycleAction.ts create mode 100644 packages/javascript/src/models/access-status.ts create mode 100644 packages/javascript/src/utils/__tests__/getAccountLifecycleActions.test.ts create mode 100644 packages/javascript/src/utils/getAccountLifecycleActions.ts create mode 100644 packages/react/src/components/admin/shared/AccountLifecycleParts.tsx create mode 100644 packages/react/src/hooks/management/useAccountLifecycle.ts create mode 100644 packages/vue/src/components/admin/shared/AccessStatusBadge.ts create mode 100644 packages/vue/src/components/admin/shared/AccountLifecycleButtons.ts create mode 100644 packages/vue/src/composables/management/useAccountLifecycle.ts diff --git a/packages/javascript/src/api/management/__tests__/accountLifecycleApi.test.ts b/packages/javascript/src/api/management/__tests__/accountLifecycleApi.test.ts new file mode 100644 index 00000000..4c0242f8 --- /dev/null +++ b/packages/javascript/src/api/management/__tests__/accountLifecycleApi.test.ts @@ -0,0 +1,95 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {describe, expect, it, vi, type Mock} from 'vitest'; +import {ApiFetcher} from '../../../models/api'; +import runAccountLifecycleAction from '../administration/runAccountLifecycleAction'; + +type FetcherMock = Mock; + +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; + +/** + * A fetcher that configures one administration flow, `handle`, under `configKey`, and completes every + * execution with the given data. + */ +const createServer = (configKey: string, handle: string, data: Record = {}): FetcherMock => + vi.fn((url: string) => { + if (url === `${baseUrl}/server-config/flow`) { + return Promise.resolve(respond({merged: {[configKey]: {defaultHandle: handle}}})); + } + if (url.startsWith(`${baseUrl}/flows?`)) { + return Promise.resolve(respond({flows: [{flowType: 'ADMINISTRATION', handle, id: `id-${handle}`}]})); + } + + return Promise.resolve(respond({data: {additionalData: data}, flowStatus: 'COMPLETE'})); + }); + +const execution = (fetcher: FetcherMock): unknown => { + const call: Parameters | undefined = fetcher.mock.calls.find( + ([url]: Parameters) => url === `${baseUrl}/flow/execute`, + ); + + return call ? (JSON.parse(call[1]?.body as string) as unknown) : undefined; +}; + +describe('runAccountLifecycleAction', () => { + it.each([ + ['user', 'suspend', 'userSuspendFlow'], + ['user', 'unsuspend', 'userUnsuspendFlow'], + ['user', 'unlock', 'userUnlockFlow'], + ['agent', 'suspend', 'agentSuspendFlow'], + ['agent', 'unsuspend', 'agentUnsuspendFlow'], + ['agent', 'unlock', 'agentUnlockFlow'], + ['application', 'suspend', 'applicationSuspendFlow'], + ['application', 'unsuspend', 'applicationUnsuspendFlow'], + ] as const)('runs the %s %s flow configured under %s', async (kind, action, configKey) => { + const fetcher: FetcherMock = createServer(configKey, 'lifecycle'); + + await runAccountLifecycleAction({action, baseUrl, fetcher, kind, subject: 's-1'}); + + expect(execution(fetcher)).toEqual({flowId: 'id-lifecycle', inputs: {subject: 's-1'}}); + }); + + it('passes the operator note and reports whether the notification was sent', async () => { + const fetcher: FetcherMock = createServer('userSuspendFlow', 'suspend', {emailSent: 'false'}); + + await expect( + runAccountLifecycleAction({ + action: 'suspend', + baseUrl, + fetcher, + kind: 'user', + operatorNote: 'Ticket 4521', + subject: 'u-1', + }), + ).resolves.toEqual({emailSent: false}); + expect(execution(fetcher)).toEqual({flowId: 'id-suspend', inputs: {operatorNote: 'Ticket 4521', subject: 'u-1'}}); + }); + + it('fails with the operation error when no flow is configured', async () => { + const fetcher: FetcherMock = vi.fn(() => Promise.resolve(respond({merged: {}}))); + + await expect( + runAccountLifecycleAction({action: 'unlock', baseUrl, fetcher, kind: 'agent', subject: 'a-1'}), + ).rejects.toMatchObject({code: 'unlockAgent-NotConfiguredError-001'}); + }); + + it('refuses to unlock an application without calling the server', async () => { + const fetcher: FetcherMock = vi.fn(); + + await expect( + runAccountLifecycleAction({action: 'unlock', baseUrl, fetcher, kind: 'application', subject: 'app-1'}), + ).rejects.toMatchObject({code: 'runAccountLifecycleAction-ValidationError-001'}); + expect(fetcher).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/javascript/src/api/management/administration/runAccountLifecycleAction.ts b/packages/javascript/src/api/management/administration/runAccountLifecycleAction.ts new file mode 100644 index 00000000..5a1a27d2 --- /dev/null +++ b/packages/javascript/src/api/management/administration/runAccountLifecycleAction.ts @@ -0,0 +1,94 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import ThunderIDAPIError from '../../../errors/ThunderIDAPIError'; +import { + AccountLifecycleAction, + AccountLifecycleOptions, + AccountLifecycleResult, + AccountLifecycleSubjectKind, +} from '../../../models/administration'; +import {ManagementRequestConfig} from '../../../models/api'; +import suspendAgent from '../agents/suspendAgent'; +import unlockAgent from '../agents/unlockAgent'; +import unsuspendAgent from '../agents/unsuspendAgent'; +import suspendApplication from '../applications/suspendApplication'; +import unsuspendApplication from '../applications/unsuspendApplication'; +import suspendUser from '../users/suspendUser'; +import unlockUser from '../users/unlockUser'; +import unsuspendUser from '../users/unsuspendUser'; + +/** + * Configuration for the runAccountLifecycleAction request. + */ +export interface RunAccountLifecycleActionConfig extends ManagementRequestConfig, AccountLifecycleOptions { + action: AccountLifecycleAction; + kind: AccountLifecycleSubjectKind; + /** + * Identifier of the user, agent or application. + */ + subject: string; +} + +type LifecycleOperation = ( + config: ManagementRequestConfig & AccountLifecycleOptions, + subject: string, +) => Promise; + +/** + * The operation behind each action, by the kind of identity. Applications are never locked. + */ +const OPERATIONS: Record>> = { + agent: { + suspend: (config, agentId) => suspendAgent({...config, agentId}), + unlock: (config, agentId) => unlockAgent({...config, agentId}), + unsuspend: (config, agentId) => unsuspendAgent({...config, agentId}), + }, + application: { + suspend: (config, applicationId) => suspendApplication({...config, applicationId}), + unsuspend: (config, applicationId) => unsuspendApplication({...config, applicationId}), + }, + user: { + suspend: (config, userId) => suspendUser({...config, userId}), + unlock: (config, userId) => unlockUser({...config, userId}), + unsuspend: (config, userId) => unsuspendUser({...config, userId}), + }, +}; + +/** + * Runs an account lifecycle action against a user, agent or application, through the operation for that + * kind and action (for example `suspendUser`). Each raises its own errors, so a missing flow fails with + * that operation's `NotConfiguredError`. An action that does not apply to the kind, such as unlocking an + * application, is refused with `runAccountLifecycleAction-ValidationError-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 Whether the flow's email step sent its notification, absent when the flow has no email step. + * + * @example + * ```typescript + * await runAccountLifecycleAction({action: 'suspend', baseUrl, kind: 'user', operatorNote: 'Ticket 4521', subject: userId}); + * ``` + */ +const runAccountLifecycleAction = async ({ + action, + kind, + subject, + ...config +}: RunAccountLifecycleActionConfig): Promise => { + const operation: LifecycleOperation | undefined = OPERATIONS[kind]?.[action]; + + if (!operation) { + throw new ThunderIDAPIError( + `The ${action} action does not apply to ${kind} accounts.`, + 'runAccountLifecycleAction-ValidationError-001', + 'javascript', + 400, + `The ${action} action does not apply to the ${kind} kind.`, + ); + } + + return operation(config, subject); +}; + +export default runAccountLifecycleAction; diff --git a/packages/javascript/src/constants/AdministrationConstants.ts b/packages/javascript/src/constants/AdministrationConstants.ts index d793225d..6d437f55 100644 --- a/packages/javascript/src/constants/AdministrationConstants.ts +++ b/packages/javascript/src/constants/AdministrationConstants.ts @@ -59,6 +59,31 @@ export const AdministrationFlowConfigKey: { USER_UNSUSPEND: 'userUnsuspendFlow', }; +/** + * The `flow` server-config key naming the flow of each account lifecycle action, by the kind of identity it + * acts on. Applications are never locked, so they have no unlock. + */ +export const AccountLifecycleFlowConfigKey: { + agent: {suspend: 'agentSuspendFlow'; unlock: 'agentUnlockFlow'; unsuspend: 'agentUnsuspendFlow'}; + application: {suspend: 'applicationSuspendFlow'; unsuspend: 'applicationUnsuspendFlow'}; + user: {suspend: 'userSuspendFlow'; unlock: 'userUnlockFlow'; unsuspend: 'userUnsuspendFlow'}; +} = { + agent: { + suspend: AdministrationFlowConfigKey.AGENT_SUSPEND, + unlock: AdministrationFlowConfigKey.AGENT_UNLOCK, + unsuspend: AdministrationFlowConfigKey.AGENT_UNSUSPEND, + }, + application: { + suspend: AdministrationFlowConfigKey.APPLICATION_SUSPEND, + unsuspend: AdministrationFlowConfigKey.APPLICATION_UNSUSPEND, + }, + user: { + suspend: AdministrationFlowConfigKey.USER_SUSPEND, + unlock: AdministrationFlowConfigKey.USER_UNLOCK, + unsuspend: AdministrationFlowConfigKey.USER_UNSUSPEND, + }, +}; + /** * 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. diff --git a/packages/javascript/src/i18n/models/i18n.ts b/packages/javascript/src/i18n/models/i18n.ts index 0901450d..e4a3ba49 100644 --- a/packages/javascript/src/i18n/models/i18n.ts +++ b/packages/javascript/src/i18n/models/i18n.ts @@ -214,8 +214,20 @@ export interface I18nTranslations { 'admin.common.actions.cancel': string; 'admin.common.actions.confirm': string; 'admin.common.actions.remove': string; + 'admin.common.status.active': string; + 'admin.common.status.locked': string; + 'admin.common.status.suspended': string; + 'admin.common.lifecycle.suspend': string; + 'admin.common.lifecycle.unsuspend': string; + 'admin.common.lifecycle.unlock': string; + 'admin.common.lifecycle.suspend_confirm': string; + 'admin.common.lifecycle.unsuspend_confirm': string; + 'admin.common.lifecycle.operator_note': string; + 'admin.common.lifecycle.error': string; + 'admin.common.lifecycle.load_error': string; 'admin.common.columns.actions': string; 'admin.common.columns.organization_unit': string; + 'admin.common.columns.status': string; 'admin.common.errors.forbidden': string; 'admin.common.errors.not_found': string; 'admin.common.errors.no_ou': string; diff --git a/packages/javascript/src/i18n/translations/en-US.ts b/packages/javascript/src/i18n/translations/en-US.ts index 193659c6..d43667b1 100644 --- a/packages/javascript/src/i18n/translations/en-US.ts +++ b/packages/javascript/src/i18n/translations/en-US.ts @@ -211,8 +211,21 @@ const translations: I18nTranslations = { 'admin.common.actions.cancel': 'Cancel', 'admin.common.actions.confirm': 'Confirm', 'admin.common.actions.remove': 'Remove', + 'admin.common.status.active': 'Active', + 'admin.common.status.locked': 'Locked', + 'admin.common.status.suspended': 'Suspended', + 'admin.common.lifecycle.suspend': 'Suspend', + 'admin.common.lifecycle.unsuspend': 'Unsuspend', + 'admin.common.lifecycle.unlock': 'Unlock', + 'admin.common.lifecycle.suspend_confirm': + 'Suspend {name}? It can no longer sign in, and its sessions and tokens are revoked.', + 'admin.common.lifecycle.unsuspend_confirm': 'Unsuspend {name}? Its access is restored, unless it is also locked.', + 'admin.common.lifecycle.operator_note': 'Note (optional)', + 'admin.common.lifecycle.error': 'The action could not be completed.', + 'admin.common.lifecycle.load_error': 'The account actions could not be loaded.', 'admin.common.columns.actions': 'Actions', 'admin.common.columns.organization_unit': 'Organization unit', + 'admin.common.columns.status': 'Status', 'admin.common.errors.forbidden': 'You do not have permission to do this.', 'admin.common.errors.not_found': 'This item could not be found.', 'admin.common.errors.no_ou': 'Choose an organization unit before creating this item.', diff --git a/packages/javascript/src/i18n/translations/fr-FR.ts b/packages/javascript/src/i18n/translations/fr-FR.ts index 4cdfcb9f..980e66a9 100644 --- a/packages/javascript/src/i18n/translations/fr-FR.ts +++ b/packages/javascript/src/i18n/translations/fr-FR.ts @@ -218,8 +218,22 @@ const translations: I18nTranslations = { 'admin.common.actions.cancel': 'Annuler', 'admin.common.actions.confirm': 'Confirmer', 'admin.common.actions.remove': 'Retirer', + 'admin.common.status.active': 'Actif', + 'admin.common.status.locked': 'Verrouillé', + 'admin.common.status.suspended': 'Suspendu', + 'admin.common.lifecycle.suspend': 'Suspendre', + 'admin.common.lifecycle.unsuspend': 'Lever la suspension', + 'admin.common.lifecycle.unlock': 'Déverrouiller', + 'admin.common.lifecycle.suspend_confirm': + 'Suspendre {name} ? Il ne pourra plus se connecter, et ses sessions et jetons seront révoqués.', + 'admin.common.lifecycle.unsuspend_confirm': + "Lever la suspension de {name} ? Son accès est rétabli, sauf s'il est aussi verrouillé.", + 'admin.common.lifecycle.operator_note': 'Note (facultatif)', + 'admin.common.lifecycle.error': "L'action n'a pas pu être effectuée.", + 'admin.common.lifecycle.load_error': "Les actions du compte n'ont pas pu être chargées.", 'admin.common.columns.actions': 'Actions', 'admin.common.columns.organization_unit': 'Unité organisationnelle', + 'admin.common.columns.status': 'Statut', 'admin.common.errors.forbidden': "Vous n'avez pas l'autorisation d'effectuer cette action.", 'admin.common.errors.not_found': 'Cet élément est introuvable.', 'admin.common.errors.no_ou': 'Choisissez une unité organisationnelle avant de créer cet élément.', diff --git a/packages/javascript/src/i18n/translations/hi-IN.ts b/packages/javascript/src/i18n/translations/hi-IN.ts index a37e5232..681a314e 100644 --- a/packages/javascript/src/i18n/translations/hi-IN.ts +++ b/packages/javascript/src/i18n/translations/hi-IN.ts @@ -208,8 +208,22 @@ const translations: I18nTranslations = { 'admin.common.actions.cancel': 'रद्द करें', 'admin.common.actions.confirm': 'पुष्टि करें', 'admin.common.actions.remove': 'हटाएं', + 'admin.common.status.active': 'सक्रिय', + 'admin.common.status.locked': 'लॉक किया गया', + 'admin.common.status.suspended': 'निलंबित', + 'admin.common.lifecycle.suspend': 'निलंबित करें', + 'admin.common.lifecycle.unsuspend': 'निलंबन हटाएं', + 'admin.common.lifecycle.unlock': 'अनलॉक करें', + 'admin.common.lifecycle.suspend_confirm': + '{name} को निलंबित करें? यह अब साइन इन नहीं कर पाएगा, और इसके सत्र और टोकन रद्द कर दिए जाएंगे।', + 'admin.common.lifecycle.unsuspend_confirm': + '{name} का निलंबन हटाएं? इसकी पहुंच बहाल हो जाएगी, जब तक कि यह लॉक भी न हो।', + 'admin.common.lifecycle.operator_note': 'नोट (वैकल्पिक)', + 'admin.common.lifecycle.error': 'कार्रवाई पूरी नहीं हो सकी।', + 'admin.common.lifecycle.load_error': 'खाता कार्रवाइयां लोड नहीं हो सकीं।', 'admin.common.columns.actions': 'कार्रवाइयाँ', 'admin.common.columns.organization_unit': 'संगठन इकाई', + 'admin.common.columns.status': 'स्थिति', 'admin.common.errors.forbidden': 'आपको यह करने की अनुमति नहीं है।', 'admin.common.errors.not_found': 'यह आइटम नहीं मिला।', 'admin.common.errors.no_ou': 'यह आइटम बनाने से पहले एक संगठन इकाई चुनें।', diff --git a/packages/javascript/src/i18n/translations/ja-JP.ts b/packages/javascript/src/i18n/translations/ja-JP.ts index 3df8f99d..6193a1ab 100644 --- a/packages/javascript/src/i18n/translations/ja-JP.ts +++ b/packages/javascript/src/i18n/translations/ja-JP.ts @@ -209,8 +209,22 @@ const translations: I18nTranslations = { 'admin.common.actions.cancel': 'キャンセル', 'admin.common.actions.confirm': '確認', 'admin.common.actions.remove': '削除', + 'admin.common.status.active': '有効', + 'admin.common.status.locked': 'ロック中', + 'admin.common.status.suspended': '停止中', + 'admin.common.lifecycle.suspend': '停止', + 'admin.common.lifecycle.unsuspend': '停止を解除', + 'admin.common.lifecycle.unlock': 'ロックを解除', + 'admin.common.lifecycle.suspend_confirm': + '{name} を停止しますか?サインインできなくなり、セッションとトークンは失効します。', + 'admin.common.lifecycle.unsuspend_confirm': + '{name} の停止を解除しますか?ロックされていない限り、アクセスが復元されます。', + 'admin.common.lifecycle.operator_note': 'メモ(任意)', + 'admin.common.lifecycle.error': '操作を完了できませんでした。', + 'admin.common.lifecycle.load_error': 'アカウントの操作を読み込めませんでした。', 'admin.common.columns.actions': '操作', 'admin.common.columns.organization_unit': '組織単位', + 'admin.common.columns.status': 'ステータス', 'admin.common.errors.forbidden': 'この操作を行う権限がありません。', 'admin.common.errors.not_found': 'この項目は見つかりませんでした。', 'admin.common.errors.no_ou': 'この項目を作成する前に組織単位を選択してください。', diff --git a/packages/javascript/src/i18n/translations/pt-BR.ts b/packages/javascript/src/i18n/translations/pt-BR.ts index 4b4745a3..c65f1199 100644 --- a/packages/javascript/src/i18n/translations/pt-BR.ts +++ b/packages/javascript/src/i18n/translations/pt-BR.ts @@ -212,8 +212,22 @@ const translations: I18nTranslations = { 'admin.common.actions.cancel': 'Cancelar', 'admin.common.actions.confirm': 'Confirmar', 'admin.common.actions.remove': 'Remover', + 'admin.common.status.active': 'Ativo', + 'admin.common.status.locked': 'Bloqueado', + 'admin.common.status.suspended': 'Suspenso', + 'admin.common.lifecycle.suspend': 'Suspender', + 'admin.common.lifecycle.unsuspend': 'Remover suspensão', + 'admin.common.lifecycle.unlock': 'Desbloquear', + 'admin.common.lifecycle.suspend_confirm': + 'Suspender {name}? Ele não poderá mais entrar, e suas sessões e tokens serão revogados.', + 'admin.common.lifecycle.unsuspend_confirm': + 'Remover a suspensão de {name}? O acesso é restaurado, a menos que também esteja bloqueado.', + 'admin.common.lifecycle.operator_note': 'Observação (opcional)', + 'admin.common.lifecycle.error': 'Não foi possível concluir a ação.', + 'admin.common.lifecycle.load_error': 'Não foi possível carregar as ações da conta.', 'admin.common.columns.actions': 'Ações', 'admin.common.columns.organization_unit': 'Unidade organizacional', + 'admin.common.columns.status': 'Status', 'admin.common.errors.forbidden': 'Você não tem permissão para fazer isso.', 'admin.common.errors.not_found': 'Este item não foi encontrado.', 'admin.common.errors.no_ou': 'Escolha uma unidade organizacional antes de criar este item.', diff --git a/packages/javascript/src/i18n/translations/pt-PT.ts b/packages/javascript/src/i18n/translations/pt-PT.ts index e7253790..2868d50d 100644 --- a/packages/javascript/src/i18n/translations/pt-PT.ts +++ b/packages/javascript/src/i18n/translations/pt-PT.ts @@ -213,8 +213,22 @@ const translations: I18nTranslations = { 'admin.common.actions.cancel': 'Cancelar', 'admin.common.actions.confirm': 'Confirmar', 'admin.common.actions.remove': 'Remover', + 'admin.common.status.active': 'Ativo', + 'admin.common.status.locked': 'Bloqueado', + 'admin.common.status.suspended': 'Suspenso', + 'admin.common.lifecycle.suspend': 'Suspender', + 'admin.common.lifecycle.unsuspend': 'Levantar suspensão', + 'admin.common.lifecycle.unlock': 'Desbloquear', + 'admin.common.lifecycle.suspend_confirm': + 'Suspender {name}? Deixa de poder iniciar sessão, e as suas sessões e tokens são revogados.', + 'admin.common.lifecycle.unsuspend_confirm': + 'Levantar a suspensão de {name}? O acesso é reposto, a menos que também esteja bloqueado.', + 'admin.common.lifecycle.operator_note': 'Nota (opcional)', + 'admin.common.lifecycle.error': 'Não foi possível concluir a ação.', + 'admin.common.lifecycle.load_error': 'Não foi possível carregar as ações da conta.', 'admin.common.columns.actions': 'Ações', 'admin.common.columns.organization_unit': 'Unidade organizacional', + 'admin.common.columns.status': 'Estado', 'admin.common.errors.forbidden': 'Não tem permissão para fazer isto.', 'admin.common.errors.not_found': 'Não foi possível encontrar este item.', 'admin.common.errors.no_ou': 'Escolha uma unidade organizacional antes de criar este item.', diff --git a/packages/javascript/src/i18n/translations/si-LK.ts b/packages/javascript/src/i18n/translations/si-LK.ts index 6637ec9b..e6309a9e 100644 --- a/packages/javascript/src/i18n/translations/si-LK.ts +++ b/packages/javascript/src/i18n/translations/si-LK.ts @@ -211,8 +211,22 @@ const translations: I18nTranslations = { 'admin.common.actions.cancel': 'අවලංගු කරන්න', 'admin.common.actions.confirm': 'තහවුරු කරන්න', 'admin.common.actions.remove': 'ඉවත් කරන්න', + 'admin.common.status.active': 'සක්‍රීය', + 'admin.common.status.locked': 'අගුළු දමා ඇත', + 'admin.common.status.suspended': 'අත්හිටුවා ඇත', + 'admin.common.lifecycle.suspend': 'අත්හිටුවන්න', + 'admin.common.lifecycle.unsuspend': 'අත්හිටුවීම ඉවත් කරන්න', + 'admin.common.lifecycle.unlock': 'අගුළු හරින්න', + 'admin.common.lifecycle.suspend_confirm': + '{name} අත්හිටුවන්නද? එයට තවදුරටත් පුරනය විය නොහැකි අතර, එහි සැසි සහ ටෝකන අවලංගු කෙරේ.', + 'admin.common.lifecycle.unsuspend_confirm': + '{name} හි අත්හිටුවීම ඉවත් කරන්නද? එය අගුළු දමා නොමැති නම්, ප්‍රවේශය යළි ලැබේ.', + 'admin.common.lifecycle.operator_note': 'සටහන (විකල්ප)', + 'admin.common.lifecycle.error': 'ක්‍රියාව සම්පූර්ණ කළ නොහැකි විය.', + 'admin.common.lifecycle.load_error': 'ගිණුම් ක්‍රියා පූරණය කළ නොහැකි විය.', 'admin.common.columns.actions': 'ක්‍රියා', 'admin.common.columns.organization_unit': 'සංවිධාන ඒකකය', + 'admin.common.columns.status': 'තත්ත්වය', 'admin.common.errors.forbidden': 'මෙය කිරීමට ඔබට අවසර නැත.', 'admin.common.errors.not_found': 'මෙම අයිතමය සොයාගත නොහැකි විය.', 'admin.common.errors.no_ou': 'මෙම අයිතමය සෑදීමට පෙර සංවිධාන ඒකකයක් තෝරන්න.', diff --git a/packages/javascript/src/i18n/translations/ta-IN.ts b/packages/javascript/src/i18n/translations/ta-IN.ts index 24b40dc3..2586a05e 100644 --- a/packages/javascript/src/i18n/translations/ta-IN.ts +++ b/packages/javascript/src/i18n/translations/ta-IN.ts @@ -213,8 +213,22 @@ const translations: I18nTranslations = { 'admin.common.actions.cancel': 'ரத்துசெய்', 'admin.common.actions.confirm': 'உறுதிப்படுத்து', 'admin.common.actions.remove': 'அகற்று', + 'admin.common.status.active': 'செயலில்', + 'admin.common.status.locked': 'பூட்டப்பட்டது', + 'admin.common.status.suspended': 'இடைநிறுத்தப்பட்டது', + 'admin.common.lifecycle.suspend': 'இடைநிறுத்து', + 'admin.common.lifecycle.unsuspend': 'இடைநிறுத்தத்தை நீக்கு', + 'admin.common.lifecycle.unlock': 'பூட்டை நீக்கு', + 'admin.common.lifecycle.suspend_confirm': + '{name} ஐ இடைநிறுத்தவா? இனி உள்நுழைய முடியாது, அதன் அமர்வுகளும் டோக்கன்களும் திரும்பப் பெறப்படும்.', + 'admin.common.lifecycle.unsuspend_confirm': + '{name} இன் இடைநிறுத்தத்தை நீக்கவா? பூட்டப்படாவிட்டால் அணுகல் மீட்டமைக்கப்படும்.', + 'admin.common.lifecycle.operator_note': 'குறிப்பு (விருப்பத்தேர்வு)', + 'admin.common.lifecycle.error': 'செயலை முடிக்க முடியவில்லை.', + 'admin.common.lifecycle.load_error': 'கணக்கு செயல்களை ஏற்ற முடியவில்லை.', 'admin.common.columns.actions': 'செயல்கள்', 'admin.common.columns.organization_unit': 'நிறுவன அலகு', + 'admin.common.columns.status': 'நிலை', 'admin.common.errors.forbidden': 'இதைச் செய்ய உங்களுக்கு அனுமதி இல்லை.', 'admin.common.errors.not_found': 'இந்த உருப்படியைக் கண்டறிய முடியவில்லை.', 'admin.common.errors.no_ou': 'இந்த உருப்படியை உருவாக்கும் முன் ஒரு நிறுவன அலகைத் தேர்ந்தெடுக்கவும்.', diff --git a/packages/javascript/src/i18n/translations/te-IN.ts b/packages/javascript/src/i18n/translations/te-IN.ts index 95bdef61..9d2a6b82 100644 --- a/packages/javascript/src/i18n/translations/te-IN.ts +++ b/packages/javascript/src/i18n/translations/te-IN.ts @@ -213,8 +213,22 @@ const translations: I18nTranslations = { 'admin.common.actions.cancel': 'రద్దు చేయి', 'admin.common.actions.confirm': 'నిర్ధారించు', 'admin.common.actions.remove': 'తొలగించు', + 'admin.common.status.active': 'సక్రియం', + 'admin.common.status.locked': 'లాక్ చేయబడింది', + 'admin.common.status.suspended': 'నిలిపివేయబడింది', + 'admin.common.lifecycle.suspend': 'నిలిపివేయి', + 'admin.common.lifecycle.unsuspend': 'నిలిపివేతను తొలగించు', + 'admin.common.lifecycle.unlock': 'అన్‌లాక్ చేయి', + 'admin.common.lifecycle.suspend_confirm': + '{name} ను నిలిపివేయాలా? ఇకపై సైన్ ఇన్ చేయలేదు, దాని సెషన్‌లు మరియు టోకెన్‌లు రద్దు చేయబడతాయి.', + 'admin.common.lifecycle.unsuspend_confirm': + '{name} నిలిపివేతను తొలగించాలా? లాక్ చేయబడకపోతే యాక్సెస్ పునరుద్ధరించబడుతుంది.', + 'admin.common.lifecycle.operator_note': 'గమనిక (ఐచ్ఛికం)', + 'admin.common.lifecycle.error': 'చర్యను పూర్తి చేయలేకపోయాము.', + 'admin.common.lifecycle.load_error': 'ఖాతా చర్యలను లోడ్ చేయలేకపోయాము.', 'admin.common.columns.actions': 'చర్యలు', 'admin.common.columns.organization_unit': 'సంస్థ యూనిట్', + 'admin.common.columns.status': 'స్థితి', 'admin.common.errors.forbidden': 'దీన్ని చేయడానికి మీకు అనుమతి లేదు.', 'admin.common.errors.not_found': 'ఈ అంశం కనుగొనబడలేదు.', 'admin.common.errors.no_ou': 'ఈ అంశాన్ని సృష్టించే ముందు ఒక సంస్థ యూనిట్‌ను ఎంచుకోండి.', diff --git a/packages/javascript/src/index.ts b/packages/javascript/src/index.ts index ec501d2f..01acaec3 100644 --- a/packages/javascript/src/index.ts +++ b/packages/javascript/src/index.ts @@ -388,6 +388,8 @@ export {default as suspendApplication} from './api/management/applications/suspe 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 runAccountLifecycleAction} from './api/management/administration/runAccountLifecycleAction'; +export type {RunAccountLifecycleActionConfig} from './api/management/administration/runAccountLifecycleAction'; 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'; @@ -408,6 +410,7 @@ export {default as GroupQueryKeys} from './constants/GroupQueryKeys'; export { default as AdministrationModes, ADMINISTRATION_FLOW_TYPE, + AccountLifecycleFlowConfigKey, AdministrationFlowConfigKey, AdministrationFlowData, AdministrationFlowInput, @@ -590,6 +593,13 @@ export type { UserInfoResponseType, CimdPreviewResponse, } from './models/application'; +export type { + AccessHoldReason, + AccessLockedMethod, + AccessStatus, + AccessStatusDetails, + AccessStatusValue, +} from './models/access-status'; export type { CreateManagedUserRequest, CreateUserByPathRequest, @@ -601,8 +611,10 @@ export type { UserUsagesResponse, } from './models/managed-user'; export type { + AccountLifecycleAction, AccountLifecycleOptions, AccountLifecycleResult, + AccountLifecycleSubjectKind, AdministrationFlowConfig, AdministrationFlowConfigKeyValue, AdministrationFlowExecutionResponse, @@ -854,6 +866,7 @@ export {default as toCreateApplicationRequest} from './utils/toCreateApplication export {default as toUpdateApplicationRequest} from './utils/toUpdateApplicationRequest'; export {default as toManagementErrorMessage} from './utils/toManagementErrorMessage'; export {default as resolveManagedIdentityName} from './utils/resolveManagedIdentityName'; +export {default as getAccountLifecycleActions} from './utils/getAccountLifecycleActions'; export {default as hasPermission} from './utils/hasPermission'; export {default as arrayBufferToBase64url} from './utils/arrayBufferToBase64url'; export {default as base64urlToArrayBuffer} from './utils/base64urlToArrayBuffer'; diff --git a/packages/javascript/src/models/access-status.ts b/packages/javascript/src/models/access-status.ts new file mode 100644 index 00000000..f7e1d808 --- /dev/null +++ b/packages/javascript/src/models/access-status.ts @@ -0,0 +1,58 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +/** + * An identity's effective access status, with precedence `SUSPENDED` over `LOCKED` over `ACTIVE`. + * Applications are never `LOCKED`. + */ +export type AccessStatusValue = 'ACTIVE' | 'LOCKED' | 'SUSPENDED'; + +/** + * What put an identity into the status it reports. + */ +export type AccessHoldReason = 'ADMINISTRATIVE_SUSPENSION' | 'FAILED_ATTEMPTS' | 'SUSPENSION_RELEASE_LOCK'; + +/** + * One live automatic lock: the authentication method it holds, and when it lifts. + */ +export interface AccessLockedMethod { + /** + * When the lock lifts on its own. Absent when it lasts until released. + */ + expiresAt?: string; + /** + * The authentication method the lock holds, or `account` for all of them. + */ + method: string; +} + +/** + * What a management read says about a status beyond its value. + */ +export interface AccessStatusDetails { + /** + * Every live automatic lock. Absent for a status that is not a lock. + */ + lockedMethods?: AccessLockedMethod[]; + /** + * The operator's note on an administrative hold. Render it as text, never as markup. + */ + operatorNote?: string; + reason?: AccessHoldReason; + /** + * When an administrative hold was placed. Absent for an automatic lock. + */ + since?: string; +} + +/** + * The access status a management read of a user or an agent carries. The server computes it on read + * and never accepts it on a write. + */ +export interface AccessStatus { + /** + * What produced the status. Absent when there is nothing to add. + */ + details?: AccessStatusDetails; + value: AccessStatusValue; +} diff --git a/packages/javascript/src/models/administration.ts b/packages/javascript/src/models/administration.ts index 3da9e36e..77a5828d 100644 --- a/packages/javascript/src/models/administration.ts +++ b/packages/javascript/src/models/administration.ts @@ -85,3 +85,13 @@ export interface AccountLifecycleResult { */ emailSent?: boolean; } + +/** + * A kind of identity the account lifecycle operations act on. + */ +export type AccountLifecycleSubjectKind = 'agent' | 'application' | 'user'; + +/** + * An account lifecycle operation an operator can run. Locking is automatic only, so there is no lock. + */ +export type AccountLifecycleAction = 'suspend' | 'unlock' | 'unsuspend'; diff --git a/packages/javascript/src/models/agent-resource.ts b/packages/javascript/src/models/agent-resource.ts index 1b75af15..98faaa40 100644 --- a/packages/javascript/src/models/agent-resource.ts +++ b/packages/javascript/src/models/agent-resource.ts @@ -1,6 +1,7 @@ // Copyright 2026 The ThunderID Authors // SPDX-License-Identifier: Apache-2.0 +import {AccessStatus} from './access-status'; import {AssertionConfig, OAuth2Config} from './application'; /** @@ -43,6 +44,10 @@ export interface Agent { ouId: string; owner?: string; registrationFlowId?: string; + /** + * The effective access status, set by the server on a read. + */ + status?: AccessStatus; type: string; } diff --git a/packages/javascript/src/models/managed-user.ts b/packages/javascript/src/models/managed-user.ts index 83166b6f..1418ee94 100644 --- a/packages/javascript/src/models/managed-user.ts +++ b/packages/javascript/src/models/managed-user.ts @@ -1,6 +1,7 @@ // Copyright 2025-2026 The ThunderID Authors // SPDX-License-Identifier: Apache-2.0 +import {AccessStatus} from './access-status'; import {ApiPaginationLink} from './api'; /** @@ -15,6 +16,10 @@ export interface ManagedUser { isReadOnly?: boolean; ouHandle?: string; ouId: string; + /** + * The effective access status, set by the server on a read. + */ + status?: AccessStatus; type: string; } diff --git a/packages/javascript/src/utils/__tests__/getAccountLifecycleActions.test.ts b/packages/javascript/src/utils/__tests__/getAccountLifecycleActions.test.ts new file mode 100644 index 00000000..dcccc54e --- /dev/null +++ b/packages/javascript/src/utils/__tests__/getAccountLifecycleActions.test.ts @@ -0,0 +1,49 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {describe, expect, it} from 'vitest'; +import getAccountLifecycleActions from '../getAccountLifecycleActions'; + +const allUserFlows: Record = { + userSuspendFlow: 'f-suspend', + userUnlockFlow: 'f-unlock', + userUnsuspendFlow: 'f-unsuspend', +}; + +describe('getAccountLifecycleActions', () => { + it('offers the actions each status allows, most expected first', () => { + expect(getAccountLifecycleActions('user', 'ACTIVE', allUserFlows)).toEqual(['suspend']); + expect(getAccountLifecycleActions('user', 'LOCKED', allUserFlows)).toEqual(['unlock', 'suspend']); + expect(getAccountLifecycleActions('user', 'SUSPENDED', allUserFlows)).toEqual(['unsuspend']); + }); + + it('reads an identity with no status as active', () => { + expect(getAccountLifecycleActions('user', undefined, allUserFlows)).toEqual(['suspend']); + }); + + it('offers only the actions whose flow is configured', () => { + expect(getAccountLifecycleActions('user', 'LOCKED', {userSuspendFlow: 'f-suspend'})).toEqual(['suspend']); + expect(getAccountLifecycleActions('user', 'ACTIVE', {})).toEqual([]); + }); + + it('reads the flows of the identity kind it is given', () => { + expect(getAccountLifecycleActions('agent', 'ACTIVE', allUserFlows)).toEqual([]); + expect(getAccountLifecycleActions('agent', 'LOCKED', {agentSuspendFlow: 'a', agentUnlockFlow: 'b'})).toEqual([ + 'unlock', + 'suspend', + ]); + }); + + it('never offers an unlock for an application', () => { + expect( + getAccountLifecycleActions('application', 'LOCKED', { + applicationSuspendFlow: 'a', + applicationUnsuspendFlow: 'b', + }), + ).toEqual(['suspend']); + }); + + it('offers nothing for a status it does not know', () => { + expect(getAccountLifecycleActions('user', 'ARCHIVED' as never, allUserFlows)).toEqual([]); + }); +}); diff --git a/packages/javascript/src/utils/getAccountLifecycleActions.ts b/packages/javascript/src/utils/getAccountLifecycleActions.ts new file mode 100644 index 00000000..87f4d301 --- /dev/null +++ b/packages/javascript/src/utils/getAccountLifecycleActions.ts @@ -0,0 +1,49 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {AccountLifecycleFlowConfigKey} from '../constants/AdministrationConstants'; +import {AccessStatusValue} from '../models/access-status'; +import {AccountLifecycleAction, AccountLifecycleSubjectKind} from '../models/administration'; + +/** + * The actions each status offers, most expected first. A suspended identity offers no unlock, since a + * read does not show a lock beneath the suspension: once unsuspended, a remaining lock shows as `LOCKED`. + */ +const ACTIONS_BY_STATUS: Record = { + ACTIVE: ['suspend'], + LOCKED: ['unlock', 'suspend'], + SUSPENDED: ['unsuspend'], +}; + +/** + * The account lifecycle actions to offer for an identity: those its status allows, that apply to its + * kind, and whose administration flow is configured. These actions run only through their flows, so one + * with no flow is never offered. + * + * @param kind - The kind of identity. + * @param status - Its effective access status. An identity with none reads as `ACTIVE`. + * @param configuredFlows - The flows the server configures, keyed by `flow` server-config key, as + * `resolveAdministrationFlows` returns them. + * @returns The actions to offer, most expected first. + * + * @example + * ```typescript + * const flows = await resolveAdministrationFlows(config, Object.values(AccountLifecycleFlowConfigKey.user)); + * getAccountLifecycleActions('user', user.status?.value, flows); // ['unlock', 'suspend'] for a locked user + * ``` + */ +const getAccountLifecycleActions = ( + kind: AccountLifecycleSubjectKind, + status: AccessStatusValue | undefined, + configuredFlows: Record, +): AccountLifecycleAction[] => { + const configKeys: Partial> = AccountLifecycleFlowConfigKey[kind]; + + return (ACTIONS_BY_STATUS[status ?? 'ACTIVE'] ?? []).filter((action: AccountLifecycleAction) => { + const configKey: string | undefined = configKeys[action]; + + return configKey !== undefined && Boolean(configuredFlows[configKey]); + }); +}; + +export default getAccountLifecycleActions; diff --git a/packages/react/src/components/admin/AgentDetails/AgentDetails.tsx b/packages/react/src/components/admin/AgentDetails/AgentDetails.tsx index e3db14ec..7ac0162f 100644 --- a/packages/react/src/components/admin/AgentDetails/AgentDetails.tsx +++ b/packages/react/src/components/admin/AgentDetails/AgentDetails.tsx @@ -16,6 +16,7 @@ import { } from '@thunderid/browser'; import {FC, ReactElement} from 'react'; import BaseAgentDetails, {BaseAgentDetailsProps} from './BaseAgentDetails'; +import useAccountLifecycle, {AccountLifecycleState} from '../../../hooks/management/useAccountLifecycle'; import useManagementEndpoint, {ManagementEndpoint} from '../../../hooks/management/useManagementEndpoint'; import useResourceMutation, {ResourceMutationResult} from '../../../hooks/management/useResourceMutation'; import useResourceQuery, {ResourceQueryResult} from '../../../hooks/management/useResourceQuery'; @@ -23,7 +24,16 @@ import useTranslation from '../../../hooks/useTranslation'; export type AgentDetailsProps = Omit< BaseAgentDetailsProps, - 'agent' | 'error' | 'groups' | 'isDeleting' | 'isLoading' | 'onDelete' | 'roles' + | 'agent' + | 'error' + | 'groups' + | 'isDeleting' + | 'isLoading' + | 'isRunningLifecycleAction' + | 'lifecycleActions' + | 'onDelete' + | 'onLifecycleAction' + | 'roles' > & { agentId: string; /** @@ -31,6 +41,13 @@ export type AgentDetailsProps = Omit< * @default false */ deletable?: boolean; + /** + * Whether the agent can be suspended, unsuspended and unlocked from this view. Each action runs through its + * administration flow and is offered only when the server configures that flow. Reading the configured + * flows needs a token that may read the server configuration. + * @default false + */ + lifecycleActions?: boolean; /** * Called after the agent has been deleted. */ @@ -49,6 +66,7 @@ export type AgentDetailsProps = Omit< const AgentDetails: FC = ({ agentId, deletable = false, + lifecycleActions = false, onDeleted, preferences, ...rest @@ -76,6 +94,14 @@ const AgentDetails: FC = ({ {onSuccess: onDeleted}, ); + const lifecycle: AccountLifecycleState = useAccountLifecycle({ + enabled: lifecycleActions, + invalidatedKeys: [[AgentQueryKeys.AGENT, agentId], [AgentQueryKeys.AGENTS]], + kind: 'agent', + status: agent.data?.status?.value, + subject: agentId, + }); + return ( = ({ isLoading={agent.isLoading} error={ toManagementErrorMessage(t, agent.error, 'admin.agents.details.error') ?? - toManagementErrorMessage(t, deletion.error, 'admin.agents.details.delete.error') + toManagementErrorMessage(t, deletion.error, 'admin.agents.details.delete.error') ?? + toManagementErrorMessage(t, lifecycle.error, 'admin.common.lifecycle.error') ?? + toManagementErrorMessage(t, lifecycle.loadError, 'admin.common.lifecycle.load_error') } isDeleting={deletion.isLoading} onDelete={deletable ? (): void => void deletion.mutate(agentId) : undefined} + lifecycleActions={lifecycle.actions} + isRunningLifecycleAction={lifecycle.isRunning} + onLifecycleAction={lifecycleActions ? lifecycle.run : undefined} preferences={preferences} {...rest} /> diff --git a/packages/react/src/components/admin/AgentDetails/BaseAgentDetails.tsx b/packages/react/src/components/admin/AgentDetails/BaseAgentDetails.tsx index a2225b2f..13d2cbb6 100644 --- a/packages/react/src/components/admin/AgentDetails/BaseAgentDetails.tsx +++ b/packages/react/src/components/admin/AgentDetails/BaseAgentDetails.tsx @@ -1,12 +1,13 @@ // Copyright 2026 The ThunderID Authors // SPDX-License-Identifier: Apache-2.0 -import {Agent, MembershipGroup, Preferences} from '@thunderid/browser'; +import {AccountLifecycleAction, Agent, MembershipGroup, Preferences} from '@thunderid/browser'; import {FC, ReactElement, ReactNode} from 'react'; import useTranslation from '../../../hooks/useTranslation'; import Alert from '../../primitives/Alert/Alert'; import CopyableText from '../../primitives/CopyableText/CopyableText'; import Spinner from '../../primitives/Spinner/Spinner'; +import {AccessStatusBadge, AccountLifecycleButtons} from '../shared/AccountLifecycleParts'; import {ChipList, ConfirmButton, DetailList, Panel} from '../shared/AdminParts'; import AdminSection from '../shared/AdminSection'; import useAdminPartStyles from '../shared/useAdminPartStyles'; @@ -20,10 +21,19 @@ export interface BaseAgentDetailsRenderProps { groups: MembershipGroup[]; isDeleting: boolean; isLoading: boolean; + isRunningLifecycleAction: boolean; + /** + * The account lifecycle actions offered for the agent. Empty when none is allowed. + */ + lifecycleActions: AccountLifecycleAction[]; /** * Deletes the agent. Undefined when deletion is not allowed, including for read-only agents. */ onDelete?: () => void; + /** + * Runs an account lifecycle action, with the operator's note on a suspension. Undefined when none is allowed. + */ + onLifecycleAction?: (action: AccountLifecycleAction, operatorNote?: string) => void; roles: string[]; } @@ -46,10 +56,21 @@ export interface BaseAgentDetailsProps { groups?: MembershipGroup[]; isDeleting?: boolean; isLoading?: boolean; + isRunningLifecycleAction?: boolean; + /** + * The account lifecycle actions to offer (suspend, unsuspend, unlock), shown only with `onLifecycleAction`. They change + * only runtime state, so they apply to read-only agents too. + */ + lifecycleActions?: AccountLifecycleAction[]; /** * Deletes the agent. The delete action is hidden when omitted. */ onDelete?: () => void; + /** + * Runs an account lifecycle action. Suspending and unsuspending ask for confirmation first, and a + * suspension takes an optional operator note. + */ + onLifecycleAction?: (action: AccountLifecycleAction, operatorNote?: string) => void; preferences?: Preferences; /** * The names of the roles the agent holds, directly or through its groups. @@ -70,7 +91,10 @@ const BaseAgentDetails: FC = ({ groups = [], isDeleting = false, isLoading = false, + isRunningLifecycleAction = false, + lifecycleActions = [], onDelete, + onLifecycleAction, preferences, roles = [], }: BaseAgentDetailsProps): ReactElement => { @@ -86,7 +110,10 @@ const BaseAgentDetails: FC = ({ groups, isDeleting, isLoading, + isRunningLifecycleAction, + lifecycleActions: onLifecycleAction && agent ? lifecycleActions : [], onDelete: agent && !agent.isReadOnly ? onDelete : undefined, + onLifecycleAction: agent ? onLifecycleAction : undefined, roles, })} @@ -119,6 +146,10 @@ const BaseAgentDetails: FC = ({ {label: t('admin.common.columns.organization_unit'), value: agent.ouHandle ?? agent.ouId}, ]; + if (agent.status) { + details.push({label: t('admin.common.columns.status'), value: }); + } + if (agent.clientId) { details.push({label: t('admin.agents.list.columns.client_id'), value: }); } @@ -133,6 +164,15 @@ const BaseAgentDetails: FC = ({ actions={ <> {actions} + {onLifecycleAction && ( + + )} {onDelete && !agent.isReadOnly && ( & { + /** + * How the delete and the client secret regeneration run. `flow` runs the administration flow the server configures for it, which can revoke + * grants and end sessions first. It needs `baseUrl`. + * @default 'native' + */ + administrationMode?: AdministrationMode; applicationId: string; /** * Whether the application can be deleted from this view. @@ -52,6 +63,13 @@ export type ApplicationDetailsProps = Omit< * @default false */ editable?: boolean; + /** + * Whether the application can be suspended and unsuspended from this view. Each action runs through its + * administration flow and is offered only when the server configures that flow. Reading the configured + * flows needs a token that may read the server configuration. + * @default false + */ + lifecycleActions?: boolean; /** * Called after the application has been deleted. */ @@ -87,9 +105,11 @@ export type ApplicationDetailsProps = Omit< * ``` */ const ApplicationDetails: FC = ({ + administrationMode, applicationId, deletable = false, editable = false, + lifecycleActions = false, onDeleted, preferences, secretRegenerable = false, @@ -103,7 +123,7 @@ const ApplicationDetails: FC = ({ {enabled: Boolean(applicationId)}, ); const deletion: ResourceMutationResult = useResourceMutation( - (id: string, fetcher) => deleteApplication({...endpoint, applicationId: id, fetcher}), + (id: string, fetcher) => deleteApplication({...endpoint, administrationMode, applicationId: id, fetcher}), () => [[ApplicationQueryKeys.APPLICATIONS]], {onSuccess: onDeleted}, ); @@ -121,10 +141,18 @@ const ApplicationDetails: FC = ({ ], ); const regeneration: ResourceMutationResult = useResourceMutation( - (id: string, fetcher) => regenerateClientSecret({...endpoint, applicationId: id, fetcher}), + (id: string, fetcher) => regenerateClientSecret({...endpoint, administrationMode, applicationId: id, fetcher}), (id: string) => [[ApplicationQueryKeys.APPLICATION, id], [ApplicationQueryKeys.APPLICATIONS]], ); + const lifecycle: AccountLifecycleState = useAccountLifecycle({ + enabled: lifecycleActions, + invalidatedKeys: [[ApplicationQueryKeys.APPLICATION, applicationId], [ApplicationQueryKeys.APPLICATIONS]], + kind: 'application', + status: application.data?.status?.value, + subject: applicationId, + }); + return ( = ({ toManagementErrorMessage(t, application.error, 'admin.applications.details.error') ?? toManagementErrorMessage(t, deletion.error, 'admin.applications.details.delete.error') ?? toManagementErrorMessage(t, update.error, 'admin.applications.details.edit.error') ?? - toManagementErrorMessage(t, regeneration.error, 'admin.applications.details.secret.error') + toManagementErrorMessage(t, regeneration.error, 'admin.applications.details.secret.error') ?? + toManagementErrorMessage(t, lifecycle.error, 'admin.common.lifecycle.error') ?? + toManagementErrorMessage(t, lifecycle.loadError, 'admin.common.lifecycle.load_error') } isDeleting={deletion.isLoading} onDelete={deletable ? (): void => void deletion.mutate(applicationId) : undefined} + lifecycleActions={lifecycle.actions} + isRunningLifecycleAction={lifecycle.isRunning} + onLifecycleAction={lifecycleActions ? lifecycle.run : undefined} isUpdating={update.isLoading} onUpdate={ editable && application.data diff --git a/packages/react/src/components/admin/ApplicationDetails/BaseApplicationDetails.tsx b/packages/react/src/components/admin/ApplicationDetails/BaseApplicationDetails.tsx index 4cbc1be3..969a7813 100644 --- a/packages/react/src/components/admin/ApplicationDetails/BaseApplicationDetails.tsx +++ b/packages/react/src/components/admin/ApplicationDetails/BaseApplicationDetails.tsx @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { + AccountLifecycleAction, Application, APPLICATION_GRANT_TYPES, ApplicationSubmission, @@ -17,6 +18,7 @@ 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 {AccessStatusBadge, AccountLifecycleButtons} from '../shared/AccountLifecycleParts'; import {ChipList, ConfirmButton, DetailList, DetailListItem, Panel} from '../shared/AdminParts'; import AdminSection from '../shared/AdminSection'; import ApplicationFormFields, {ApplicationFormValues, ApplicationTextField} from '../shared/ApplicationFormFields'; @@ -43,7 +45,12 @@ export interface BaseApplicationDetailsRenderProps { isDeleting: boolean; isLoading: boolean; isRegeneratingSecret: boolean; + isRunningLifecycleAction: boolean; isUpdating: boolean; + /** + * The account lifecycle actions offered for the application. Empty when none is allowed. + */ + lifecycleActions: AccountLifecycleAction[]; /** * The application's OAuth configuration, without its client secret. Undefined when it has no OAuth client. */ @@ -52,6 +59,10 @@ export interface BaseApplicationDetailsRenderProps { * Deletes the application. Undefined when deletion is not allowed, including for read-only applications. */ onDelete?: () => void; + /** + * Runs an account lifecycle action. Undefined when none is allowed. + */ + onLifecycleAction?: (action: AccountLifecycleAction, operatorNote?: string) => void; /** * Gives the application a new client secret. Undefined when not allowed, for read-only applications * and for applications without a confidential OAuth client. @@ -87,11 +98,21 @@ export interface BaseApplicationDetailsProps { isDeleting?: boolean; isLoading?: boolean; isRegeneratingSecret?: boolean; + isRunningLifecycleAction?: boolean; isUpdating?: boolean; + /** + * The account lifecycle actions to offer (suspend, unsuspend; applications are never locked), shown only with `onLifecycleAction`. They change + * only runtime state, so they apply to read-only applications too. + */ + lifecycleActions?: AccountLifecycleAction[]; /** * Deletes the application. The delete action is hidden when omitted. */ onDelete?: () => void; + /** + * Runs an account lifecycle action. Suspending and unsuspending ask for confirmation first. + */ + onLifecycleAction?: (action: AccountLifecycleAction, operatorNote?: string) => void; /** * Gives the application a new client secret. The regenerate action is hidden when omitted, and for * public clients, which have no secret. @@ -143,8 +164,11 @@ const BaseApplicationDetails: FC = ({ isDeleting = false, isLoading = false, isRegeneratingSecret = false, + isRunningLifecycleAction = false, isUpdating = false, + lifecycleActions = [], onDelete, + onLifecycleAction, onRegenerateSecret, onSecretDismiss, onUpdate, @@ -176,9 +200,12 @@ const BaseApplicationDetails: FC = ({ isDeleting, isLoading, isRegeneratingSecret, + isRunningLifecycleAction, isUpdating, + lifecycleActions: onLifecycleAction && application ? lifecycleActions : [], oauth, onDelete: isWritable ? onDelete : undefined, + onLifecycleAction: application ? onLifecycleAction : undefined, onRegenerateSecret: canRegenerate ? onRegenerateSecret : undefined, onSecretDismiss: clientSecret ? onSecretDismiss : undefined, onUpdate: isWritable ? onUpdate : undefined, @@ -219,6 +246,13 @@ const BaseApplicationDetails: FC = ({ {label: t('admin.applications.fields.logo_url'), value: application.logoUrl ?? notSet}, ]; + if (application.status?.value) { + overview.push({ + label: t('admin.common.columns.status'), + value: , + }); + } + const handleSave = (event: FormEvent): void => { event.preventDefault(); @@ -242,6 +276,14 @@ const BaseApplicationDetails: FC = ({ actions={ <> {actions} + {onLifecycleAction && ( + + )} {onDelete && isWritable && ( & { + /** + * How a member is removed. `flow` runs the administration flow the server configures for it, which can revoke + * grants and end sessions first. It needs `baseUrl`. + * @default 'native' + */ + administrationMode?: AdministrationMode; /** * Whether members can be added and removed from this view. * @default false @@ -66,6 +73,7 @@ export type GroupMembersProps = Omit< * ``` */ const GroupMembers: FC = ({ + administrationMode, editable = false, groupId, preferences, @@ -97,7 +105,7 @@ const GroupMembers: FC = ({ ); const remove: ResourceMutationResult = useResourceMutation( ({groupId: id, members: changes}: GroupMembersVariables, fetcher) => - removeGroupMembers({...groupsEndpoint, fetcher, groupId: id, members: changes}), + removeGroupMembers({...groupsEndpoint, administrationMode, fetcher, groupId: id, members: changes}), getMembershipInvalidatedKeys, ); diff --git a/packages/react/src/components/admin/RoleAssignments/RoleAssignments.tsx b/packages/react/src/components/admin/RoleAssignments/RoleAssignments.tsx index f28ba054..b4fa9f95 100644 --- a/packages/react/src/components/admin/RoleAssignments/RoleAssignments.tsx +++ b/packages/react/src/components/admin/RoleAssignments/RoleAssignments.tsx @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { + AdministrationMode, AgentListResponse, AgentQueryKeys, BasicAgent, @@ -50,6 +51,12 @@ export type RoleAssignmentsProps = Omit< | 'onUnassign' | 'typeFilter' > & { + /** + * How an assignment is removed. `flow` runs the administration flow the server configures for it, which can revoke + * grants and end sessions first. It needs `baseUrl`. + * @default 'native' + */ + administrationMode?: AdministrationMode; /** * Whether assignments can be added and removed from this view. * @default false @@ -73,6 +80,7 @@ export type RoleAssignmentsProps = Omit< * ``` */ const RoleAssignments: FC = ({ + administrationMode, editable = false, filterable = true, preferences, @@ -113,7 +121,7 @@ const RoleAssignments: FC = ({ ); const unassign: ResourceMutationResult = useResourceMutation( ({assignments: changes, roleId: id}: RoleAssignmentsVariables, fetcher) => - removeRoleAssignments({...rolesEndpoint, assignments: changes, fetcher, roleId: id}), + removeRoleAssignments({...rolesEndpoint, administrationMode, assignments: changes, fetcher, roleId: id}), () => [[RoleQueryKeys.ROLE_ASSIGNMENTS]], ); diff --git a/packages/react/src/components/admin/UserDetails/BaseUserDetails.tsx b/packages/react/src/components/admin/UserDetails/BaseUserDetails.tsx index 5755ac3f..1c6acc04 100644 --- a/packages/react/src/components/admin/UserDetails/BaseUserDetails.tsx +++ b/packages/react/src/components/admin/UserDetails/BaseUserDetails.tsx @@ -1,7 +1,13 @@ // Copyright 2026 The ThunderID Authors // SPDX-License-Identifier: Apache-2.0 -import {resolveManagedIdentityName, ManagedUser, MembershipGroup, Preferences} from '@thunderid/browser'; +import { + AccountLifecycleAction, + resolveManagedIdentityName, + ManagedUser, + MembershipGroup, + Preferences, +} from '@thunderid/browser'; import {ChangeEvent, FC, ReactElement, ReactNode, useState} from 'react'; import useTranslation from '../../../hooks/useTranslation'; import Alert from '../../primitives/Alert/Alert'; @@ -9,6 +15,7 @@ import {Avatar} from '../../primitives/Avatar/Avatar'; import Button from '../../primitives/Button/Button'; import Spinner from '../../primitives/Spinner/Spinner'; import TextField from '../../primitives/TextField/TextField'; +import {AccessStatusBadge, AccountLifecycleButtons} from '../shared/AccountLifecycleParts'; import {ChipList, ConfirmButton, DetailList, Panel} from '../shared/AdminParts'; import AdminSection from '../shared/AdminSection'; import useAdminPartStyles from '../shared/useAdminPartStyles'; @@ -29,11 +36,21 @@ export interface BaseUserDetailsRenderProps { groups: MembershipGroup[]; isDeleting: boolean; isLoading: boolean; + isRunningLifecycleAction: boolean; isUpdating: boolean; + /** + * The account lifecycle actions offered for the user. Empty when none is allowed. + */ + lifecycleActions: AccountLifecycleAction[]; /** * Deletes the user. Undefined when deletion is not allowed, including for read-only users. */ onDelete?: () => void; + /** + * Runs an account lifecycle action, with the operator's note on a suspension. Undefined when none is + * allowed. + */ + onLifecycleAction?: (action: AccountLifecycleAction, operatorNote?: string) => void; /** * Saves the given attributes. Undefined when editing is not allowed, including for read-only users. */ @@ -64,11 +81,22 @@ export interface BaseUserDetailsProps { hiddenAttributes?: string[]; isDeleting?: boolean; isLoading?: boolean; + isRunningLifecycleAction?: boolean; isUpdating?: boolean; + /** + * The account lifecycle actions to offer (suspend, unsuspend, unlock), shown only with + * `onLifecycleAction`. They change only runtime state, so they apply to read-only users too. + */ + lifecycleActions?: AccountLifecycleAction[]; /** * Deletes the user. The delete action is hidden when omitted. */ onDelete?: () => void; + /** + * Runs an account lifecycle action. Suspending and unsuspending ask for confirmation first, and a + * suspension takes an optional operator note. + */ + onLifecycleAction?: (action: AccountLifecycleAction, operatorNote?: string) => void; /** * Saves edited attributes. Text attributes become editable only when set. Receives every * attribute, edited or not. @@ -99,8 +127,11 @@ const BaseUserDetails: FC = ({ hiddenAttributes = ['password'], isDeleting = false, isLoading = false, + isRunningLifecycleAction = false, isUpdating = false, + lifecycleActions = [], onDelete, + onLifecycleAction, onUpdate, preferences, user, @@ -125,8 +156,11 @@ const BaseUserDetails: FC = ({ groups, isDeleting, isLoading, + isRunningLifecycleAction, isUpdating, + lifecycleActions: onLifecycleAction && user ? lifecycleActions : [], onDelete: isWritable ? onDelete : undefined, + onLifecycleAction: user ? onLifecycleAction : undefined, onUpdate: isWritable ? onUpdate : undefined, user, })} @@ -172,6 +206,15 @@ const BaseUserDetails: FC = ({ actions={ <> {actions} + {onLifecycleAction && ( + + )} {onDelete && !user.isReadOnly && ( = ({
{user.ouHandle ?? user.ouId} +
& { + /** + * How the delete runs. `flow` runs the user deletion flow the server configures, which revokes the + * user's grants and ends their sessions before the record goes. It needs `baseUrl`. + * @default 'native' + */ + administrationMode?: AdministrationMode; /** * Whether the user can be deleted from this view. * @default false @@ -39,6 +57,13 @@ export type UserDetailsProps = Omit< * @default false */ editable?: boolean; + /** + * Whether the user can be suspended, unsuspended and unlocked from this view. Each action runs + * through its administration flow and is offered only when the server configures that flow. Reading + * the configured flows needs a token that may read the server configuration. + * @default false + */ + lifecycleActions?: boolean; /** * Called after the user has been deleted. */ @@ -55,8 +80,10 @@ export type UserDetailsProps = Omit< * ``` */ const UserDetails: FC = ({ + administrationMode, deletable = false, editable = false, + lifecycleActions = false, onDeleted, preferences, userId, @@ -75,7 +102,7 @@ const UserDetails: FC = ({ {enabled: Boolean(userId)}, ); const deletion: ResourceMutationResult = useResourceMutation( - (id: string, fetcher) => deleteUser({...endpoint, fetcher, userId: id}), + (id: string, fetcher) => deleteUser({...endpoint, administrationMode, fetcher, userId: id}), () => [[UserQueryKeys.USERS]], {onSuccess: onDeleted}, ); @@ -84,6 +111,14 @@ const UserDetails: FC = ({ ({userId: id}: UpdateUserVariables) => [[UserQueryKeys.USER, id], [UserQueryKeys.USERS]], ); + const lifecycle: AccountLifecycleState = useAccountLifecycle({ + enabled: lifecycleActions, + invalidatedKeys: [[UserQueryKeys.USER, userId], [UserQueryKeys.USERS]], + kind: 'user', + status: user.data?.status?.value, + subject: userId, + }); + return ( = ({ error={ toManagementErrorMessage(t, user.error, 'admin.users.details.error') ?? toManagementErrorMessage(t, deletion.error, 'admin.users.details.delete.error') ?? - toManagementErrorMessage(t, update.error, 'admin.users.details.edit.error') + toManagementErrorMessage(t, update.error, 'admin.users.details.edit.error') ?? + toManagementErrorMessage(t, lifecycle.error, 'admin.common.lifecycle.error') ?? + toManagementErrorMessage(t, lifecycle.loadError, 'admin.common.lifecycle.load_error') } isDeleting={deletion.isLoading} onDelete={deletable ? (): void => void deletion.mutate(userId) : undefined} + lifecycleActions={lifecycle.actions} + isRunningLifecycleAction={lifecycle.isRunning} + onLifecycleAction={lifecycleActions ? lifecycle.run : undefined} isUpdating={update.isLoading} onUpdate={ editable && user.data diff --git a/packages/react/src/components/admin/__tests__/adminComponents.test.tsx b/packages/react/src/components/admin/__tests__/adminComponents.test.tsx index b6d52996..05366db4 100644 --- a/packages/react/src/components/admin/__tests__/adminComponents.test.tsx +++ b/packages/react/src/components/admin/__tests__/adminComponents.test.tsx @@ -1287,3 +1287,239 @@ describe('applications', () => { expect((await screen.findByRole('alert')).textContent).not.toContain('raw server detail'); }); }); + +describe('administration flows', () => { + interface FlowServer { + /** + * The `flow` server-config section, as configured handle by key. + */ + configured?: Record; + /** + * The response of every other request, by path. + */ + resources?: Record; + } + + /** + * A fetcher that answers like the server: the flow configuration, the administration flow listing (one + * flow per configured handle, with the id `id-`), flow execution, and the given resources. + */ + const createFlowServer = ({configured = {}, resources = {}}: FlowServer = {}): FetcherMock => + vi.fn().mockImplementation((url: string) => { + const {pathname} = new URL(url); + + if (pathname === '/server-config/flow') { + return Promise.resolve( + respond({ + merged: Object.fromEntries( + Object.entries(configured).map(([key, handle]: [string, string]) => [key, {defaultHandle: handle}]), + ), + }), + ); + } + if (pathname === '/flows') { + return Promise.resolve( + respond({ + flows: Object.values(configured).map((handle: string) => ({ + flowType: 'ADMINISTRATION', + handle, + id: `id-${handle}`, + })), + }), + ); + } + if (pathname === '/flow/execute') { + return Promise.resolve(respond({data: {additionalData: {}}, flowStatus: 'COMPLETE'})); + } + if (pathname.endsWith('/groups') || pathname.endsWith('/roles')) { + return Promise.resolve(respond({count: 0, groups: [], roles: [], startIndex: 1, totalResults: 0})); + } + + return Promise.resolve(respond(resources[pathname] ?? {})); + }); + + const executions = (fetcher: FetcherMock): unknown[] => + fetcher.mock.calls + .filter(([url]: [string, RequestInit]) => url === `${baseUrl}/flow/execute`) + .map(([, init]: [string, RequestInit]) => JSON.parse(init.body as string) as unknown); + + const lockedUser: ManagedUser = {...users[0], status: {value: 'LOCKED'}}; + + describe('BaseUserDetails', () => { + it('shows the translated access status', () => { + renderWithProviders(); + + expect(screen.getByText('Locked')).toBeTruthy(); + }); + + it('offers no lifecycle actions without a handler', () => { + renderWithProviders(); + + expect(screen.queryByRole('button', {name: 'Unlock'})).toBeNull(); + expect(screen.queryByRole('button', {name: 'Suspend'})).toBeNull(); + }); + + it('unlocks straight away, and asks for confirmation and an optional note before suspending', () => { + const onLifecycleAction: Mock = vi.fn(); + + renderWithProviders( + , + ); + + fireEvent.click(screen.getByRole('button', {name: 'Unlock'})); + expect(onLifecycleAction).toHaveBeenLastCalledWith('unlock'); + + fireEvent.click(screen.getByRole('button', {name: 'Suspend'})); + expect(onLifecycleAction).toHaveBeenCalledOnce(); + const confirmation: HTMLElement = screen.getByRole('group', {name: /^Suspend Aiko Tanaka\?/}); + fireEvent.change(within(confirmation).getByLabelText('Note (optional)'), {target: {value: ' Ticket 4521 '}}); + fireEvent.click(within(confirmation).getByRole('button', {name: 'Suspend'})); + + expect(onLifecycleAction).toHaveBeenLastCalledWith('suspend', 'Ticket 4521'); + }); + + it('hands custom details the lifecycle actions, for read-only users too', () => { + const onLifecycleAction: Mock = vi.fn(); + const seen: {actions?: string[]; run?: unknown} = {}; + + renderWithProviders( + + {({lifecycleActions, onLifecycleAction: run}): ReactNode => { + seen.actions = lifecycleActions; + seen.run = run; + + return null; + }} + , + ); + + expect(seen.actions).toEqual(['unlock']); + expect(seen.run).toBe(onLifecycleAction); + }); + }); + + describe('UserDetails', () => { + it('reads no flow configuration unless lifecycle actions are on', async () => { + const fetcher: FetcherMock = createFlowServer({resources: {'/users/u-1': lockedUser}}); + + renderWithProviders(, {fetcher}); + + await screen.findByRole('heading', {name: 'Aiko Tanaka'}); + expect(fetcher.mock.calls.some(([url]: [string, RequestInit]) => url.includes('/server-config/'))).toBe(false); + }); + + it('says the account actions could not be loaded when the flow configuration cannot be read', async () => { + const server: FetcherMock = createFlowServer({resources: {'/users/u-1': lockedUser}}); + const fetcher: FetcherMock = vi + .fn() + .mockImplementation((url: string, init?: RequestInit) => + url.includes('/server-config/') ? Promise.resolve(respond({}, 500)) : server(url, init), + ); + + renderWithProviders(, {fetcher}); + + expect((await screen.findByRole('alert')).textContent).toBe('The account actions could not be loaded.'); + expect(screen.queryByRole('button', {name: 'Unlock'})).toBeNull(); + }); + + it('offers only the actions whose flow is configured, and runs one through its flow', async () => { + const fetcher: FetcherMock = createFlowServer({ + configured: {userUnlockFlow: 'unlock-user'}, + resources: {'/users/u-1': lockedUser}, + }); + + renderWithProviders(, {fetcher}); + + fireEvent.click(await screen.findByRole('button', {name: 'Unlock'})); + expect(screen.queryByRole('button', {name: 'Suspend'})).toBeNull(); + + await waitFor(() => expect(executions(fetcher)).toEqual([{flowId: 'id-unlock-user', inputs: {subject: 'u-1'}}])); + }); + + it('deletes through the user deletion flow in flow mode', async () => { + const fetcher: FetcherMock = createFlowServer({ + configured: {userDeletionFlow: 'delete-user'}, + resources: {'/users/u-1': users[0]}, + }); + const onDeleted: Mock = vi.fn(); + + renderWithProviders(, { + fetcher, + }); + + fireEvent.click(await screen.findByRole('button', {name: 'Delete user'})); + fireEvent.click(screen.getByRole('button', {name: 'Confirm'})); + + await waitFor(() => expect(onDeleted).toHaveBeenCalledOnce()); + expect(executions(fetcher)).toEqual([{flowId: 'id-delete-user', inputs: {subject: 'u-1'}}]); + expect(fetcher.mock.calls.some(([, init]: [string, RequestInit]) => init?.method === 'DELETE')).toBe(false); + }); + }); + + it('removes a group member through the membership removal flow in flow mode', async () => { + const fetcher: FetcherMock = createFlowServer({ + configured: {groupMembershipRemovalFlow: 'remove-member'}, + resources: { + '/groups/g-1/members': { + count: 1, + members: [{display: 'Aiko Tanaka', id: 'u-1', type: 'user'}], + startIndex: 1, + totalResults: 1, + }, + }, + }); + + renderWithProviders(, {fetcher}); + + fireEvent.click(await screen.findByRole('button', {name: 'Remove Aiko Tanaka from the group'})); + + await waitFor(() => expect(executions(fetcher)).toHaveLength(1)); + expect(fetcher.mock.calls.some(([url]: [string, RequestInit]) => url.endsWith('/members/remove'))).toBe(false); + }); + + it('unsuspends an agent through its flow', async () => { + const fetcher: FetcherMock = createFlowServer({ + configured: {agentSuspendFlow: 'suspend-agent', agentUnsuspendFlow: 'unsuspend-agent'}, + resources: { + '/agents/a-1': {id: 'a-1', name: 'Billing bot', ouId: 'ou-1', status: {value: 'SUSPENDED'}, type: 'bot'}, + }, + }); + + renderWithProviders(, {fetcher}); + + expect(await screen.findByText('Suspended')).toBeTruthy(); + fireEvent.click(await screen.findByRole('button', {name: 'Unsuspend'})); + expect(screen.queryByRole('button', {name: 'Suspend'})).toBeNull(); + fireEvent.click( + within(screen.getByRole('group', {name: /^Unsuspend Billing bot\?/})).getByRole('button', {name: 'Unsuspend'}), + ); + + await waitFor(() => + expect(executions(fetcher)).toEqual([{flowId: 'id-unsuspend-agent', inputs: {subject: 'a-1'}}]), + ); + }); + + it('suspends an application without asking for an operator note', async () => { + const fetcher: FetcherMock = createFlowServer({ + configured: {applicationSuspendFlow: 'suspend-app'}, + resources: {'/applications/app-1': {id: 'app-1', name: 'Portal', status: {value: 'ACTIVE'}}}, + }); + + renderWithProviders(, {fetcher}); + + fireEvent.click(await screen.findByRole('button', {name: 'Suspend'})); + const confirmation: HTMLElement = screen.getByRole('group', {name: /^Suspend Portal\?/}); + expect(within(confirmation).queryByLabelText('Note (optional)')).toBeNull(); + fireEvent.click(within(confirmation).getByRole('button', {name: 'Suspend'})); + + await waitFor(() => expect(executions(fetcher)).toEqual([{flowId: 'id-suspend-app', inputs: {subject: 'app-1'}}])); + }); +}); diff --git a/packages/react/src/components/admin/shared/AccountLifecycleParts.tsx b/packages/react/src/components/admin/shared/AccountLifecycleParts.tsx new file mode 100644 index 00000000..79b52c75 --- /dev/null +++ b/packages/react/src/components/admin/shared/AccountLifecycleParts.tsx @@ -0,0 +1,146 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {AccessStatusValue, AccountLifecycleAction} from '@thunderid/browser'; +import {ChangeEvent, FC, ReactElement, useState} from 'react'; +import useAdminPartStyles from './useAdminPartStyles'; +import useTranslation from '../../../hooks/useTranslation'; +import Button from '../../primitives/Button/Button'; +import TextField from '../../primitives/TextField/TextField'; + +/** + * Matches the server's limit on an operator note. + */ +const OPERATOR_NOTE_MAX_LENGTH = 100; + +export interface AccessStatusBadgeProps { + status?: AccessStatusValue; +} + +/** + * The translated access status of an identity, or its raw value when the status is one this SDK does + * not know. Renders nothing when the server reports no status. + */ +export const AccessStatusBadge: FC = ({ + status, +}: AccessStatusBadgeProps): ReactElement | null => { + const styles: Record = useAdminPartStyles(); + const {t} = useTranslation(); + + if (!status) { + return null; + } + + const labels: Record = { + ACTIVE: t('admin.common.status.active'), + LOCKED: t('admin.common.status.locked'), + SUSPENDED: t('admin.common.status.suspended'), + }; + + return {labels[status] ?? status}; +}; + +export interface AccountLifecycleButtonsProps { + /** + * The actions to offer, most expected first. + */ + actions: AccountLifecycleAction[]; + /** + * Whether a suspension asks for an optional operator note. The note is recorded by the flow. + */ + collectsOperatorNote?: boolean; + isRunning?: boolean; + /** + * Name of the identity, already resolved, used in the confirmation question. + */ + name: string; + onAction: (action: AccountLifecycleAction, operatorNote?: string) => void; +} + +/** + * One button per account lifecycle action. Suspending and unsuspending ask for confirmation inline; + * unlocking is remedial and runs straight away. + */ +export const AccountLifecycleButtons: FC = ({ + actions, + collectsOperatorNote = false, + isRunning = false, + name, + onAction, +}: AccountLifecycleButtonsProps): ReactElement | null => { + const styles: Record = useAdminPartStyles(); + const {t} = useTranslation(); + const [confirming, setConfirming] = useState(null); + const [operatorNote, setOperatorNote] = useState(''); + + if (actions.length === 0) { + return null; + } + + const labels: Record = { + suspend: t('admin.common.lifecycle.suspend'), + unlock: t('admin.common.lifecycle.unlock'), + unsuspend: t('admin.common.lifecycle.unsuspend'), + }; + + if (confirming && actions.includes(confirming)) { + const question: string = + confirming === 'suspend' + ? t('admin.common.lifecycle.suspend_confirm', {name}) + : t('admin.common.lifecycle.unsuspend_confirm', {name}); + const withNote: boolean = collectsOperatorNote && confirming === 'suspend'; + const close = (): void => { + setConfirming(null); + setOperatorNote(''); + }; + + return ( + + {question} + {withNote && ( + ): void => setOperatorNote(event.target.value)} + /> + )} + + + + ); + } + + return ( + <> + {actions.map((action: AccountLifecycleAction) => ( + + ))} + + ); +}; diff --git a/packages/react/src/hooks/management/useAccountLifecycle.ts b/packages/react/src/hooks/management/useAccountLifecycle.ts new file mode 100644 index 00000000..78a07f93 --- /dev/null +++ b/packages/react/src/hooks/management/useAccountLifecycle.ts @@ -0,0 +1,105 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import { + AccessStatusValue, + AccountLifecycleAction, + AccountLifecycleFlowConfigKey, + AccountLifecycleResult, + AccountLifecycleSubjectKind, + ResourceQueryKey, + getAccountLifecycleActions, + resolveAdministrationFlows, + runAccountLifecycleAction, +} from '@thunderid/browser'; +import {useMemo} from 'react'; +import useResourceMutation, {ResourceMutationResult} from './useResourceMutation'; +import useResourceQuery, {ResourceQueryResult} from './useResourceQuery'; +import useThunderID from '../../contexts/ThunderID/useThunderID'; + +interface AccountLifecycleVariables { + action: AccountLifecycleAction; + operatorNote?: string; +} + +export interface AccountLifecycleOptions { + /** + * Whether to offer the actions at all. The configured flows are read only when it is on. + */ + enabled: boolean; + /** + * Query keys to refetch after an action, such as the record and the list it appears in. + */ + invalidatedKeys: ResourceQueryKey[]; + kind: AccountLifecycleSubjectKind; + /** + * The identity's effective access status, from the read that rendered it. + */ + status: AccessStatusValue | undefined; + /** + * Identifier of the user, agent or application. + */ + subject: string; +} + +export interface AccountLifecycleState { + /** + * The actions to offer now: allowed by the status and backed by a configured flow. + */ + actions: AccountLifecycleAction[]; + /** + * Why the last action failed. + */ + error: Error | null; + isRunning: boolean; + /** + * Why the configured flows could not be read, so no action can be offered. + */ + loadError: Error | null; + run: (action: AccountLifecycleAction, operatorNote?: string) => void; +} + +/** + * Offers the account lifecycle actions (suspend, unsuspend, unlock) of a user, agent or application and + * runs them. These actions have no management API endpoint: they run only through the administration + * flows the server configures, so an action is offered only when its flow is configured. + * + * @example + * ```tsx + * const lifecycle = useAccountLifecycle({enabled: true, invalidatedKeys: [[UserQueryKeys.USER, id]], kind: 'user', status, subject: id}); + * ``` + */ +const useAccountLifecycle = ({ + enabled, + invalidatedKeys, + kind, + status, + subject, +}: AccountLifecycleOptions): AccountLifecycleState => { + const {baseUrl} = useThunderID(); + const flows: ResourceQueryResult> = useResourceQuery( + ['administration-flows', 'account-lifecycle', kind], + (fetcher) => resolveAdministrationFlows({baseUrl, fetcher}, Object.values(AccountLifecycleFlowConfigKey[kind])), + {enabled}, + ); + const execution: ResourceMutationResult = useResourceMutation( + ({action, operatorNote}: AccountLifecycleVariables, fetcher) => + runAccountLifecycleAction({action, baseUrl, fetcher, kind, operatorNote, subject}), + () => invalidatedKeys, + ); + + const actions: AccountLifecycleAction[] = useMemo( + () => (enabled && flows.data ? getAccountLifecycleActions(kind, status, flows.data) : []), + [enabled, flows.data, kind, status], + ); + + return { + actions, + error: execution.error, + isRunning: execution.isLoading, + loadError: flows.error, + run: (action: AccountLifecycleAction, operatorNote?: string): void => void execution.mutate({action, operatorNote}), + }; +}; + +export default useAccountLifecycle; diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 2d3cd33c..d7019708 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -543,6 +543,7 @@ export { type RoleSummary, type UpdateGroupRequest, type UpdateRoleRequest, + AccountLifecycleFlowConfigKey, AdministrationFlowConfigKey, AdministrationFlowData, AdministrationFlowError, @@ -553,6 +554,7 @@ export { deleteUserType, executeAdministrationFlow, findAdministrationFlows, + getAccountLifecycleActions, getAgentType, getUserType, getUserTypes, @@ -718,6 +720,7 @@ export { resolveTranslations, restoreFlowVersion, revertGatewayConfiguration, + runAccountLifecycleAction, setTranslation, setTranslations, suspendAgent, @@ -748,8 +751,16 @@ export { updateVariable, } from '@thunderid/browser'; export type { + AccessHoldReason, + AccessLockedMethod, + AccessStatus, + AccessStatusDetails, + AccessStatusValue, + AccountLifecycleAction, AccountLifecycleOptions, AccountLifecycleResult, + AccountLifecycleSubjectKind, + RunAccountLifecycleActionConfig, ApplyGatewayConfigurationConfig, ApplyGatewayConfigurationRequest, CaptureConfigurationVersionRequest, 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 741fa768..c6e952d6 100644 --- a/packages/vue/src/__tests__/components/admin/admin-components.test.ts +++ b/packages/vue/src/__tests__/components/admin/admin-components.test.ts @@ -10,7 +10,7 @@ import type { ResourceQueryKey, RoleResourcePermissions, } from '@thunderid/browser'; -import {mount} from '@vue/test-utils'; +import {type DOMWrapper, type VueWrapper, 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'; @@ -1350,4 +1350,262 @@ describe('admin components (vue)', () => { expect(denied.text()).not.toContain('raw server detail'); }); }); + + describe('administration flows', () => { + /** + * A fetcher that answers like the server: the flow configuration (`configured`, handle by config key), + * the administration flow listing (one flow per configured handle, with the id `id-`), flow + * execution, and the given resources by path. + */ + const createFlowContext = ({ + configured = {}, + resources = {}, + }: {configured?: Record; resources?: Record} = {}): ManagementTestContext => { + const context = createManagementContext(); + context.fetcher.mockImplementation((url: string) => { + const {pathname} = new URL(url); + + if (pathname === '/server-config/flow') { + return Promise.resolve( + jsonResponse({ + merged: Object.fromEntries( + Object.entries(configured).map(([key, handle]) => [key, {defaultHandle: handle}]), + ), + }), + ); + } + if (pathname === '/flows') { + return Promise.resolve( + jsonResponse({ + flows: Object.values(configured).map((handle) => ({ + flowType: 'ADMINISTRATION', + handle, + id: `id-${handle}`, + })), + }), + ); + } + if (pathname === '/flow/execute') { + return Promise.resolve(jsonResponse({data: {additionalData: {}}, flowStatus: 'COMPLETE'})); + } + if (pathname.endsWith('/groups') || pathname.endsWith('/roles')) { + return Promise.resolve(jsonResponse({count: 0, groups: [], roles: [], startIndex: 1, totalResults: 0})); + } + + return Promise.resolve(jsonResponse(resources[pathname] ?? {})); + }); + + return context; + }; + + /** The bodies of the flow executions a context's fetcher received, in order. */ + const executions = (context: ManagementTestContext): unknown[] => + context.fetcher.mock.calls + .filter(([url]) => url === 'https://thunder.example/flow/execute') + .map(([, init]) => JSON.parse(init.body as string) as unknown); + + const lifecycleButton = ( + wrapper: VueWrapper | DOMWrapper, + name: string, + ): DOMWrapper | undefined => + wrapper.findAll('button').find((button: DOMWrapper) => button.text() === name); + + const lockedUser: ManagedUser = {...users[0], status: {value: 'LOCKED'}}; + + describe('BaseUserDetails', () => { + it('shows the translated access status', () => { + const {wrapper} = mountWith(BaseUserDetails, {user: lockedUser}); + + expect(wrapper.text()).toContain(translate('admin.common.status.locked')); + }); + + it('offers no lifecycle actions without a handler', () => { + const {wrapper} = mountWith(BaseUserDetails, {lifecycleActions: ['unlock', 'suspend'], user: lockedUser}); + + expect(lifecycleButton(wrapper, translate('admin.common.lifecycle.unlock'))).toBeUndefined(); + expect(lifecycleButton(wrapper, translate('admin.common.lifecycle.suspend'))).toBeUndefined(); + }); + + it('unlocks straight away, and asks for confirmation and an optional note before suspending', async () => { + const onLifecycleAction = vi.fn(); + const {wrapper} = mountWith(BaseUserDetails, { + lifecycleActions: ['unlock', 'suspend'], + onLifecycleAction, + user: lockedUser, + }); + + await lifecycleButton(wrapper, translate('admin.common.lifecycle.unlock'))!.trigger('click'); + expect(onLifecycleAction).toHaveBeenLastCalledWith('unlock'); + + await lifecycleButton(wrapper, translate('admin.common.lifecycle.suspend'))!.trigger('click'); + expect(onLifecycleAction).toHaveBeenCalledTimes(1); + const confirmation = wrapper.find('[role="group"]'); + expect(confirmation.attributes('aria-label')).toBe( + translate('admin.common.lifecycle.suspend_confirm', {name: 'Jane Doe'}), + ); + const note = confirmation.find('input'); + expect(confirmation.find(`label[for="${note.attributes('id')}"]`).text()).toBe( + translate('admin.common.lifecycle.operator_note'), + ); + expect(note.attributes('maxlength')).toBe('100'); + await note.setValue(' Ticket 4521 '); + await lifecycleButton(confirmation, translate('admin.common.lifecycle.suspend'))!.trigger('click'); + + expect(onLifecycleAction).toHaveBeenLastCalledWith('suspend', 'Ticket 4521'); + }); + + it('hands the default slot the lifecycle actions, for read-only users too', () => { + const onLifecycleAction = vi.fn(); + let renderProps: BaseUserDetailsRenderProps | undefined; + mountWith( + BaseUserDetails, + {lifecycleActions: ['unlock'], onLifecycleAction, user: {...lockedUser, isReadOnly: true}}, + undefined, + { + default: (props: BaseUserDetailsRenderProps) => { + renderProps = props; + + return h('span', props.displayName); + }, + }, + ); + + expect(renderProps?.lifecycleActions).toEqual(['unlock']); + expect(renderProps?.onLifecycleAction).toBe(onLifecycleAction); + }); + }); + + describe('UserDetails', () => { + it('reads no flow configuration unless lifecycle actions are on', async () => { + const context = createFlowContext({resources: {'/users/u1': lockedUser}}); + const {wrapper} = mountWith(UserDetails, {userId: 'u1'}, context); + + await vi.waitFor(() => expect(wrapper.find('h2').text()).toBe('Jane Doe')); + expect(context.fetcher.mock.calls.some(([url]) => url.includes('/server-config/'))).toBe(false); + }); + + it('says the account actions could not be loaded when the flow configuration cannot be read', async () => { + const context = createFlowContext({resources: {'/users/u1': lockedUser}}); + const server = context.fetcher.getMockImplementation()!; + context.fetcher.mockImplementation((url: string, init?: RequestInit) => + url.includes('/server-config/') ? Promise.resolve(new Response('{}', {status: 500})) : server(url, init), + ); + const {wrapper} = mountWith(UserDetails, {lifecycleActions: true, userId: 'u1'}, context); + + await vi.waitFor(() => + expect(wrapper.find('[role="alert"]').text()).toBe(translate('admin.common.lifecycle.load_error')), + ); + expect(lifecycleButton(wrapper, translate('admin.common.lifecycle.unlock'))).toBeUndefined(); + }); + + it('offers only the actions whose flow is configured, and runs one through its flow', async () => { + const context = createFlowContext({ + configured: {userUnlockFlow: 'unlock-user'}, + resources: {'/users/u1': lockedUser}, + }); + const {wrapper} = mountWith(UserDetails, {lifecycleActions: true, userId: 'u1'}, context); + + await vi.waitFor(() => + expect(lifecycleButton(wrapper, translate('admin.common.lifecycle.unlock'))).toBeDefined(), + ); + expect(lifecycleButton(wrapper, translate('admin.common.lifecycle.suspend'))).toBeUndefined(); + await lifecycleButton(wrapper, translate('admin.common.lifecycle.unlock'))!.trigger('click'); + + await vi.waitFor(() => + expect(executions(context)).toEqual([{flowId: 'id-unlock-user', inputs: {subject: 'u1'}}]), + ); + }); + + it('deletes through the user deletion flow in flow mode', async () => { + const context = createFlowContext({ + configured: {userDeletionFlow: 'delete-user'}, + resources: {'/users/u1': users[0]}, + }); + const {wrapper} = mountWith(UserDetails, {administrationMode: 'flow', deletable: true, userId: 'u1'}, context); + + await vi.waitFor(() => + expect(lifecycleButton(wrapper, translate('admin.users.details.delete.action'))).toBeDefined(), + ); + await lifecycleButton(wrapper, translate('admin.users.details.delete.action'))!.trigger('click'); + await lifecycleButton(wrapper.find('[role="group"]'), translate('admin.common.actions.confirm'))!.trigger( + 'click', + ); + + await vi.waitFor(() => expect(wrapper.emitted('deleted')).toHaveLength(1)); + expect(executions(context)).toEqual([{flowId: 'id-delete-user', inputs: {subject: 'u1'}}]); + expect(context.fetcher.mock.calls.some(([, init]) => init?.method === 'DELETE')).toBe(false); + }); + }); + + it('removes a group member through the membership removal flow in flow mode', async () => { + const context = createFlowContext({ + configured: {groupMembershipRemovalFlow: 'remove-member'}, + resources: { + '/groups/g-1/members': { + count: 1, + members: [{display: 'Jane Doe', id: 'u1', type: 'user'}], + startIndex: 1, + totalResults: 1, + }, + }, + }); + const {wrapper} = mountWith(GroupMembers, {administrationMode: 'flow', editable: true, groupId: 'g-1'}, context); + const removeLabel: string = translate('admin.groups.members.remove_label', {name: 'Jane Doe'}); + + await vi.waitFor(() => expect(wrapper.find(`button[aria-label="${removeLabel}"]`).exists()).toBe(true)); + await wrapper.find(`button[aria-label="${removeLabel}"]`).trigger('click'); + + await vi.waitFor(() => expect(executions(context)).toHaveLength(1)); + expect(context.fetcher.mock.calls.some(([url]) => url.endsWith('/members/remove'))).toBe(false); + }); + + it('unsuspends an agent through its flow', async () => { + const context = createFlowContext({ + configured: {agentSuspendFlow: 'suspend-agent', agentUnsuspendFlow: 'unsuspend-agent'}, + resources: { + '/agents/a-1': {id: 'a-1', name: 'Billing bot', ouId: 'ou-1', status: {value: 'SUSPENDED'}, type: 'bot'}, + }, + }); + const {wrapper} = mountWith(AgentDetails, {agentId: 'a-1', lifecycleActions: true}, context); + + await vi.waitFor(() => + expect(lifecycleButton(wrapper, translate('admin.common.lifecycle.unsuspend'))).toBeDefined(), + ); + expect(wrapper.text()).toContain(translate('admin.common.status.suspended')); + expect(lifecycleButton(wrapper, translate('admin.common.lifecycle.suspend'))).toBeUndefined(); + await lifecycleButton(wrapper, translate('admin.common.lifecycle.unsuspend'))!.trigger('click'); + const confirmation = wrapper.find('[role="group"]'); + expect(confirmation.attributes('aria-label')).toBe( + translate('admin.common.lifecycle.unsuspend_confirm', {name: 'Billing bot'}), + ); + await lifecycleButton(confirmation, translate('admin.common.lifecycle.unsuspend'))!.trigger('click'); + + await vi.waitFor(() => + expect(executions(context)).toEqual([{flowId: 'id-unsuspend-agent', inputs: {subject: 'a-1'}}]), + ); + }); + + it('suspends an application without asking for an operator note', async () => { + const context = createFlowContext({ + configured: {applicationSuspendFlow: 'suspend-app'}, + resources: {'/applications/app-1': {id: 'app-1', name: 'Portal', status: {value: 'ACTIVE'}}}, + }); + const {wrapper} = mountWith(ApplicationDetails, {applicationId: 'app-1', lifecycleActions: true}, context); + + await vi.waitFor(() => + expect(lifecycleButton(wrapper, translate('admin.common.lifecycle.suspend'))).toBeDefined(), + ); + await lifecycleButton(wrapper, translate('admin.common.lifecycle.suspend'))!.trigger('click'); + const confirmation = wrapper.find('[role="group"]'); + expect(confirmation.attributes('aria-label')).toBe( + translate('admin.common.lifecycle.suspend_confirm', {name: 'Portal'}), + ); + expect(confirmation.find('input').exists()).toBe(false); + await lifecycleButton(confirmation, translate('admin.common.lifecycle.suspend'))!.trigger('click'); + + await vi.waitFor(() => + expect(executions(context)).toEqual([{flowId: 'id-suspend-app', inputs: {subject: 'app-1'}}]), + ); + }); + }); }); diff --git a/packages/vue/src/components/admin/AgentDetails/AgentDetails.ts b/packages/vue/src/components/admin/AgentDetails/AgentDetails.ts index a3a5d6b8..8f5ef669 100644 --- a/packages/vue/src/components/admin/AgentDetails/AgentDetails.ts +++ b/packages/vue/src/components/admin/AgentDetails/AgentDetails.ts @@ -14,6 +14,7 @@ import { 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 useAccountLifecycle, {type AccountLifecycleState} from '../../../composables/management/useAccountLifecycle'; import useManagementEndpoint, {type ManagementEndpoint} from '../../../composables/management/useManagementEndpoint'; import useResourceMutation, {type ResourceMutationResult} from '../../../composables/management/useResourceMutation'; import useResourceQuery, {type ResourceQueryResult} from '../../../composables/management/useResourceQuery'; @@ -23,6 +24,7 @@ type AgentDetailsProps = Readonly<{ agentId: string; className: string; deletable: boolean; + lifecycleActions: boolean; preferences: Preferences | undefined; }>; @@ -46,6 +48,12 @@ const AgentDetails: Component = defineComponent({ className: {default: '', type: String}, /** Whether the agent can be deleted from this view. */ deletable: {default: false, type: Boolean}, + /** + * Whether the agent can be suspended, unsuspended and unlocked from this view. Each action runs + * through its administration flow and is offered only when the server configures that flow. Reading + * the configured flows needs a token that may read the server configuration. + */ + lifecycleActions: {default: false, type: Boolean}, /** Component-level preferences, for example `i18n.bundles` overrides. */ preferences: {default: undefined, type: Object as PropType}, }, @@ -74,6 +82,13 @@ const AgentDetails: Component = defineComponent({ () => [[AgentQueryKeys.AGENTS]], {onSuccess: (): void => emit('deleted')}, ); + const lifecycle: AccountLifecycleState = useAccountLifecycle({ + enabled: (): boolean => props.lifecycleActions, + invalidatedKeys: () => [[AgentQueryKeys.AGENT, props.agentId], [AgentQueryKeys.AGENTS]], + kind: 'agent', + status: () => agent.data.value?.status?.value, + subject: () => props.agentId, + }); return (): VNode => h( @@ -83,11 +98,16 @@ const AgentDetails: Component = defineComponent({ className: props.className, error: toManagementErrorMessage(t, agent.error.value, 'admin.agents.details.error') ?? - toManagementErrorMessage(t, deletion.error.value, 'admin.agents.details.delete.error'), + toManagementErrorMessage(t, deletion.error.value, 'admin.agents.details.delete.error') ?? + toManagementErrorMessage(t, lifecycle.error.value, 'admin.common.lifecycle.error') ?? + toManagementErrorMessage(t, lifecycle.loadError.value, 'admin.common.lifecycle.load_error'), groups: groups.data.value?.groups ?? [], isDeleting: deletion.isLoading.value, isLoading: agent.isLoading.value, + isRunningLifecycleAction: lifecycle.isRunning.value, + lifecycleActions: lifecycle.actions.value, onDelete: props.deletable ? (): void => void deletion.mutate(props.agentId) : undefined, + onLifecycleAction: props.lifecycleActions ? lifecycle.run : undefined, preferences: props.preferences, roles: roles.data.value?.roles ?? [], }, diff --git a/packages/vue/src/components/admin/AgentDetails/BaseAgentDetails.ts b/packages/vue/src/components/admin/AgentDetails/BaseAgentDetails.ts index b2876995..176c71e4 100644 --- a/packages/vue/src/components/admin/AgentDetails/BaseAgentDetails.ts +++ b/packages/vue/src/components/admin/AgentDetails/BaseAgentDetails.ts @@ -1,11 +1,19 @@ // Copyright 2026 The ThunderID Authors // SPDX-License-Identifier: Apache-2.0 -import {type Agent, type MembershipGroup, type Preferences, withVendorCSSClassPrefix} from '@thunderid/browser'; +import { + type AccountLifecycleAction, + type Agent, + type MembershipGroup, + type Preferences, + withVendorCSSClassPrefix, +} from '@thunderid/browser'; import {type Component, type PropType, type SetupContext, type VNode, defineComponent, h} from 'vue'; import Alert from '../../primitives/Alert/Alert'; import CopyableText from '../../primitives/CopyableText/CopyableText'; import Spinner from '../../primitives/Spinner/Spinner'; +import AccessStatusBadge from '../shared/AccessStatusBadge'; +import AccountLifecycleButtons from '../shared/AccountLifecycleButtons'; import AdminSection from '../shared/AdminSection'; import ChipList from '../shared/ChipList'; import ConfirmButton from '../shared/ConfirmButton'; @@ -20,7 +28,10 @@ type BaseAgentDetailsProps = Readonly<{ groups: MembershipGroup[]; isDeleting: boolean; isLoading: boolean; + isRunningLifecycleAction: boolean; + lifecycleActions: AccountLifecycleAction[]; onDelete: (() => void) | undefined; + onLifecycleAction: ((action: AccountLifecycleAction, operatorNote?: string) => void) | undefined; preferences: Preferences | undefined; roles: string[]; }>; @@ -34,8 +45,13 @@ export interface BaseAgentDetailsRenderProps { groups: MembershipGroup[]; isDeleting: boolean; isLoading: boolean; + isRunningLifecycleAction: boolean; + /** The account lifecycle actions offered for the agent. Empty when none is allowed. */ + lifecycleActions: AccountLifecycleAction[]; /** Deletes the agent. Undefined when deletion is not allowed, including for read-only agents. */ onDelete?: () => void; + /** Runs an account lifecycle action, with the operator's note on a suspension. Undefined when none is allowed. */ + onLifecycleAction?: (action: AccountLifecycleAction, operatorNote?: string) => void; roles: string[]; } @@ -61,8 +77,22 @@ const BaseAgentDetails: Component = defineComponent({ groups: {default: () => [], type: Array as PropType}, isDeleting: {default: false, type: Boolean}, isLoading: {default: false, type: Boolean}, + isRunningLifecycleAction: {default: false, type: Boolean}, + /** + * The account lifecycle actions to offer (suspend, unsuspend, unlock), shown only with + * `onLifecycleAction`. They change only runtime state, so they apply to read-only agents too. + */ + lifecycleActions: {default: () => [], type: Array as PropType}, /** Deletes the agent. The delete action is hidden when omitted. Also available as `@delete`. */ onDelete: {default: undefined, type: Function as PropType<() => void>}, + /** + * Runs an account lifecycle action. Suspending and unsuspending ask for confirmation first, and a + * suspension takes an optional operator note. Also available as `@lifecycle-action`. + */ + onLifecycleAction: { + default: undefined, + type: Function as PropType<(action: AccountLifecycleAction, operatorNote?: string) => void>, + }, /** Component-level preferences, for example `i18n.bundles` overrides. */ preferences: {default: undefined, type: Object as PropType}, /** Names of the roles assigned to the agent, directly or through its groups. */ @@ -81,7 +111,10 @@ const BaseAgentDetails: Component = defineComponent({ groups: props.groups, isDeleting: props.isDeleting, isLoading: props.isLoading, + isRunningLifecycleAction: props.isRunningLifecycleAction, + lifecycleActions: props.onLifecycleAction && agent ? props.lifecycleActions : [], onDelete: agent && !agent.isReadOnly ? props.onDelete : undefined, + onLifecycleAction: agent ? props.onLifecycleAction : undefined, roles: props.roles, }; @@ -123,6 +156,13 @@ const BaseAgentDetails: Component = defineComponent({ {label: t('admin.common.columns.organization_unit'), value: agent.ouHandle ?? agent.ouId}, ]; + if (agent.status) { + details.push({ + label: t('admin.common.columns.status'), + value: h(AccessStatusBadge, {status: agent.status.value, t}), + }); + } + if (agent.clientId) { details.push({ label: t('admin.agents.list.columns.client_id'), @@ -136,6 +176,16 @@ const BaseAgentDetails: Component = defineComponent({ { actions: (): (VNode | VNode[] | null | undefined)[] => [ slots['actions']?.(), + props.onLifecycleAction + ? h(AccountLifecycleButtons, { + actions: props.lifecycleActions, + collectsOperatorNote: true, + isRunning: props.isRunningLifecycleAction, + name: agent.name, + onAction: props.onLifecycleAction, + t, + }) + : null, props.onDelete && !agent.isReadOnly ? h( ConfirmButton, diff --git a/packages/vue/src/components/admin/ApplicationDetails/ApplicationDetails.ts b/packages/vue/src/components/admin/ApplicationDetails/ApplicationDetails.ts index 37a88061..42f6dd3b 100644 --- a/packages/vue/src/components/admin/ApplicationDetails/ApplicationDetails.ts +++ b/packages/vue/src/components/admin/ApplicationDetails/ApplicationDetails.ts @@ -10,19 +10,22 @@ import { toUpdateApplicationRequest, updateApplication, } from '@thunderid/browser'; -import type {Application, Preferences, RegenerateClientSecretResult} from '@thunderid/browser'; +import type {AdministrationMode, 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 useAccountLifecycle, {type AccountLifecycleState} from '../../../composables/management/useAccountLifecycle'; 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<{ + administrationMode: AdministrationMode | undefined; applicationId: string; className: string; deletable: boolean; editable: boolean; + lifecycleActions: boolean; preferences: Preferences | undefined; secretRegenerable: boolean; }>; @@ -58,6 +61,12 @@ interface UpdateApplicationVariables { const ApplicationDetails: Component = defineComponent({ name: 'ApplicationDetails', props: { + /** + * How the delete and the client secret regeneration run. `flow` runs the administration flow the + * server configures for it, which can revoke grants and end sessions first. It needs `baseUrl`. + * Defaults to `native`. + */ + administrationMode: {default: undefined, type: String as PropType}, /** Identifier of the application to show. */ applicationId: {required: true, type: String}, /** Extra CSS class added to the root element. */ @@ -66,6 +75,12 @@ const ApplicationDetails: Component = defineComponent({ 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}, + /** + * Whether the application can be suspended and unsuspended from this view. Each action runs through + * its administration flow and is offered only when the server configures that flow. Reading the + * configured flows needs a token that may read the server configuration. + */ + lifecycleActions: {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. */ @@ -81,7 +96,8 @@ const ApplicationDetails: Component = defineComponent({ {enabled: (): boolean => Boolean(props.applicationId)}, ); const deletion: ResourceMutationResult = useResourceMutation( - (applicationId: string, fetcher) => deleteApplication({...endpoint, applicationId, fetcher}), + (applicationId: string, fetcher) => + deleteApplication({...endpoint, administrationMode: props.administrationMode, applicationId, fetcher}), () => [[ApplicationQueryKeys.APPLICATIONS]], {onSuccess: (): void => emit('deleted')}, ); @@ -99,12 +115,23 @@ const ApplicationDetails: Component = defineComponent({ ], ); const regeneration: ResourceMutationResult = useResourceMutation( - (applicationId: string, fetcher) => regenerateClientSecret({...endpoint, applicationId, fetcher}), + (applicationId: string, fetcher) => + regenerateClientSecret({...endpoint, administrationMode: props.administrationMode, applicationId, fetcher}), (applicationId: string) => [ [ApplicationQueryKeys.APPLICATION, applicationId], [ApplicationQueryKeys.APPLICATIONS], ], ); + const lifecycle: AccountLifecycleState = useAccountLifecycle({ + enabled: (): boolean => props.lifecycleActions, + invalidatedKeys: () => [ + [ApplicationQueryKeys.APPLICATION, props.applicationId], + [ApplicationQueryKeys.APPLICATIONS], + ], + kind: 'application', + status: () => application.data.value?.status?.value, + subject: () => props.applicationId, + }); return (): VNode => { const current: Application | undefined = application.data.value; @@ -119,12 +146,17 @@ const ApplicationDetails: Component = defineComponent({ 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'), + toManagementErrorMessage(t, regeneration.error.value, 'admin.applications.details.secret.error') ?? + toManagementErrorMessage(t, lifecycle.error.value, 'admin.common.lifecycle.error') ?? + toManagementErrorMessage(t, lifecycle.loadError.value, 'admin.common.lifecycle.load_error'), isDeleting: deletion.isLoading.value, isLoading: application.isLoading.value, isRegeneratingSecret: regeneration.isLoading.value, + isRunningLifecycleAction: lifecycle.isRunning.value, isUpdating: update.isLoading.value, + lifecycleActions: lifecycle.actions.value, onDelete: props.deletable ? (): void => void deletion.mutate(props.applicationId) : undefined, + onLifecycleAction: props.lifecycleActions ? lifecycle.run : undefined, onRegenerateSecret: props.secretRegenerable ? (): void => void regeneration.mutate(props.applicationId) : undefined, diff --git a/packages/vue/src/components/admin/ApplicationDetails/BaseApplicationDetails.ts b/packages/vue/src/components/admin/ApplicationDetails/BaseApplicationDetails.ts index ece0ed01..64ade8c4 100644 --- a/packages/vue/src/components/admin/ApplicationDetails/BaseApplicationDetails.ts +++ b/packages/vue/src/components/admin/ApplicationDetails/BaseApplicationDetails.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { + type AccountLifecycleAction, type Application, APPLICATION_GRANT_TYPES, type ApplicationSubmission, @@ -27,6 +28,8 @@ 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 AccessStatusBadge from '../shared/AccessStatusBadge'; +import AccountLifecycleButtons from '../shared/AccountLifecycleButtons'; import AdminSection from '../shared/AdminSection'; import ChipList from '../shared/ChipList'; import ClientSecretNotice from '../shared/ClientSecretNotice'; @@ -52,8 +55,11 @@ type BaseApplicationDetailsProps = Readonly<{ isDeleting: boolean; isLoading: boolean; isRegeneratingSecret: boolean; + isRunningLifecycleAction: boolean; isUpdating: boolean; + lifecycleActions: AccountLifecycleAction[]; onDelete: (() => void) | undefined; + onLifecycleAction: ((action: AccountLifecycleAction, operatorNote?: string) => void) | undefined; onRegenerateSecret: (() => void) | undefined; onSecretDismiss: (() => void) | undefined; onUpdate: ((application: ApplicationUpdateSubmission) => void) | undefined; @@ -71,11 +77,16 @@ export interface BaseApplicationDetailsRenderProps { isDeleting: boolean; isLoading: boolean; isRegeneratingSecret: boolean; + isRunningLifecycleAction: boolean; isUpdating: boolean; + /** The account lifecycle actions offered for the application. Empty when none is allowed. */ + lifecycleActions: AccountLifecycleAction[]; /** 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; + /** Runs an account lifecycle action. Undefined when none is allowed. */ + onLifecycleAction?: (action: AccountLifecycleAction, operatorNote?: string) => void; /** * Gives the application a new client secret. Undefined when not allowed, for read-only applications * and for applications without a confidential OAuth client. @@ -131,9 +142,24 @@ const BaseApplicationDetails: Component = defineComponent({ isDeleting: {default: false, type: Boolean}, isLoading: {default: false, type: Boolean}, isRegeneratingSecret: {default: false, type: Boolean}, + isRunningLifecycleAction: {default: false, type: Boolean}, isUpdating: {default: false, type: Boolean}, + /** + * The account lifecycle actions to offer (suspend, unsuspend; applications are never locked), shown + * only with `onLifecycleAction`. They change only runtime state, so they apply to read-only + * applications too. + */ + lifecycleActions: {default: () => [], type: Array as PropType}, /** Deletes the application. The delete action is hidden when omitted. Also available as `@delete`. */ onDelete: {default: undefined, type: Function as PropType<() => void>}, + /** + * Runs an account lifecycle action. Suspending and unsuspending ask for confirmation first. Also + * available as `@lifecycle-action`. + */ + onLifecycleAction: { + default: undefined, + type: Function as PropType<(action: AccountLifecycleAction, operatorNote?: string) => 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`. @@ -176,9 +202,12 @@ const BaseApplicationDetails: Component = defineComponent({ isDeleting: props.isDeleting, isLoading: props.isLoading, isRegeneratingSecret: props.isRegeneratingSecret, + isRunningLifecycleAction: props.isRunningLifecycleAction, isUpdating: props.isUpdating, + lifecycleActions: props.onLifecycleAction && application ? props.lifecycleActions : [], oauth, onDelete: isWritable ? props.onDelete : undefined, + onLifecycleAction: application ? props.onLifecycleAction : undefined, onRegenerateSecret: canRegenerate ? props.onRegenerateSecret : undefined, onSecretDismiss: props.clientSecret ? props.onSecretDismiss : undefined, onUpdate: isWritable ? props.onUpdate : undefined, @@ -228,6 +257,13 @@ const BaseApplicationDetails: Component = defineComponent({ {label: t('admin.applications.fields.logo_url'), value: application.logoUrl ?? notSet}, ]; + if (application.status?.value) { + overview.push({ + label: t('admin.common.columns.status'), + value: h(AccessStatusBadge, {status: application.status.value, t}), + }); + } + const renderEditForm = (current: ApplicationFormValues): VNode => h( Panel, @@ -355,6 +391,15 @@ const BaseApplicationDetails: Component = defineComponent({ { actions: (): (VNode | VNode[] | null | undefined)[] => [ slots['actions']?.(), + props.onLifecycleAction + ? h(AccountLifecycleButtons, { + actions: props.lifecycleActions, + isRunning: props.isRunningLifecycleAction, + name: application.name, + onAction: props.onLifecycleAction, + t, + }) + : null, props.onDelete && isWritable ? h( ConfirmButton, diff --git a/packages/vue/src/components/admin/GroupMembers/GroupMembers.ts b/packages/vue/src/components/admin/GroupMembers/GroupMembers.ts index 9145167d..b23a6609 100644 --- a/packages/vue/src/components/admin/GroupMembers/GroupMembers.ts +++ b/packages/vue/src/components/admin/GroupMembers/GroupMembers.ts @@ -15,6 +15,7 @@ import { toManagementErrorMessage, } from '@thunderid/browser'; import type { + AdministrationMode, AgentListResponse, BasicAgent, Group, @@ -34,6 +35,7 @@ import type {IdentityCandidate} from '../shared/IdentityPicker'; import useAdminTranslation, {type AdminTranslate} from '../shared/useAdminTranslation'; type GroupMembersProps = Readonly<{ + administrationMode: AdministrationMode | undefined; className: string; description: string | undefined; editable: boolean; @@ -59,6 +61,11 @@ interface GroupMembersVariables { const GroupMembers: Component = defineComponent({ name: 'GroupMembers', props: { + /** + * How a member is removed. `flow` runs the administration flow the server configures for it, which can revoke + * grants and end sessions first. It needs `baseUrl`. Defaults to `native`. + */ + administrationMode: {default: undefined, type: String as PropType}, /** Extra CSS class added to the root element. */ className: {default: '', type: String}, /** Text shown under the title. */ @@ -107,7 +114,13 @@ const GroupMembers: Component = defineComponent({ ); const remove: ResourceMutationResult = useResourceMutation( ({groupId, members: removed}: GroupMembersVariables, fetcher) => - removeGroupMembers({...groupsEndpoint, fetcher, groupId, members: removed}), + removeGroupMembers({ + ...groupsEndpoint, + administrationMode: props.administrationMode, + fetcher, + groupId, + members: removed, + }), invalidatedByMembership, ); diff --git a/packages/vue/src/components/admin/RoleAssignments/RoleAssignments.ts b/packages/vue/src/components/admin/RoleAssignments/RoleAssignments.ts index 45124e10..4df7f19d 100644 --- a/packages/vue/src/components/admin/RoleAssignments/RoleAssignments.ts +++ b/packages/vue/src/components/admin/RoleAssignments/RoleAssignments.ts @@ -16,6 +16,7 @@ import { toManagementErrorMessage, } from '@thunderid/browser'; import type { + AdministrationMode, AgentListResponse, BasicAgent, Group, @@ -37,6 +38,7 @@ import type {IdentityCandidate} from '../shared/IdentityPicker'; import useAdminTranslation, {type AdminTranslate} from '../shared/useAdminTranslation'; type RoleAssignmentsProps = Readonly<{ + administrationMode: AdministrationMode | undefined; className: string; description: string | undefined; editable: boolean; @@ -63,6 +65,11 @@ interface RoleAssignmentsVariables { const RoleAssignments: Component = defineComponent({ name: 'RoleAssignments', props: { + /** + * How an assignment is removed. `flow` runs the administration flow the server configures for it, which can revoke + * grants and end sessions first. It needs `baseUrl`. Defaults to `native`. + */ + administrationMode: {default: undefined, type: String as PropType}, /** Extra CSS class added to the root element. */ className: {default: '', type: String}, /** Text shown under the title. */ @@ -121,7 +128,13 @@ const RoleAssignments: Component = defineComponent({ ); const unassign: ResourceMutationResult = useResourceMutation( ({roleId, assignments: removed}: RoleAssignmentsVariables, fetcher) => - removeRoleAssignments({...rolesEndpoint, assignments: removed, fetcher, roleId}), + removeRoleAssignments({ + ...rolesEndpoint, + administrationMode: props.administrationMode, + assignments: removed, + fetcher, + roleId, + }), () => [[RoleQueryKeys.ROLE_ASSIGNMENTS]], ); diff --git a/packages/vue/src/components/admin/UserDetails/BaseUserDetails.ts b/packages/vue/src/components/admin/UserDetails/BaseUserDetails.ts index c44cd12c..75016310 100644 --- a/packages/vue/src/components/admin/UserDetails/BaseUserDetails.ts +++ b/packages/vue/src/components/admin/UserDetails/BaseUserDetails.ts @@ -2,7 +2,13 @@ // SPDX-License-Identifier: Apache-2.0 import {resolveManagedIdentityName} from '@thunderid/browser'; -import {type ManagedUser, type MembershipGroup, type Preferences, withVendorCSSClassPrefix} from '@thunderid/browser'; +import { + type AccountLifecycleAction, + type ManagedUser, + type MembershipGroup, + type Preferences, + withVendorCSSClassPrefix, +} from '@thunderid/browser'; import { type Component, type PropType, @@ -18,6 +24,8 @@ import Alert from '../../primitives/Alert/Alert'; import Button from '../../primitives/Button/Button'; import Spinner from '../../primitives/Spinner/Spinner'; import TextField from '../../primitives/TextField/TextField'; +import AccessStatusBadge from '../shared/AccessStatusBadge'; +import AccountLifecycleButtons from '../shared/AccountLifecycleButtons'; import AdminAvatar from '../shared/AdminAvatar'; import AdminSection from '../shared/AdminSection'; import ChipList from '../shared/ChipList'; @@ -33,8 +41,11 @@ type BaseUserDetailsProps = Readonly<{ hiddenAttributes: string[]; isDeleting: boolean; isLoading: boolean; + isRunningLifecycleAction: boolean; isUpdating: boolean; + lifecycleActions: AccountLifecycleAction[]; onDelete: (() => void) | undefined; + onLifecycleAction: ((action: AccountLifecycleAction, operatorNote?: string) => void) | undefined; onUpdate: ((attributes: Record) => void) | undefined; preferences: Preferences | undefined; user: ManagedUser | undefined; @@ -52,9 +63,17 @@ export interface BaseUserDetailsRenderProps { groups: MembershipGroup[]; isDeleting: boolean; isLoading: boolean; + isRunningLifecycleAction: boolean; isUpdating: boolean; + /** The account lifecycle actions offered for the user. Empty when none is allowed. */ + lifecycleActions: AccountLifecycleAction[]; /** Deletes the user. Undefined when deletion is not allowed, including for read-only users. */ onDelete?: () => void; + /** + * Runs an account lifecycle action, with the operator's note on a suspension. Undefined when none is + * allowed. + */ + onLifecycleAction?: (action: AccountLifecycleAction, operatorNote?: string) => void; /** Saves the given attributes. Undefined when editing is not allowed, including for read-only users. */ onUpdate?: (attributes: Record) => void; user?: ManagedUser; @@ -90,9 +109,23 @@ const BaseUserDetails: Component = defineComponent({ hiddenAttributes: {default: () => ['password'], type: Array as PropType}, isDeleting: {default: false, type: Boolean}, isLoading: {default: false, type: Boolean}, + isRunningLifecycleAction: {default: false, type: Boolean}, isUpdating: {default: false, type: Boolean}, + /** + * The account lifecycle actions to offer (suspend, unsuspend, unlock), shown only with + * `onLifecycleAction`. They change only runtime state, so they apply to read-only users too. + */ + lifecycleActions: {default: () => [], type: Array as PropType}, /** Deletes the user. The delete action is hidden when omitted. Also available as `@delete`. */ onDelete: {default: undefined, type: Function as PropType<() => void>}, + /** + * Runs an account lifecycle action. Suspending and unsuspending ask for confirmation first, and a + * suspension takes an optional operator note. Also available as `@lifecycle-action`. + */ + onLifecycleAction: { + default: undefined, + type: Function as PropType<(action: AccountLifecycleAction, operatorNote?: string) => void>, + }, /** * Saves edited attributes. Text attributes become editable only when set. Receives every attribute, * edited or not. Also available as `@update`. @@ -124,8 +157,11 @@ const BaseUserDetails: Component = defineComponent({ groups: props.groups, isDeleting: props.isDeleting, isLoading: props.isLoading, + isRunningLifecycleAction: props.isRunningLifecycleAction, isUpdating: props.isUpdating, + lifecycleActions: props.onLifecycleAction && user ? props.lifecycleActions : [], onDelete: isWritable ? props.onDelete : undefined, + onLifecycleAction: user ? props.onLifecycleAction : undefined, onUpdate: isWritable ? props.onUpdate : undefined, user, }; @@ -240,6 +276,16 @@ const BaseUserDetails: Component = defineComponent({ { actions: (): (VNode | VNode[] | null | undefined)[] => [ slots['actions']?.(), + props.onLifecycleAction + ? h(AccountLifecycleButtons, { + actions: props.lifecycleActions, + collectsOperatorNote: true, + isRunning: props.isRunningLifecycleAction, + name: displayName, + onAction: props.onLifecycleAction, + t, + }) + : null, props.onDelete && !user.isReadOnly ? h( ConfirmButton, @@ -258,6 +304,7 @@ const BaseUserDetails: Component = defineComponent({ h('div', {class: cls('row')}, [ h(AdminAvatar, {name: displayName, size: 48}), h('span', {class: cls('muted')}, user.ouHandle ?? user.ouId), + h(AccessStatusBadge, {status: user.status?.value, t}), ]), h( Panel, diff --git a/packages/vue/src/components/admin/UserDetails/UserDetails.ts b/packages/vue/src/components/admin/UserDetails/UserDetails.ts index 60b8dc9a..16072940 100644 --- a/packages/vue/src/components/admin/UserDetails/UserDetails.ts +++ b/packages/vue/src/components/admin/UserDetails/UserDetails.ts @@ -10,19 +10,28 @@ import { toManagementErrorMessage, updateUser, } from '@thunderid/browser'; -import type {ManagedUser, MembershipGroupListResponse, Preferences, UpdateManagedUserRequest} from '@thunderid/browser'; +import type { + AdministrationMode, + 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 useAccountLifecycle, {type AccountLifecycleState} from '../../../composables/management/useAccountLifecycle'; 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<{ + administrationMode: AdministrationMode | undefined; className: string; deletable: boolean; editable: boolean; hiddenAttributes: string[]; + lifecycleActions: boolean; preferences: Preferences | undefined; userId: string; }>; @@ -46,6 +55,11 @@ interface UpdateUserVariables { const UserDetails: Component = defineComponent({ name: 'UserDetails', props: { + /** + * How the delete runs. `flow` runs the user deletion flow the server configures, which revokes the + * user's grants and ends their sessions before the record goes. It needs `baseUrl`. Defaults to `native`. + */ + administrationMode: {default: undefined, type: String as PropType}, /** Extra CSS class added to the root element. */ className: {default: '', type: String}, /** Whether the user can be deleted from this view. */ @@ -54,6 +68,12 @@ const UserDetails: Component = defineComponent({ editable: {default: false, type: Boolean}, /** Attribute names that are never shown, such as credentials. */ hiddenAttributes: {default: () => ['password'], type: Array as PropType}, + /** + * Whether the user can be suspended, unsuspended and unlocked from this view. Each action runs + * through its administration flow and is offered only when the server configures that flow. Reading + * the configured flows needs a token that may read the server configuration. + */ + lifecycleActions: {default: false, type: Boolean}, /** Component-level preferences, for example `i18n.bundles` overrides. */ preferences: {default: undefined, type: Object as PropType}, /** Identifier of the user to show. */ @@ -75,7 +95,8 @@ const UserDetails: Component = defineComponent({ {enabled: hasUserId}, ); const deletion: ResourceMutationResult = useResourceMutation( - (userId: string, fetcher) => deleteUser({...endpoint, fetcher, userId}), + (userId: string, fetcher) => + deleteUser({...endpoint, administrationMode: props.administrationMode, fetcher, userId}), () => [[UserQueryKeys.USERS]], {onSuccess: (): void => emit('deleted')}, ); @@ -83,6 +104,13 @@ const UserDetails: Component = defineComponent({ ({userId, data}: UpdateUserVariables, fetcher) => updateUser({...endpoint, fetcher, payload: data, userId}), ({userId}: UpdateUserVariables) => [[UserQueryKeys.USER, userId], [UserQueryKeys.USERS]], ); + const lifecycle: AccountLifecycleState = useAccountLifecycle({ + enabled: (): boolean => props.lifecycleActions, + invalidatedKeys: () => [[UserQueryKeys.USER, props.userId], [UserQueryKeys.USERS]], + kind: 'user', + status: () => user.data.value?.status?.value, + subject: () => props.userId, + }); return (): VNode => { const current: ManagedUser | undefined = user.data.value; @@ -94,13 +122,18 @@ const UserDetails: Component = defineComponent({ error: toManagementErrorMessage(t, user.error.value, 'admin.users.details.error') ?? toManagementErrorMessage(t, deletion.error.value, 'admin.users.details.delete.error') ?? - toManagementErrorMessage(t, update.error.value, 'admin.users.details.edit.error'), + toManagementErrorMessage(t, update.error.value, 'admin.users.details.edit.error') ?? + toManagementErrorMessage(t, lifecycle.error.value, 'admin.common.lifecycle.error') ?? + toManagementErrorMessage(t, lifecycle.loadError.value, 'admin.common.lifecycle.load_error'), groups: groups.data.value?.groups ?? [], hiddenAttributes: props.hiddenAttributes, isDeleting: deletion.isLoading.value, isLoading: user.isLoading.value, + isRunningLifecycleAction: lifecycle.isRunning.value, isUpdating: update.isLoading.value, + lifecycleActions: lifecycle.actions.value, onDelete: props.deletable ? (): void => void deletion.mutate(props.userId) : undefined, + onLifecycleAction: props.lifecycleActions ? lifecycle.run : undefined, onUpdate: props.editable && current ? (attributes: Record): void => diff --git a/packages/vue/src/components/admin/shared/AccessStatusBadge.ts b/packages/vue/src/components/admin/shared/AccessStatusBadge.ts new file mode 100644 index 00000000..38d08ada --- /dev/null +++ b/packages/vue/src/components/admin/shared/AccessStatusBadge.ts @@ -0,0 +1,38 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {type AccessStatusValue, withVendorCSSClassPrefix} from '@thunderid/browser'; +import {type Component, type PropType, type VNode, defineComponent, h} from 'vue'; +import type {AdminTranslate} from './useAdminTranslation'; + +const cls = (element: string): string => withVendorCSSClassPrefix(`admin__${element}`); + +/** + * The translated access status of an identity, or its raw value when the status is one this SDK does + * not know. Renders nothing when the server reports no status. + */ +const AccessStatusBadge: Component = defineComponent({ + name: 'AdminAccessStatusBadge', + props: { + /** The identity's effective access status. */ + status: {default: undefined, type: String as PropType}, + t: {required: true, type: Function as PropType}, + }, + setup(props: Readonly<{status: AccessStatusValue | undefined; t: AdminTranslate}>): () => VNode | null { + return (): VNode | null => { + if (!props.status) { + return null; + } + + const labels: Record = { + ACTIVE: props.t('admin.common.status.active'), + LOCKED: props.t('admin.common.status.locked'), + SUSPENDED: props.t('admin.common.status.suspended'), + }; + + return h('span', {class: cls('chip')}, labels[props.status] ?? props.status); + }; + }, +}); + +export default AccessStatusBadge; diff --git a/packages/vue/src/components/admin/shared/AccountLifecycleButtons.ts b/packages/vue/src/components/admin/shared/AccountLifecycleButtons.ts new file mode 100644 index 00000000..c77568f3 --- /dev/null +++ b/packages/vue/src/components/admin/shared/AccountLifecycleButtons.ts @@ -0,0 +1,144 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {type AccountLifecycleAction, withVendorCSSClassPrefix} from '@thunderid/browser'; +import { + type Component, + type PropType, + type Ref, + type SetupContext, + type VNode, + defineComponent, + h, + ref, + useId, +} from 'vue'; +import type {AdminTranslate} from './useAdminTranslation'; +import Button from '../../primitives/Button/Button'; +import TextField from '../../primitives/TextField/TextField'; + +const cls = (element: string): string => withVendorCSSClassPrefix(`admin__${element}`); + +/** + * Matches the server's limit on an operator note. + */ +const OPERATOR_NOTE_MAX_LENGTH = 100; + +type AccountLifecycleButtonsProps = Readonly<{ + actions: AccountLifecycleAction[]; + collectsOperatorNote: boolean; + isRunning: boolean; + name: string; + t: AdminTranslate; +}>; + +/** + * One button per account lifecycle action. Suspending and unsuspending ask for confirmation inline; + * unlocking is remedial and runs straight away. Emits `action` with the action and, on a suspension + * that collects one, the operator's note. + */ +const AccountLifecycleButtons: Component = defineComponent({ + name: 'AdminAccountLifecycleButtons', + props: { + /** The actions to offer, most expected first. */ + actions: {required: true, type: Array as PropType}, + /** Whether a suspension asks for an optional operator note. The note is recorded by the flow. */ + collectsOperatorNote: {default: false, type: Boolean}, + isRunning: {default: false, type: Boolean}, + /** Name of the identity, already resolved, used in the confirmation question. */ + name: {required: true, type: String}, + t: {required: true, type: Function as PropType}, + }, + emits: ['action'], + setup(props: AccountLifecycleButtonsProps, {emit}: SetupContext): () => VNode | VNode[] | null { + const uid: string = useId(); + const confirming: Ref = ref(null); + const operatorNote: Ref = ref(''); + + const close = (): void => { + confirming.value = null; + operatorNote.value = ''; + }; + + return (): VNode | VNode[] | null => { + const {t} = props; + + if (props.actions.length === 0) { + return null; + } + + const labels: Record = { + suspend: t('admin.common.lifecycle.suspend'), + unlock: t('admin.common.lifecycle.unlock'), + unsuspend: t('admin.common.lifecycle.unsuspend'), + }; + const action: AccountLifecycleAction | null = confirming.value; + + if (action && props.actions.includes(action)) { + const question: string = + action === 'suspend' + ? t('admin.common.lifecycle.suspend_confirm', {name: props.name}) + : t('admin.common.lifecycle.unsuspend_confirm', {name: props.name}); + const withNote: boolean = props.collectsOperatorNote && action === 'suspend'; + + return h('span', {'aria-label': question, class: cls('confirm'), role: 'group'}, [ + h('span', {}, question), + withNote + ? h(TextField, { + disabled: props.isRunning, + label: t('admin.common.lifecycle.operator_note'), + maxLength: OPERATOR_NOTE_MAX_LENGTH, + modelValue: operatorNote.value, + name: `${uid}-operator-note`, + 'onUpdate:modelValue': (value: string): void => { + operatorNote.value = value; + }, + }) + : null, + h( + Button, + {disabled: props.isRunning, onClick: close, size: 'small', variant: 'text'}, + {default: (): string => t('admin.common.actions.cancel')}, + ), + h( + Button, + { + color: action === 'suspend' ? 'danger' : 'primary', + disabled: props.isRunning, + loading: props.isRunning, + onClick: (): void => { + emit('action', action, withNote ? operatorNote.value.trim() || undefined : undefined); + close(); + }, + size: 'small', + }, + {default: (): string => labels[action]}, + ), + ]); + } + + return props.actions.map((offered: AccountLifecycleAction) => + h( + Button, + { + color: offered === 'suspend' ? 'danger' : 'primary', + disabled: props.isRunning, + key: offered, + loading: props.isRunning && offered === 'unlock', + onClick: (): void => { + if (offered === 'unlock') { + emit('action', offered); + } else { + confirming.value = offered; + } + }, + variant: 'outline', + }, + {default: (): string => labels[offered]}, + ), + ); + }; + }, +}); + +export default AccountLifecycleButtons; diff --git a/packages/vue/src/components/primitives/TextField/TextField.ts b/packages/vue/src/components/primitives/TextField/TextField.ts index e0fd76fa..7b1764ea 100644 --- a/packages/vue/src/components/primitives/TextField/TextField.ts +++ b/packages/vue/src/components/primitives/TextField/TextField.ts @@ -11,6 +11,7 @@ type TextFieldProps = Readonly<{ helperText: string | undefined; id: string | undefined; label: string | undefined; + maxLength: number | undefined; modelValue: string; name: string | undefined; placeholder: string | undefined; @@ -27,6 +28,8 @@ const TextField: Component = defineComponent({ helperText: {default: undefined, type: String}, id: {default: undefined, type: String}, label: {default: undefined, type: String}, + /** The most characters the field accepts. */ + maxLength: {default: undefined, type: Number}, modelValue: {default: '', type: String}, name: {default: undefined, type: String}, placeholder: {default: undefined, type: String}, @@ -77,6 +80,7 @@ const TextField: Component = defineComponent({ 'data-testid': attrs['data-testid'], disabled: props.disabled, id: props.id ?? props.name, + maxlength: props.maxLength, name: props.name, onBlur: () => emit('blur'), onInput: (e: Event) => emit('update:modelValue', (e.target as HTMLInputElement).value), diff --git a/packages/vue/src/composables/management/useAccountLifecycle.ts b/packages/vue/src/composables/management/useAccountLifecycle.ts new file mode 100644 index 00000000..f4e74a4f --- /dev/null +++ b/packages/vue/src/composables/management/useAccountLifecycle.ts @@ -0,0 +1,109 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import { + type AccessStatusValue, + type AccountLifecycleAction, + AccountLifecycleFlowConfigKey, + type AccountLifecycleResult, + type AccountLifecycleSubjectKind, + type ResourceQueryKey, + getAccountLifecycleActions, + resolveAdministrationFlows, + runAccountLifecycleAction, +} from '@thunderid/browser'; +import {type ComputedRef, computed} from 'vue'; +import useResourceMutation, {type ResourceMutationResult} from './useResourceMutation'; +import useResourceQuery, {type ResourceQueryResult} from './useResourceQuery'; +import type {ThunderIDContext} from '../../models/contexts'; +import useThunderID from '../useThunderID'; + +interface AccountLifecycleVariables { + action: AccountLifecycleAction; + operatorNote?: string; +} + +export interface AccountLifecycleOptions { + /** + * Whether to offer the actions at all. The configured flows are read only when it is on. + */ + enabled: () => boolean; + /** + * Query keys to refetch after an action, such as the record and the list it appears in. + */ + invalidatedKeys: () => ResourceQueryKey[]; + kind: AccountLifecycleSubjectKind; + /** + * The identity's effective access status, from the read that rendered it. + */ + status: () => AccessStatusValue | undefined; + /** + * Identifier of the user, agent or application. + */ + subject: () => string; +} + +export interface AccountLifecycleState { + /** + * The actions to offer now: allowed by the status and backed by a configured flow. + */ + actions: ComputedRef; + /** + * Why the last action failed. + */ + error: ComputedRef; + isRunning: ComputedRef; + /** + * Why the configured flows could not be read, so no action can be offered. + */ + loadError: ComputedRef; + run: (action: AccountLifecycleAction, operatorNote?: string) => void; +} + +/** + * Offers the account lifecycle actions (suspend, unsuspend, unlock) of a user, agent or application and + * runs them. These actions have no management API endpoint: they run only through the administration + * flows the server configures, so an action is offered only when its flow is configured. + * + * @example + * ```typescript + * const lifecycle = useAccountLifecycle({ + * enabled: () => props.lifecycleActions, + * invalidatedKeys: () => [[UserQueryKeys.USER, props.userId]], + * kind: 'user', + * status: () => user.data.value?.status?.value, + * subject: () => props.userId, + * }); + * ``` + */ +const useAccountLifecycle = ({ + enabled, + invalidatedKeys, + kind, + status, + subject, +}: AccountLifecycleOptions): AccountLifecycleState => { + const {baseUrl}: ThunderIDContext = useThunderID(); + const flows: ResourceQueryResult> = useResourceQuery( + () => ['administration-flows', 'account-lifecycle', kind], + (fetcher) => resolveAdministrationFlows({baseUrl, fetcher}, Object.values(AccountLifecycleFlowConfigKey[kind])), + {enabled}, + ); + const execution: ResourceMutationResult = useResourceMutation( + ({action, operatorNote}: AccountLifecycleVariables, fetcher) => + runAccountLifecycleAction({action, baseUrl, fetcher, kind, operatorNote, subject: subject()}), + () => invalidatedKeys(), + ); + + return { + actions: computed((): AccountLifecycleAction[] => + enabled() && flows.data.value ? getAccountLifecycleActions(kind, status(), flows.data.value) : [], + ), + error: computed((): Error | null => execution.error.value), + isRunning: computed((): boolean => execution.isLoading.value), + loadError: computed((): Error | null => flows.error.value), + run: (action: AccountLifecycleAction, operatorNote?: string): void => void execution.mutate({action, operatorNote}), + }; +}; + +export default useAccountLifecycle; diff --git a/packages/vue/src/index.ts b/packages/vue/src/index.ts index c824b082..71dcfe8f 100644 --- a/packages/vue/src/index.ts +++ b/packages/vue/src/index.ts @@ -381,6 +381,7 @@ export { type UpdateGroupRequest, type UpdateManagedUserRequest, type UpdateRoleRequest, + AccountLifecycleFlowConfigKey, AdministrationFlowConfigKey, AdministrationFlowData, AdministrationFlowError, @@ -391,6 +392,7 @@ export { deleteUserType, executeAdministrationFlow, findAdministrationFlows, + getAccountLifecycleActions, getAgentType, getUserType, getUserTypes, @@ -556,6 +558,7 @@ export { resolveTranslations, restoreFlowVersion, revertGatewayConfiguration, + runAccountLifecycleAction, setTranslation, setTranslations, suspendAgent, @@ -586,8 +589,16 @@ export { updateVariable, } from '@thunderid/browser'; export type { + AccessHoldReason, + AccessLockedMethod, + AccessStatus, + AccessStatusDetails, + AccessStatusValue, + AccountLifecycleAction, AccountLifecycleOptions, AccountLifecycleResult, + AccountLifecycleSubjectKind, + RunAccountLifecycleActionConfig, ApplyGatewayConfigurationConfig, ApplyGatewayConfigurationRequest, CaptureConfigurationVersionRequest, diff --git a/playgrounds/react/src/pages/explorer/adminStories.tsx b/playgrounds/react/src/pages/explorer/adminStories.tsx index 2c0855b1..109626cb 100644 --- a/playgrounds/react/src/pages/explorer/adminStories.tsx +++ b/playgrounds/react/src/pages/explorer/adminStories.tsx @@ -2,6 +2,9 @@ // SPDX-License-Identifier: Apache-2.0 import { + type AccessStatusValue, + type AccountLifecycleAction, + type AdministrationMode, type Agent, AgentCreate, AgentDetails, @@ -43,6 +46,7 @@ import { UserCreate, UserDetails, UserList, + getAccountLifecycleActions, } from '@thunderid/react'; import {ReactElement} from 'react'; import { @@ -71,6 +75,15 @@ import {bool, jsx, num, SOURCE_CONTROL, str} from './storyHelpers'; import {Narrow} from './StoryWidgets'; import type {Action, Args, ComponentDef, PropControl, Snippet} from './types'; +/** + * Every user lifecycle flow configured, so the sample offers whatever its status allows. + */ +const ALL_USER_LIFECYCLE_FLOWS: Record = { + userSuspendFlow: 'user-suspend', + userUnlockFlow: 'user-unlock', + userUnsuspendFlow: 'user-unsuspend', +}; + const isLive = (args: Args): boolean => str(args, 'source') === 'live'; const pick = (args: Args, ...keys: string[]): Args => @@ -466,7 +479,11 @@ const STORIES: ComponentDef[] = [ isLive(args) ? { imports: ['UserDetails'], - jsx: jsx('UserDetails', pick(args, 'userId', 'editable', 'deletable'), {extra: ['onDeleted={backToList}']}), + jsx: jsx( + 'UserDetails', + pick(args, 'userId', 'editable', 'deletable', 'lifecycleActions', 'administrationMode'), + {extra: ['onDeleted={backToList}']}, + ), } : { imports: ['BaseUserDetails'], @@ -476,6 +493,9 @@ const STORIES: ComponentDef[] = [ 'groups={groups}', ...(bool(args, 'editable') ? ['onUpdate={saveAttributes}'] : []), ...(bool(args, 'deletable') ? ['onDelete={deleteUser}'] : []), + ...(bool(args, 'lifecycleActions') + ? ['lifecycleActions={lifecycleActions}', 'onLifecycleAction={runLifecycleAction}'] + : []), ], }), }, @@ -484,10 +504,38 @@ const STORIES: ComponentDef[] = [ idControl('userId'), {control: 'bool', description: 'Allows editing string attributes (onUpdate).', name: 'editable', type: 'boolean'}, {control: 'bool', description: 'Shows the delete button (onDelete).', name: 'deletable', type: 'boolean'}, + { + control: 'bool', + description: 'Offers suspend, unsuspend and unlock through the configured administration flows.', + name: 'lifecycleActions', + type: 'boolean', + }, + { + control: ['ACTIVE', 'LOCKED', 'SUSPENDED'], + description: "The sample user's access status, which decides the lifecycle actions offered (base only).", + name: 'status', + type: "'ACTIVE' | 'LOCKED' | 'SUSPENDED'", + }, + { + control: ['native', 'flow'], + description: 'How the delete runs: the management API, or the configured user deletion flow (live only).', + name: 'administrationMode', + type: "'native' | 'flow'", + }, LOADING, ERROR, ], - defaults: {deletable: true, editable: true, error: '', isLoading: false, source: 'base', userId: ''}, + defaults: { + administrationMode: 'native', + deletable: true, + editable: true, + error: '', + isLoading: false, + lifecycleActions: true, + source: 'base', + status: 'LOCKED', + userId: '', + }, group: 'Admin: users', id: 'userdetails', live: true, @@ -502,16 +550,27 @@ const STORIES: ComponentDef[] = [ userId={str(args, 'userId')} editable={bool(args, 'editable')} deletable={bool(args, 'deletable')} + lifecycleActions={bool(args, 'lifecycleActions')} + administrationMode={str(args, 'administrationMode') as AdministrationMode} onDeleted={() => action('onDeleted')} /> ); } + const status: AccessStatusValue = str(args, 'status') as AccessStatusValue; + return ( + action('onLifecycleAction', operatorNote ? `${lifecycleAction}: ${operatorNote}` : lifecycleAction) + : undefined + } groups={SAMPLE_MEMBERSHIPS} isLoading={bool(args, 'isLoading')} error={str(args, 'error') || null} @@ -527,9 +586,11 @@ const STORIES: ComponentDef[] = [ }, stories: [ {id: 'base', name: 'Sample user'}, - {args: {deletable: false, editable: false}, id: 'readonly', name: 'Read only'}, + {args: {deletable: false, editable: false, lifecycleActions: false}, id: 'readonly', name: 'Read only'}, + {args: {status: 'SUSPENDED'}, id: 'suspended', name: 'Suspended'}, {args: {isLoading: true}, id: 'loading', name: 'Loading'}, {args: {source: 'live'}, id: 'live', name: 'Live (needs userId)'}, + {args: {administrationMode: 'flow', source: 'live'}, id: 'live-flows', name: 'Live through administration flows'}, ], }, { diff --git a/playgrounds/vue/src/pages/explorer/adminStories.ts b/playgrounds/vue/src/pages/explorer/adminStories.ts index cecac645..8e681b74 100644 --- a/playgrounds/vue/src/pages/explorer/adminStories.ts +++ b/playgrounds/vue/src/pages/explorer/adminStories.ts @@ -2,6 +2,9 @@ // SPDX-License-Identifier: Apache-2.0 import { + AccessStatusValue, + AccountLifecycleAction, + AdministrationMode, Agent, AgentCreate, AgentDetails, @@ -56,6 +59,7 @@ import { UserCreate, UserDetails, UserList, + getAccountLifecycleActions, } from '@thunderid/vue'; import {Component, h, VNode} from 'vue'; import DataTableHost from './DataTableHost'; @@ -72,6 +76,15 @@ import useApiQuery from '../../lib/useApiQuery'; const OU = 'ou_7d1e'; +/** + * Every user lifecycle flow configured, so the sample offers whatever its status allows. + */ +const ALL_USER_LIFECYCLE_FLOWS: Record = { + userSuspendFlow: 'user-suspend', + userUnlockFlow: 'user-unlock', + userUnsuspendFlow: 'user-unsuspend', +}; + const SAMPLE_USERS: ManagedUser[] = [ { attributes: {email: 'liam.chen@acme.io', family_name: 'Chen', given_name: 'Liam', username: 'liam.chen'}, @@ -629,12 +642,14 @@ export const ADMIN_COMPONENTS: ComponentDef[] = [ ? { imports: ['UserDetails'], script: handler('handleDeleted'), - template: tpl('UserDetails', omit(args, 'source', 'isLoading', 'error', 'userId'), { + template: tpl('UserDetails', omit(args, 'source', 'isLoading', 'error', 'userId', 'status'), { + defaults: {administrationMode: 'native'}, extra: [`user-id="${str(args, 'userId') || SAMPLE_USERS[0].id}"`, '@deleted="handleDeleted"'], }), } : { imports: [ + ...(bool(args, 'lifecycleActions') ? ['AccountLifecycleAction'] : []), 'BaseUserDetails', 'getUser', 'getUserGroups', @@ -660,6 +675,9 @@ export const ADMIN_COMPONENTS: ComponentDef[] = [ ? ['const handleUpdate = (attributes: Record): void => {};'] : []), ...(bool(args, 'deletable') ? handler('handleDelete') : []), + ...(bool(args, 'lifecycleActions') + ? ['const handleLifecycleAction = (action: AccountLifecycleAction, operatorNote?: string): void => {};'] + : []), ], template: tpl( 'BaseUserDetails', @@ -671,6 +689,9 @@ export const ADMIN_COMPONENTS: ComponentDef[] = [ ':is-loading="isLoading"', ...(bool(args, 'editable') ? ['@update="handleUpdate"'] : []), ...(bool(args, 'deletable') ? ['@delete="handleDelete"'] : []), + ...(bool(args, 'lifecycleActions') + ? [`:lifecycle-actions="['unlock', 'suspend']"`, '@lifecycle-action="handleLifecycleAction"'] + : []), ], }, ), @@ -685,10 +706,38 @@ export const ADMIN_COMPONENTS: ComponentDef[] = [ name: 'deletable', type: 'boolean', }, + { + control: 'bool', + description: 'Offers suspend, unsuspend and unlock through the configured administration flows.', + name: 'lifecycleActions', + type: 'boolean', + }, + { + control: ['ACTIVE', 'LOCKED', 'SUSPENDED'], + description: "The sample user's access status, which decides the lifecycle actions offered (base only).", + name: 'status', + type: "'ACTIVE' | 'LOCKED' | 'SUSPENDED'", + }, + { + control: ['native', 'flow'], + description: 'How the delete runs: the management API, or the configured user deletion flow (live only).', + name: 'administrationMode', + type: "'native' | 'flow'", + }, idControl('userId', 'user'), ...baseStateControls, ], - defaults: {deletable: true, editable: true, error: '', isLoading: false, source: 'base', userId: ''}, + defaults: { + administrationMode: 'native', + deletable: true, + editable: true, + error: '', + isLoading: false, + lifecycleActions: true, + source: 'base', + status: 'LOCKED', + userId: '', + }, group: 'Admin', id: 'userdetails', live: true, @@ -704,9 +753,11 @@ export const ADMIN_COMPONENTS: ComponentDef[] = [ query: () => useApiQuery(() => getUsers({...useManagementRequestConfig('users'), limit: 1})), render: (userId: string): VNode => h(UserDetails, { + administrationMode: str(args, 'administrationMode') as AdministrationMode, deletable: bool(args, 'deletable'), editable: bool(args, 'editable'), key: userId, + lifecycleActions: bool(args, 'lifecycleActions'), onDeleted: () => action('deleted', quoted(userId)), userId, }), @@ -715,18 +766,29 @@ export const ADMIN_COMPONENTS: ComponentDef[] = [ error: str(args, 'error') || null, groups: SAMPLE_MEMBERSHIPS, isLoading: bool(args, 'isLoading'), + lifecycleActions: getAccountLifecycleActions( + 'user', + str(args, 'status') as AccessStatusValue, + ALL_USER_LIFECYCLE_FLOWS, + ), onDelete: bool(args, 'deletable') ? (): void => action('delete', quoted(SAMPLE_USERS[0].id)) : undefined, + onLifecycleAction: bool(args, 'lifecycleActions') + ? (lifecycleAction: AccountLifecycleAction, operatorNote?: string): void => + action('lifecycle-action', operatorNote ? `${lifecycleAction}: ${operatorNote}` : lifecycleAction) + : undefined, onUpdate: bool(args, 'editable') ? (attributes: Record): void => action('update', JSON.stringify(attributes)) : undefined, - user: SAMPLE_USERS[0], + user: {...SAMPLE_USERS[0], status: {value: str(args, 'status') as AccessStatusValue}}, }), ), stories: [ {id: 'base', name: 'Sample user'}, - {args: {deletable: false, editable: false}, id: 'readonly', name: 'Read only'}, + {args: {deletable: false, editable: false, lifecycleActions: false}, id: 'readonly', name: 'Read only'}, + {args: {status: 'SUSPENDED'}, id: 'suspended', name: 'Suspended'}, {args: {isLoading: true}, id: 'loading', name: 'Loading'}, {args: {source: 'live'}, id: 'live', name: 'Live (first user)'}, + {args: {administrationMode: 'flow', source: 'live'}, id: 'live-flows', name: 'Live through administration flows'}, ], }, listStory({