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({