Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<ApiFetcher>;

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<string, string> = {}): FetcherMock =>
vi.fn<ApiFetcher>((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<ApiFetcher> | undefined = fetcher.mock.calls.find(
([url]: Parameters<ApiFetcher>) => 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<ApiFetcher>(() => 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<ApiFetcher>();

await expect(
runAccountLifecycleAction({action: 'unlock', baseUrl, fetcher, kind: 'application', subject: 'app-1'}),
).rejects.toMatchObject({code: 'runAccountLifecycleAction-ValidationError-001'});
expect(fetcher).not.toHaveBeenCalled();
});
});
Original file line number Diff line number Diff line change
@@ -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<AccountLifecycleResult>;

/**
* The operation behind each action, by the kind of identity. Applications are never locked.
*/
const OPERATIONS: Record<AccountLifecycleSubjectKind, Partial<Record<AccountLifecycleAction, LifecycleOperation>>> = {
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<AccountLifecycleResult> => {
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;
25 changes: 25 additions & 0 deletions packages/javascript/src/constants/AdministrationConstants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
12 changes: 12 additions & 0 deletions packages/javascript/src/i18n/models/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
13 changes: 13 additions & 0 deletions packages/javascript/src/i18n/translations/en-US.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
Expand Down
14 changes: 14 additions & 0 deletions packages/javascript/src/i18n/translations/fr-FR.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
Expand Down
14 changes: 14 additions & 0 deletions packages/javascript/src/i18n/translations/hi-IN.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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': 'यह आइटम बनाने से पहले एक संगठन इकाई चुनें।',
Expand Down
14 changes: 14 additions & 0 deletions packages/javascript/src/i18n/translations/ja-JP.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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': 'この項目を作成する前に組織単位を選択してください。',
Expand Down
14 changes: 14 additions & 0 deletions packages/javascript/src/i18n/translations/pt-BR.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
Expand Down
14 changes: 14 additions & 0 deletions packages/javascript/src/i18n/translations/pt-PT.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
Expand Down
Loading
Loading