diff --git a/packages/javascript/src/api/auth/__tests__/executeEmbeddedSelfFlow.test.ts b/packages/javascript/src/api/auth/__tests__/executeEmbeddedSelfFlow.test.ts new file mode 100644 index 00000000..64296dad --- /dev/null +++ b/packages/javascript/src/api/auth/__tests__/executeEmbeddedSelfFlow.test.ts @@ -0,0 +1,216 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {beforeEach, describe, expect, it, Mock, vi} from 'vitest'; +import ThunderIDAPIError from '../../../errors/ThunderIDAPIError'; +import {EmbeddedFlowType} from '../../../models/embedded-flow'; +import { + EmbeddedSelfFlowRequest, + EmbeddedSelfFlowResponse, + EmbeddedSelfFlowStatus, +} from '../../../models/embedded-self-flow'; +import executeEmbeddedSelfFlow from '../executeEmbeddedSelfFlow'; + +const URL = 'https://localhost:8090/flow/execute'; + +const initiate: EmbeddedSelfFlowRequest = { + flowType: EmbeddedFlowType.Self, + handle: 'default-self-attribute-verification-flow', + inputs: {attribute: 'email'}, +}; + +type Fetcher = (url: string, config: RequestInit) => Promise; + +const flowResponse: EmbeddedSelfFlowResponse = { + data: {additionalData: {emailSent: 'true', maskedDestination: 'a•••@example.com'}}, + executionId: 'exec-1', + flowStatus: EmbeddedSelfFlowStatus.Incomplete, +}; + +const lastRequest = (fetcher: Mock): [string, RequestInit] => + fetcher.mock.calls[fetcher.mock.calls.length - 1] as [string, RequestInit]; + +describe('executeEmbeddedSelfFlow', (): void => { + let fetcher: Mock; + + beforeEach((): void => { + fetcher = vi.fn().mockResolvedValue({json: () => Promise.resolve(flowResponse), ok: true} as Response); + }); + + it('starts the flow by type and handle with its inputs and returns the step', async (): Promise => { + await expect(executeEmbeddedSelfFlow({fetcher, payload: initiate, url: URL})).resolves.toEqual(flowResponse); + + const [url, init] = lastRequest(fetcher); + expect(url).toBe(URL); + expect(init.method).toBe('POST'); + expect(JSON.parse(init.body as string)).toEqual({ + flowType: 'SELF', + handle: 'default-self-attribute-verification-flow', + inputs: {attribute: 'email'}, + verbose: true, + }); + expect(init.headers).toMatchObject({Accept: 'application/json', 'Content-Type': 'application/json'}); + }); + + it('continues an execution without naming the handle again', async (): Promise => { + await executeEmbeddedSelfFlow({ + fetcher, + payload: {action: 'verify', challengeToken: 'ct-1', executionId: 'exec-1', inputs: {otp: '123456'}}, + url: URL, + }); + + expect(JSON.parse(lastRequest(fetcher)[1].body as string)).toEqual({ + action: 'verify', + challengeToken: 'ct-1', + executionId: 'exec-1', + inputs: {otp: '123456'}, + verbose: true, + }); + }); + + it('builds the endpoint from baseUrl when url is not given', async (): Promise => { + await executeEmbeddedSelfFlow({baseUrl: 'https://localhost:8090', fetcher, payload: initiate}); + + expect(lastRequest(fetcher)[0]).toBe('https://localhost:8090/flow/execute'); + }); + + it('keeps caller headers while setting the JSON headers', async (): Promise => { + await executeEmbeddedSelfFlow({ + fetcher, + headers: {'X-Request-Id': 'req-1'}, + payload: initiate, + url: URL, + }); + + expect(lastRequest(fetcher)[1].headers).toMatchObject({ + 'Content-Type': 'application/json', + 'X-Request-Id': 'req-1', + }); + }); + + it('uses native fetch when no fetcher is given', async (): Promise => { + const nativeFetch = vi.fn().mockResolvedValue({json: () => Promise.resolve(flowResponse), ok: true}); + vi.stubGlobal('fetch', nativeFetch); + + try { + await executeEmbeddedSelfFlow({payload: initiate, url: URL}); + } finally { + vi.unstubAllGlobals(); + } + + expect(nativeFetch).toHaveBeenCalledWith(URL, expect.objectContaining({method: 'POST'})); + }); + + describe('validation', (): void => { + it.each([ + ['no url', {}], + ['an invalid url', {url: 'not-a-url'}], + ['an invalid baseUrl', {baseUrl: 'invalid'}], + ])('refuses %s before sending', async (_name: string, endpoint: Record): Promise => { + const error: ThunderIDAPIError = (await executeEmbeddedSelfFlow({ + ...endpoint, + fetcher, + payload: initiate, + }).catch((e: unknown) => e as ThunderIDAPIError)) as ThunderIDAPIError; + + expect(error).toBeInstanceOf(ThunderIDAPIError); + expect(error.code).toBe('executeEmbeddedSelfFlow-ValidationError-001'); + expect(error.statusCode).toBe(400); + expect(fetcher).not.toHaveBeenCalled(); + }); + + it('refuses a missing payload before sending', async (): Promise => { + const error: ThunderIDAPIError = (await executeEmbeddedSelfFlow({ + fetcher, + payload: undefined as unknown as EmbeddedSelfFlowRequest, + url: URL, + }).catch((e: unknown) => e as ThunderIDAPIError)) as ThunderIDAPIError; + + expect(error).toBeInstanceOf(ThunderIDAPIError); + expect(error.code).toBe('executeEmbeddedSelfFlow-ValidationError-002'); + expect(fetcher).not.toHaveBeenCalled(); + }); + + it.each<[string, EmbeddedSelfFlowRequest]>([ + ['no handle', {flowType: EmbeddedFlowType.Self, inputs: {attribute: 'email'}}], + ['an empty handle', {flowType: EmbeddedFlowType.Self, handle: '', inputs: {attribute: 'email'}}], + ])( + 'refuses a new flow with %s before sending', + async (_name: string, payload: EmbeddedSelfFlowRequest): Promise => { + const error: ThunderIDAPIError = (await executeEmbeddedSelfFlow({ + fetcher, + payload, + url: URL, + }).catch((e: unknown) => e as ThunderIDAPIError)) as ThunderIDAPIError; + + expect(error).toBeInstanceOf(ThunderIDAPIError); + expect(error.code).toBe('executeEmbeddedSelfFlow-ValidationError-003'); + expect(fetcher).not.toHaveBeenCalled(); + }, + ); + }); + + describe('response errors', (): void => { + it('maps a failed response to an API error with its status', async (): Promise => { + fetcher.mockResolvedValue({ + ok: false, + status: 401, + statusText: 'Unauthorized', + text: () => Promise.resolve('{"code":"AUTH-1001","message":"Unauthorized","description":"invalid token"}'), + } as unknown as Response); + + const error: ThunderIDAPIError = (await executeEmbeddedSelfFlow({ + fetcher, + payload: initiate, + url: URL, + }).catch((e: unknown) => e as ThunderIDAPIError)) as ThunderIDAPIError; + + expect(error).toBeInstanceOf(ThunderIDAPIError); + expect(error.code).toBe('executeEmbeddedSelfFlow-ResponseError-001'); + expect(error.statusCode).toBe(401); + expect(error.statusText).toBe('Unauthorized'); + expect(error.message).toMatch(/^SELF flow request failed: /); + }); + + it('maps a network failure to a network error', async (): Promise => { + fetcher.mockRejectedValue(new TypeError('Failed to fetch')); + + const error: ThunderIDAPIError = (await executeEmbeddedSelfFlow({ + fetcher, + payload: initiate, + url: URL, + }).catch((e: unknown) => e as ThunderIDAPIError)) as ThunderIDAPIError; + + expect(error).toBeInstanceOf(ThunderIDAPIError); + expect(error.code).toBe('executeEmbeddedSelfFlow-NetworkError-001'); + expect(error.statusCode).toBe(0); + expect(error.message).toContain('Failed to fetch'); + }); + + it('maps an unreadable body to a network error', async (): Promise => { + fetcher.mockResolvedValue({ + json: () => Promise.reject(new SyntaxError('Unexpected token')), + ok: true, + } as unknown as Response); + + const error: ThunderIDAPIError = (await executeEmbeddedSelfFlow({ + fetcher, + payload: initiate, + url: URL, + }).catch((e: unknown) => e as ThunderIDAPIError)) as ThunderIDAPIError; + + expect(error.code).toBe('executeEmbeddedSelfFlow-NetworkError-001'); + }); + + it('returns a flow error step as data rather than throwing', async (): Promise => { + const refused: EmbeddedSelfFlowResponse = { + error: {code: 'FET-1112'} as EmbeddedSelfFlowResponse['error'], + executionId: 'exec-1', + flowStatus: EmbeddedSelfFlowStatus.Error, + }; + fetcher.mockResolvedValue({json: () => Promise.resolve(refused), ok: true} as unknown as Response); + + await expect(executeEmbeddedSelfFlow({fetcher, payload: initiate, url: URL})).resolves.toEqual(refused); + }); + }); +}); diff --git a/packages/javascript/src/api/auth/executeEmbeddedSelfFlow.ts b/packages/javascript/src/api/auth/executeEmbeddedSelfFlow.ts new file mode 100644 index 00000000..ab706219 --- /dev/null +++ b/packages/javascript/src/api/auth/executeEmbeddedSelfFlow.ts @@ -0,0 +1,148 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import ThunderIDAPIError from '../../errors/ThunderIDAPIError'; +import {EmbeddedSelfFlowRequest, EmbeddedSelfFlowResponse} from '../../models/embedded-self-flow'; + +/** + * Configuration for the executeEmbeddedSelfFlow request. + */ +export interface ExecuteEmbeddedSelfFlowConfig extends Omit { + /** + * The base path of the API endpoint. + */ + baseUrl?: string; + /** + * Optional custom fetcher function. If not provided, native fetch is used, which sends no + * access token; framework SDKs pass a fetcher that attaches the signed-in user's token. + */ + fetcher?: (url: string, config: RequestInit) => Promise; + /** + * The step to execute: an initiating request or a continuation. + */ + payload: EmbeddedSelfFlowRequest; + /** + * The absolute flow execution endpoint. + */ + url?: string; +} + +/** + * Executes one step of a `SELF` flow, an operation the signed-in user performs on their own + * account, such as verifying their email address. A new execution names the flow by its handle; the + * server takes the user from the access token on that first request and keeps it for the execution. + * + * @param config - Configuration object with URL, payload, fetcher and optional request config. + * @returns A promise that resolves with the flow step. + * @throws ThunderIDAPIError when the input is invalid or the request fails, coded + * `executeEmbeddedSelfFlow-` plus `ValidationError-001` (invalid `url` or `baseUrl`), `ValidationError-002` + * (no payload), `ValidationError-003` (a new flow without a handle), `ResponseError-001` (the server refused, with + * its status) or `NetworkError-001` (no response, or an unreadable one). + * + * @example + * ```typescript + * const step = await executeEmbeddedSelfFlow({ + * baseUrl: 'https://localhost:8090', + * payload: { + * flowType: 'SELF', + * handle: 'default-self-attribute-verification-flow', + * inputs: {attribute: 'email'}, + * }, + * fetcher: authenticatedFetcher, + * }); + * + * await executeEmbeddedSelfFlow({ + * baseUrl: 'https://localhost:8090', + * payload: {executionId: step.executionId, action: 'verify', inputs: {otp: '123456'}}, + * fetcher: authenticatedFetcher, + * }); + * ``` + */ +const executeEmbeddedSelfFlow = async ({ + url, + baseUrl, + payload, + fetcher, + ...requestConfig +}: ExecuteEmbeddedSelfFlowConfig): Promise => { + try { + // eslint-disable-next-line no-new + new URL((url ?? baseUrl)!); + } catch (error) { + throw new ThunderIDAPIError( + `Invalid URL provided. ${error instanceof Error ? error.message : String(error)}`, + 'executeEmbeddedSelfFlow-ValidationError-001', + 'javascript', + 400, + 'The provided `url` or `baseUrl` path does not adhere to the URL schema.', + ); + } + + if (!payload) { + throw new ThunderIDAPIError( + 'SELF flow payload is required', + 'executeEmbeddedSelfFlow-ValidationError-002', + 'javascript', + 400, + 'If a SELF flow payload is not provided, the request cannot be constructed correctly.', + ); + } + + if (!payload.executionId && !payload.handle) { + throw new ThunderIDAPIError( + 'The flow handle is required', + 'executeEmbeddedSelfFlow-ValidationError-003', + 'javascript', + 400, + 'A new SELF flow must name the flow to start by its handle.', + ); + } + + const fetchFn: typeof fetch = fetcher ?? fetch; + const resolvedUrl: string = url ?? `${baseUrl}/flow/execute`; + + const requestInit: RequestInit = { + ...requestConfig, + // Every step is rendered from `meta`, so always ask for it. + body: JSON.stringify({...payload, verbose: true}), + headers: { + ...requestConfig.headers, + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + method: 'POST', + }; + + try { + const response: Response = await fetchFn(resolvedUrl, requestInit); + + if (!response?.ok) { + const errorText: string = await response.text(); + + throw new ThunderIDAPIError( + errorText, + 'executeEmbeddedSelfFlow-ResponseError-001', + 'javascript', + response.status, + response.statusText, + 'SELF flow request failed', + ); + } + + return (await response.json()) as EmbeddedSelfFlowResponse; + } catch (error) { + if (error instanceof ThunderIDAPIError) { + throw error; + } + + throw new ThunderIDAPIError( + `Network or parsing error: ${error instanceof Error ? error.message : 'Unknown error'}`, + 'executeEmbeddedSelfFlow-NetworkError-001', + 'javascript', + 0, + 'Network Error', + ); + } +}; + +export default executeEmbeddedSelfFlow; diff --git a/packages/javascript/src/constants/AttributeVerificationConstants.ts b/packages/javascript/src/constants/AttributeVerificationConstants.ts new file mode 100644 index 00000000..42d4d5c5 --- /dev/null +++ b/packages/javascript/src/constants/AttributeVerificationConstants.ts @@ -0,0 +1,42 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +/** + * Wire names and eligibility rules for self user attribute verification. + */ +const AttributeVerificationConstants: { + ATTRIBUTE_INPUT: string; + DEFAULT_FLOW_HANDLE: string; + MASKED_DESTINATION_KEY: string; + VERIFIABLE_ATTRIBUTE_TYPES: readonly string[]; + VERIFIED_ATTRIBUTES_KEY: string; + VERIFY_AFTER_SAVE_ATTRIBUTE_TYPES: readonly string[]; +} = { + /** + * Flow input naming the attribute to verify on the initial request. + */ + ATTRIBUTE_INPUT: 'attribute', + /** + * Handle of the self attribute verification flow the server ships, used unless the app names another. + */ + DEFAULT_FLOW_HANDLE: 'default-self-attribute-verification-flow', + /** + * `additionalData` key carrying the masked destination the code was sent to. + */ + MASKED_DESTINATION_KEY: 'maskedDestination', + /** + * Schema property types whose verification status is shown and which the user can verify. + */ + VERIFIABLE_ATTRIBUTE_TYPES: ['email', 'mobile'], + /** + * Read-only `/users/me` field holding the verified attributes. + */ + VERIFIED_ATTRIBUTES_KEY: 'verifiedAttributes', + /** + * Schema property types whose changed value opens verification after a save. Mobile is verified on + * request only, since SMS needs a provider the deployment may not have. + */ + VERIFY_AFTER_SAVE_ATTRIBUTE_TYPES: ['email'], +}; + +export default AttributeVerificationConstants; diff --git a/packages/javascript/src/i18n/models/i18n.ts b/packages/javascript/src/i18n/models/i18n.ts index 2f688a76..ace7d99f 100644 --- a/packages/javascript/src/i18n/models/i18n.ts +++ b/packages/javascript/src/i18n/models/i18n.ts @@ -124,6 +124,25 @@ export interface I18nTranslations { 'user.profile.heading': string; 'user.profile.update.generic.error': string; + /* |---------------------------------------------------------------| */ + /* | User Verification | */ + /* |---------------------------------------------------------------| */ + + 'user.verification.status.verified': string; + 'user.verification.status.unverified': string; + 'user.verification.status.verified_at': string; + 'user.verification.buttons.verify.text': string; + 'user.verification.buttons.cancel.text': string; + 'user.verification.buttons.close.text': string; + 'user.verification.buttons.done.text': string; + 'user.verification.heading': string; + 'user.verification.subheading': string; + 'user.verification.subheading.generic': string; + 'user.verification.subheading.link': string; + 'user.verification.destination': string; + 'user.verification.success': string; + 'user.verification.error': string; + /* |---------------------------------------------------------------| */ /* | Change Password | */ /* |---------------------------------------------------------------| */ diff --git a/packages/javascript/src/i18n/translations/en-US.ts b/packages/javascript/src/i18n/translations/en-US.ts index 830dfabd..fc349f85 100644 --- a/packages/javascript/src/i18n/translations/en-US.ts +++ b/packages/javascript/src/i18n/translations/en-US.ts @@ -124,6 +124,25 @@ const translations: I18nTranslations = { 'user.profile.heading': 'Profile', 'user.profile.update.generic.error': 'An error occurred while updating your profile. Please try again.', + /* |---------------------------------------------------------------| */ + /* | User Verification | */ + /* |---------------------------------------------------------------| */ + + 'user.verification.status.verified': 'Verified', + 'user.verification.status.unverified': 'Unverified', + 'user.verification.status.verified_at': 'Verified on {date}', + 'user.verification.buttons.verify.text': 'Verify', + 'user.verification.buttons.cancel.text': 'Cancel', + 'user.verification.buttons.close.text': 'Close', + 'user.verification.buttons.done.text': 'Done', + 'user.verification.heading': 'Verify {attribute}', + 'user.verification.subheading': 'Enter the code sent to {destination}.', + 'user.verification.subheading.generic': 'Enter the code sent to you.', + 'user.verification.subheading.link': 'We sent a verification link to {destination}. Open it, then select Done.', + 'user.verification.destination': 'Sent to {destination}.', + 'user.verification.success': '{attribute} verified.', + 'user.verification.error': 'Verification failed. Please try again.', + /* |---------------------------------------------------------------| */ /* | Change Password | */ /* |---------------------------------------------------------------| */ diff --git a/packages/javascript/src/i18n/translations/fr-FR.ts b/packages/javascript/src/i18n/translations/fr-FR.ts index c811c090..f229e5ed 100644 --- a/packages/javascript/src/i18n/translations/fr-FR.ts +++ b/packages/javascript/src/i18n/translations/fr-FR.ts @@ -125,6 +125,26 @@ const translations: I18nTranslations = { 'user.profile.update.generic.error': 'Une erreur est survenue lors de la mise à jour de votre profil. Veuillez réessayer.', + /* |---------------------------------------------------------------| */ + /* | User Verification | */ + /* |---------------------------------------------------------------| */ + + 'user.verification.status.verified': 'Vérifié', + 'user.verification.status.unverified': 'Non vérifié', + 'user.verification.status.verified_at': 'Vérifié le {date}', + 'user.verification.buttons.verify.text': 'Vérifier', + 'user.verification.buttons.cancel.text': 'Annuler', + 'user.verification.buttons.close.text': 'Fermer', + 'user.verification.buttons.done.text': 'Terminé', + 'user.verification.heading': 'Vérifier {attribute}', + 'user.verification.subheading': 'Saisissez le code envoyé à {destination}.', + 'user.verification.subheading.generic': 'Saisissez le code qui vous a été envoyé.', + 'user.verification.subheading.link': + 'Nous avons envoyé un lien de vérification à {destination}. Ouvrez-le, puis sélectionnez Terminé.', + 'user.verification.destination': 'Envoyé à {destination}.', + 'user.verification.success': 'Vérification de {attribute} réussie.', + 'user.verification.error': 'La vérification a échoué. Veuillez réessayer.', + /* |---------------------------------------------------------------| */ /* | Change Password | */ /* |---------------------------------------------------------------| */ diff --git a/packages/javascript/src/i18n/translations/hi-IN.ts b/packages/javascript/src/i18n/translations/hi-IN.ts index 1dfe8124..d008b622 100644 --- a/packages/javascript/src/i18n/translations/hi-IN.ts +++ b/packages/javascript/src/i18n/translations/hi-IN.ts @@ -123,6 +123,25 @@ const translations: I18nTranslations = { 'user.profile.heading': 'प्रोफ़ाइल', 'user.profile.update.generic.error': 'प्रोफ़ाइल अपडेट करते समय त्रुटि हुई। कृपया पुनः प्रयास करें।', + /* |---------------------------------------------------------------| */ + /* | User Verification | */ + /* |---------------------------------------------------------------| */ + + 'user.verification.status.verified': 'सत्यापित', + 'user.verification.status.unverified': 'असत्यापित', + 'user.verification.status.verified_at': '{date} को सत्यापित', + 'user.verification.buttons.verify.text': 'सत्यापित करें', + 'user.verification.buttons.cancel.text': 'रद्द करें', + 'user.verification.buttons.close.text': 'बंद करें', + 'user.verification.buttons.done.text': 'पूर्ण', + 'user.verification.heading': '{attribute} सत्यापित करें', + 'user.verification.subheading': '{destination} पर भेजा गया कोड दर्ज करें।', + 'user.verification.subheading.generic': 'आपको भेजा गया कोड दर्ज करें।', + 'user.verification.subheading.link': '{destination} पर एक सत्यापन लिंक भेजा गया है। उसे खोलें, फिर पूर्ण चुनें।', + 'user.verification.destination': '{destination} पर भेजा गया।', + 'user.verification.success': '{attribute} सत्यापित हो गया।', + 'user.verification.error': 'सत्यापन विफल रहा। कृपया पुनः प्रयास करें।', + /* |---------------------------------------------------------------| */ /* | Change Password | */ /* |---------------------------------------------------------------| */ diff --git a/packages/javascript/src/i18n/translations/ja-JP.ts b/packages/javascript/src/i18n/translations/ja-JP.ts index a35890c0..5922fd6f 100644 --- a/packages/javascript/src/i18n/translations/ja-JP.ts +++ b/packages/javascript/src/i18n/translations/ja-JP.ts @@ -123,6 +123,26 @@ const translations: I18nTranslations = { 'user.profile.heading': 'プロフィール', 'user.profile.update.generic.error': 'プロフィール更新中にエラーが発生しました。もう一度お試しください。', + /* |---------------------------------------------------------------| */ + /* | User Verification | */ + /* |---------------------------------------------------------------| */ + + 'user.verification.status.verified': '確認済み', + 'user.verification.status.unverified': '未確認', + 'user.verification.status.verified_at': '{date}に確認済み', + 'user.verification.buttons.verify.text': '確認する', + 'user.verification.buttons.cancel.text': 'キャンセル', + 'user.verification.buttons.close.text': '閉じる', + 'user.verification.buttons.done.text': '完了', + 'user.verification.heading': '{attribute}を確認', + 'user.verification.subheading': '{destination}に送信されたコードを入力してください。', + 'user.verification.subheading.generic': '送信されたコードを入力してください。', + 'user.verification.subheading.link': + '{destination}に確認リンクを送信しました。リンクを開いてから「完了」を選択してください。', + 'user.verification.destination': '{destination}に送信しました。', + 'user.verification.success': '{attribute}を確認しました。', + 'user.verification.error': '確認に失敗しました。もう一度お試しください。', + /* |---------------------------------------------------------------| */ /* | Change Password | */ /* |---------------------------------------------------------------| */ diff --git a/packages/javascript/src/i18n/translations/pt-BR.ts b/packages/javascript/src/i18n/translations/pt-BR.ts index 6d4ccd0c..abe62309 100644 --- a/packages/javascript/src/i18n/translations/pt-BR.ts +++ b/packages/javascript/src/i18n/translations/pt-BR.ts @@ -123,6 +123,26 @@ const translations: I18nTranslations = { 'user.profile.heading': 'Perfil', 'user.profile.update.generic.error': 'Ocorreu um erro ao atualizar seu perfil. Tente novamente.', + /* |---------------------------------------------------------------| */ + /* | User Verification | */ + /* |---------------------------------------------------------------| */ + + 'user.verification.status.verified': 'Verificado', + 'user.verification.status.unverified': 'Não verificado', + 'user.verification.status.verified_at': 'Verificado em {date}', + 'user.verification.buttons.verify.text': 'Verificar', + 'user.verification.buttons.cancel.text': 'Cancelar', + 'user.verification.buttons.close.text': 'Fechar', + 'user.verification.buttons.done.text': 'Concluído', + 'user.verification.heading': 'Verificar {attribute}', + 'user.verification.subheading': 'Digite o código enviado para {destination}.', + 'user.verification.subheading.generic': 'Digite o código enviado para você.', + 'user.verification.subheading.link': + 'Enviamos um link de verificação para {destination}. Abra-o e selecione Concluído.', + 'user.verification.destination': 'Enviado para {destination}.', + 'user.verification.success': 'Verificação de {attribute} concluída.', + 'user.verification.error': 'Falha na verificação. Tente novamente.', + /* |---------------------------------------------------------------| */ /* | Change Password | */ /* |---------------------------------------------------------------| */ diff --git a/packages/javascript/src/i18n/translations/pt-PT.ts b/packages/javascript/src/i18n/translations/pt-PT.ts index 35919bcd..56af9683 100644 --- a/packages/javascript/src/i18n/translations/pt-PT.ts +++ b/packages/javascript/src/i18n/translations/pt-PT.ts @@ -123,6 +123,26 @@ const translations: I18nTranslations = { 'user.profile.heading': 'Perfil', 'user.profile.update.generic.error': 'Ocorreu um erro ao actualizar o seu perfil. Tente novamente.', + /* |---------------------------------------------------------------| */ + /* | User Verification | */ + /* |---------------------------------------------------------------| */ + + 'user.verification.status.verified': 'Verificado', + 'user.verification.status.unverified': 'Não verificado', + 'user.verification.status.verified_at': 'Verificado em {date}', + 'user.verification.buttons.verify.text': 'Verificar', + 'user.verification.buttons.cancel.text': 'Cancelar', + 'user.verification.buttons.close.text': 'Fechar', + 'user.verification.buttons.done.text': 'Concluído', + 'user.verification.heading': 'Verificar {attribute}', + 'user.verification.subheading': 'Introduza o código enviado para {destination}.', + 'user.verification.subheading.generic': 'Introduza o código que lhe foi enviado.', + 'user.verification.subheading.link': + 'Enviámos uma ligação de verificação para {destination}. Abra-a e selecione Concluído.', + 'user.verification.destination': 'Enviado para {destination}.', + 'user.verification.success': 'Verificação de {attribute} concluída.', + 'user.verification.error': 'A verificação falhou. Tente novamente.', + /* |---------------------------------------------------------------| */ /* | Change Password | */ /* |---------------------------------------------------------------| */ diff --git a/packages/javascript/src/i18n/translations/si-LK.ts b/packages/javascript/src/i18n/translations/si-LK.ts index 8c9300d6..c465dd4a 100644 --- a/packages/javascript/src/i18n/translations/si-LK.ts +++ b/packages/javascript/src/i18n/translations/si-LK.ts @@ -123,6 +123,26 @@ const translations: I18nTranslations = { 'user.profile.heading': 'පැතිකඩ', 'user.profile.update.generic.error': 'ඔබේ පැතිකඩ යාවත්කාලීන කිරීමේදී දෝෂයක් ඇතිවිය.කරුණාකර නැවත උත්සාහ කරන්න', + /* |---------------------------------------------------------------| */ + /* | User Verification | */ + /* |---------------------------------------------------------------| */ + + 'user.verification.status.verified': 'සත්‍යාපනය කර ඇත', + 'user.verification.status.unverified': 'සත්‍යාපනය කර නැත', + 'user.verification.status.verified_at': '{date} දින සත්‍යාපනය කරන ලදී', + 'user.verification.buttons.verify.text': 'සත්‍යාපනය කරන්න', + 'user.verification.buttons.cancel.text': 'අවලංගු කරන්න', + 'user.verification.buttons.close.text': 'වසන්න', + 'user.verification.buttons.done.text': 'නිම කළා', + 'user.verification.heading': '{attribute} සත්‍යාපනය කරන්න', + 'user.verification.subheading': '{destination} වෙත යැවූ කේතය ඇතුළත් කරන්න.', + 'user.verification.subheading.generic': 'ඔබට යැවූ කේතය ඇතුළත් කරන්න.', + 'user.verification.subheading.link': + '{destination} වෙත සත්‍යාපන සබැඳියක් යැව්වෙමු. එය විවෘත කර, ඉන්පසු නිම කළා තෝරන්න.', + 'user.verification.destination': '{destination} වෙත යවන ලදී.', + 'user.verification.success': '{attribute} සත්‍යාපනය කරන ලදී.', + 'user.verification.error': 'සත්‍යාපනය අසාර්ථක විය. කරුණාකර නැවත උත්සාහ කරන්න.', + /* |---------------------------------------------------------------| */ /* | Change Password | */ /* |---------------------------------------------------------------| */ diff --git a/packages/javascript/src/i18n/translations/ta-IN.ts b/packages/javascript/src/i18n/translations/ta-IN.ts index 47668dd2..5838337f 100644 --- a/packages/javascript/src/i18n/translations/ta-IN.ts +++ b/packages/javascript/src/i18n/translations/ta-IN.ts @@ -124,6 +124,26 @@ const translations: I18nTranslations = { 'user.profile.update.generic.error': 'உங்கள் சுயவிவரத்தை புதுப்பிக்கும் போது பிழை ஏற்பட்டது. மீண்டும் முயற்சிக்கவும்.', + /* |---------------------------------------------------------------| */ + /* | User Verification | */ + /* |---------------------------------------------------------------| */ + + 'user.verification.status.verified': 'சரிபார்க்கப்பட்டது', + 'user.verification.status.unverified': 'சரிபார்க்கப்படவில்லை', + 'user.verification.status.verified_at': '{date} அன்று சரிபார்க்கப்பட்டது', + 'user.verification.buttons.verify.text': 'சரிபார்', + 'user.verification.buttons.cancel.text': 'ரத்து செய்', + 'user.verification.buttons.close.text': 'மூடு', + 'user.verification.buttons.done.text': 'முடிந்தது', + 'user.verification.heading': '{attribute} சரிபார்', + 'user.verification.subheading': '{destination} க்கு அனுப்பப்பட்ட குறியீட்டை உள்ளிடவும்.', + 'user.verification.subheading.generic': 'உங்களுக்கு அனுப்பப்பட்ட குறியீட்டை உள்ளிடவும்.', + 'user.verification.subheading.link': + '{destination} க்கு சரிபார்ப்பு இணைப்பை அனுப்பியுள்ளோம். அதைத் திறந்து, பின்னர் முடிந்தது என்பதைத் தேர்ந்தெடுக்கவும்.', + 'user.verification.destination': '{destination} க்கு அனுப்பப்பட்டது.', + 'user.verification.success': '{attribute} சரிபார்க்கப்பட்டது.', + 'user.verification.error': 'சரிபார்ப்பு தோல்வியடைந்தது. மீண்டும் முயற்சிக்கவும்.', + /* |---------------------------------------------------------------| */ /* | Change Password | */ /* |---------------------------------------------------------------| */ diff --git a/packages/javascript/src/i18n/translations/te-IN.ts b/packages/javascript/src/i18n/translations/te-IN.ts index be91ba96..d0dc352e 100644 --- a/packages/javascript/src/i18n/translations/te-IN.ts +++ b/packages/javascript/src/i18n/translations/te-IN.ts @@ -124,6 +124,26 @@ const translations: I18nTranslations = { 'user.profile.heading': 'ప్రొఫైల్', 'user.profile.update.generic.error': 'ప్రొఫైల్ అప్‌డేట్ చేస్తూ లోపం వచ్చింది. దయచేసి మళ్లీ ప్రయత్నించండి.', + /* |---------------------------------------------------------------| */ + /* | User Verification | */ + /* |---------------------------------------------------------------| */ + + 'user.verification.status.verified': 'ధృవీకరించబడింది', + 'user.verification.status.unverified': 'ధృవీకరించబడలేదు', + 'user.verification.status.verified_at': '{date}న ధృవీకరించబడింది', + 'user.verification.buttons.verify.text': 'ధృవీకరించండి', + 'user.verification.buttons.cancel.text': 'రద్దు చేయండి', + 'user.verification.buttons.close.text': 'మూసివేయండి', + 'user.verification.buttons.done.text': 'పూర్తయింది', + 'user.verification.heading': '{attribute} ధృవీకరించండి', + 'user.verification.subheading': '{destination}కు పంపిన కోడ్‌ను నమోదు చేయండి.', + 'user.verification.subheading.generic': 'మీకు పంపిన కోడ్‌ను నమోదు చేయండి.', + 'user.verification.subheading.link': + '{destination}కు ధృవీకరణ లింక్‌ను పంపాము. దాన్ని తెరిచి, ఆపై పూర్తయింది ఎంచుకోండి.', + 'user.verification.destination': '{destination}కు పంపబడింది.', + 'user.verification.success': '{attribute} ధృవీకరించబడింది.', + 'user.verification.error': 'ధృవీకరణ విఫలమైంది. దయచేసి మళ్లీ ప్రయత్నించండి.', + /* |---------------------------------------------------------------| */ /* | Change Password | */ /* |---------------------------------------------------------------| */ diff --git a/packages/javascript/src/index.ts b/packages/javascript/src/index.ts index 5911def7..8e6174c2 100644 --- a/packages/javascript/src/index.ts +++ b/packages/javascript/src/index.ts @@ -16,6 +16,8 @@ export {default as executeEmbeddedSignUpFlow} from './api/auth/executeEmbeddedSi export {default as executeEmbeddedRecoveryFlow} from './api/auth/executeEmbeddedRecoveryFlow'; export {default as executeEmbeddedUserOnboardingFlow} from './api/auth/executeEmbeddedUserOnboardingFlow'; export type {EmbeddedUserOnboardingFlowResponse} from './api/auth/executeEmbeddedUserOnboardingFlow'; +export {default as executeEmbeddedSelfFlow} from './api/auth/executeEmbeddedSelfFlow'; +export type {ExecuteEmbeddedSelfFlowConfig} from './api/auth/executeEmbeddedSelfFlow'; export {default as getFlowMeta} from './api/auth/getFlowMeta'; export {default as getOrganizationUnitChildren} from './api/management/organization-units/getOrganizationUnitChildren'; export type {GetOrganizationUnitChildrenConfig} from './api/management/organization-units/getOrganizationUnitChildren'; @@ -427,6 +429,7 @@ export {default as UserSelectConstants} from './constants/UserSelectConstants'; export {default as AuthFlowSelectConstants} from './constants/AuthFlowSelectConstants'; export {default as UserTypeSelectConstants} from './constants/UserTypeSelectConstants'; export {default as SelectConstants} from './constants/SelectConstants'; +export {default as AttributeVerificationConstants} from './constants/AttributeVerificationConstants'; export {default as ThunderIDError} from './errors/ThunderIDError'; export {default as ThunderIDAPIError} from './errors/ThunderIDAPIError'; @@ -508,6 +511,8 @@ export type { EmbeddedRecoveryFlowRequest, EmbeddedRecoveryFlowErrorResponse, } from './models/embedded-recovery-flow'; +export {EmbeddedSelfFlowStatus} from './models/embedded-self-flow'; +export type {EmbeddedSelfFlowResponse, EmbeddedSelfFlowRequest} from './models/embedded-self-flow'; export {FlowMetaType} from './models/flow-meta'; export type { ApplicationMetadata, @@ -852,7 +857,7 @@ export type { export type {OIDCEndpoints} from './models/oidc-endpoints'; export type {OIDCDiscoveryApiResponse} from './models/oidc-discovery'; export type {Storage, TemporaryStore} from './models/store'; -export type {User, UserProfile} from './models/user'; +export type {User, UserProfile, VerifiedAttribute} from './models/user'; export type {SessionData} from './models/session'; export type {BackendBypass, BackendEndpoints, BackendSession, ResolvedBackendEndpoints} from './models/backend'; export type {TranslationFn} from './models/translation'; @@ -894,6 +899,10 @@ export {default as isRecognizedBaseUrlPattern} from './utils/isRecognizedBaseUrl export {default as extractPkceStorageKeyFromState} from './utils/extractPkceStorageKeyFromState'; export {default as getLatestStateParam} from './utils/getLatestStateParam'; export {default as generateFlattenedUserProfile} from './utils/generateFlattenedUserProfile'; +export {default as getFlowStepComponents} from './utils/getFlowStepComponents'; +export type {FlowStepComponents} from './utils/getFlowStepComponents'; +export {default as getVerifiedAttributes} from './utils/getVerifiedAttributes'; +export {default as isVerifiableAttribute} from './utils/isVerifiableAttribute'; export {default as getRedirectBasedSignUpUrl} from './utils/getRedirectBasedSignUpUrl'; export {default as getConsentModifiedKey, CONSENT_MODIFIED_KEY_PREFIX} from './utils/getConsentModifiedKey'; export {default as getConsentModifiedValues} from './utils/getConsentModifiedValues'; @@ -931,6 +940,8 @@ export {default as loadUntilOptionsGrow} from './utils/loadUntilOptionsGrow'; export {default as capPageSize} from './utils/capPageSize'; export {default as resolvePageSize} from './utils/resolvePageSize'; export {default as parseSelectPageQuery} from './utils/parseSelectPageQuery'; +export {default as parseSelfFlowRequest} from './utils/parseSelfFlowRequest'; +export type {SelfFlowRequestParseResult} from './utils/parseSelfFlowRequest'; export {default as getSelectMessages} from './utils/getSelectMessages'; export {default as toError} from './utils/toError'; export type {OptionsBatchIssue, OptionsMergeResult, OptionsMergeState} from './utils/mergeOptionsBatch'; diff --git a/packages/javascript/src/models/embedded-flow.ts b/packages/javascript/src/models/embedded-flow.ts index 55756449..67d034e5 100644 --- a/packages/javascript/src/models/embedded-flow.ts +++ b/packages/javascript/src/models/embedded-flow.ts @@ -6,6 +6,7 @@ export enum EmbeddedFlowType { DeviceGrant = 'DEVICE_GRANT', Recovery = 'RECOVERY', Registration = 'REGISTRATION', + Self = 'SELF', UserOnboarding = 'USER_ONBOARDING', } @@ -129,6 +130,9 @@ export enum EmbeddedFlowComponentType { /** Phone number input field with country code support */ PhoneInput = 'PHONE_INPUT', + /** Resend action that asks the server to send a new code, without submitting the step's inputs */ + Resend = 'RESEND', + /** Rich text display component that renders formatted HTML content */ RichText = 'RICH_TEXT', diff --git a/packages/javascript/src/models/embedded-self-flow.ts b/packages/javascript/src/models/embedded-self-flow.ts new file mode 100644 index 00000000..916af8ae --- /dev/null +++ b/packages/javascript/src/models/embedded-self-flow.ts @@ -0,0 +1,91 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import { + EmbeddedFlowComponent, + EmbeddedFlowResponseData, + EmbeddedFlowResponseType, + EmbeddedFlowType, + FlowExecutionError, +} from './embedded-flow'; + +/** + * Status of a `SELF` flow step. + * + * @experimental Part of the new ThunderID API + */ +export enum EmbeddedSelfFlowStatus { + /** + * The flow completed, for example a verification was recorded. + */ + Complete = 'COMPLETE', + + /** + * The server refused the operation. + */ + Error = 'ERROR', + + /** + * The flow needs more input, such as the emailed code. + */ + Incomplete = 'INCOMPLETE', +} + +/** + * Response of a `SELF` flow. + * + * @experimental Part of the new ThunderID API + */ +export interface EmbeddedSelfFlowResponse { + /** + * Per-step challenge token, sent back on the next request when present. + */ + challengeToken?: string; + + /** + * Step data. In self attribute verification, `additionalData.maskedDestination` names where the code was sent. + */ + data?: EmbeddedFlowResponseData & {components?: EmbeddedFlowComponent[]}; + + /** + * Structured error details when `flowStatus` is `ERROR`, or when a code was rejected. + */ + error?: FlowExecutionError; + + /** + * Identifier of this flow execution. + */ + executionId: string; + + /** + * Current status of the flow. + */ + flowStatus: EmbeddedSelfFlowStatus; + + /** + * Type of the step. + */ + type?: EmbeddedFlowResponseType; +} + +/** + * Request that starts or continues a `SELF` flow. + * + * @experimental Part of the new ThunderID API + */ +export interface EmbeddedSelfFlowRequest { + action?: string; + challengeToken?: string; + executionId?: string; + flowType?: EmbeddedFlowType.Self; + /** + * Handle of the `SELF` flow to start, such as `default-self-attribute-verification-flow`. + * Required on the request that starts a flow. + */ + handle?: string; + /** + * Inputs of the step, such as `{attribute: 'email'}` on the request that starts self attribute + * verification. + */ + inputs?: Record; +} diff --git a/packages/javascript/src/models/extensions/components.ts b/packages/javascript/src/models/extensions/components.ts index 82f79c0e..26ae5173 100644 --- a/packages/javascript/src/models/extensions/components.ts +++ b/packages/javascript/src/models/extensions/components.ts @@ -10,7 +10,7 @@ import {FlowMetadataResponse} from '../flow-meta'; */ export interface ComponentRenderContext { additionalData?: Record; - authType: 'signin' | 'signup' | 'recovery'; + authType: 'signin' | 'signup' | 'recovery' | 'verification'; formErrors: Record; formValues: Record; isFormValid: boolean; diff --git a/packages/javascript/src/models/user.ts b/packages/javascript/src/models/user.ts index 1aaccecc..f81cea68 100644 --- a/packages/javascript/src/models/user.ts +++ b/packages/javascript/src/models/user.ts @@ -17,3 +17,13 @@ export interface UserProfile { flattenedProfile: User; profile: User; } + +/** + * Verification record of one of the user's attributes, as returned in `/users/me`. + */ +export interface VerifiedAttribute { + /** + * When the attribute's current value was verified, as an RFC 3339 timestamp. + */ + verifiedAt: string; +} diff --git a/packages/javascript/src/utils/__tests__/getFlowStepComponents.test.ts b/packages/javascript/src/utils/__tests__/getFlowStepComponents.test.ts new file mode 100644 index 00000000..1e4eb4f4 --- /dev/null +++ b/packages/javascript/src/utils/__tests__/getFlowStepComponents.test.ts @@ -0,0 +1,33 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {describe, expect, it} from 'vitest'; +import {EmbeddedFlowComponent, EmbeddedFlowComponentType} from '../../models/embedded-flow'; +import getFlowStepComponents from '../getFlowStepComponents'; + +const component = (id: string, type: EmbeddedFlowComponentType, components?: EmbeddedFlowComponent[]) => + ({components, id, type}) as EmbeddedFlowComponent; + +describe('getFlowStepComponents', () => { + it('collects nested inputs, actions and the resend action', () => { + const otp: EmbeddedFlowComponent = component('otp', EmbeddedFlowComponentType.OtpInput); + const submit: EmbeddedFlowComponent = component('submit', EmbeddedFlowComponentType.Action); + const resend: EmbeddedFlowComponent = component('resend', EmbeddedFlowComponentType.Resend); + + const step = getFlowStepComponents([ + component('heading', EmbeddedFlowComponentType.Text), + component('block', EmbeddedFlowComponentType.Block, [otp, submit, resend]), + ]); + + expect(step.inputs).toEqual([otp]); + expect(step.actions).toEqual([submit, resend]); + expect(step.resend).toBe(resend); + }); + + it('finds nothing in a step that only displays text', () => { + expect(getFlowStepComponents([component('heading', EmbeddedFlowComponentType.Text)])).toEqual({ + actions: [], + inputs: [], + }); + }); +}); diff --git a/packages/javascript/src/utils/__tests__/getVerifiedAttributes.test.ts b/packages/javascript/src/utils/__tests__/getVerifiedAttributes.test.ts new file mode 100644 index 00000000..2ae9c151 --- /dev/null +++ b/packages/javascript/src/utils/__tests__/getVerifiedAttributes.test.ts @@ -0,0 +1,37 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {describe, expect, it} from 'vitest'; +import {User} from '../../models/user'; +import getVerifiedAttributes from '../getVerifiedAttributes'; + +describe('getVerifiedAttributes', (): void => { + it('returns the verified attributes of the profile', (): void => { + const verifiedAttributes = {email: {verifiedAt: '2026-10-16T08:30:00Z'}}; + + expect(getVerifiedAttributes({email: 'alex@example.com', verifiedAttributes} as User)).toEqual(verifiedAttributes); + }); + + it('returns an empty object when the profile has none', (): void => { + expect(getVerifiedAttributes({verifiedAttributes: {}} as User)).toEqual({}); + }); + + it('returns undefined when the profile does not carry the field', (): void => { + expect(getVerifiedAttributes({email: 'alex@example.com'} as User)).toBeUndefined(); + }); + + it('returns undefined when there is no profile', (): void => { + expect(getVerifiedAttributes(undefined)).toBeUndefined(); + expect(getVerifiedAttributes(null)).toBeUndefined(); + }); + + it('treats a field that is not an object as unknown', (): void => { + expect(getVerifiedAttributes({verifiedAttributes: 'email'} as unknown as User)).toBeUndefined(); + expect(getVerifiedAttributes({verifiedAttributes: true} as unknown as User)).toBeUndefined(); + expect(getVerifiedAttributes({verifiedAttributes: null} as unknown as User)).toBeUndefined(); + }); + + it('treats a field that is an array as unknown', (): void => { + expect(getVerifiedAttributes({verifiedAttributes: ['email']} as unknown as User)).toBeUndefined(); + }); +}); diff --git a/packages/javascript/src/utils/__tests__/isVerifiableAttribute.test.ts b/packages/javascript/src/utils/__tests__/isVerifiableAttribute.test.ts new file mode 100644 index 00000000..c6ff8a82 --- /dev/null +++ b/packages/javascript/src/utils/__tests__/isVerifiableAttribute.test.ts @@ -0,0 +1,41 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {describe, expect, it} from 'vitest'; +import {AttributeSchema} from '../../api/account/getUsersMeMeta'; +import isVerifiableAttribute from '../isVerifiableAttribute'; + +const userSchema: Record = { + address: {subAttributes: [{type: 'email'}], type: 'object'}, + emails: {type: 'array'}, + given_name: {type: 'string'}, + mobile: {type: 'mobile'}, + workEmail: {type: 'EMAIL'}, +}; + +describe('isVerifiableAttribute', (): void => { + it('accepts an email or mobile attribute', (): void => { + expect(isVerifiableAttribute(userSchema, 'workEmail')).toBe(true); + expect(isVerifiableAttribute(userSchema, 'mobile')).toBe(true); + }); + + it('refuses a string attribute such as a first name', (): void => { + expect(isVerifiableAttribute(userSchema, 'given_name')).toBe(false); + }); + + it('refuses a multi-valued or nested attribute', (): void => { + expect(isVerifiableAttribute(userSchema, 'emails')).toBe(false); + expect(isVerifiableAttribute(userSchema, 'address')).toBe(false); + expect(isVerifiableAttribute(userSchema, 'address.email')).toBe(false); + }); + + it('refuses an attribute the schema does not declare', (): void => { + expect(isVerifiableAttribute(userSchema, 'email')).toBe(false); + expect(isVerifiableAttribute(userSchema, 'toString')).toBe(false); + }); + + it('refuses every attribute when the schema is unknown', (): void => { + expect(isVerifiableAttribute(null, 'email')).toBe(false); + expect(isVerifiableAttribute(undefined, 'email')).toBe(false); + }); +}); diff --git a/packages/javascript/src/utils/__tests__/parseSelfFlowRequest.test.ts b/packages/javascript/src/utils/__tests__/parseSelfFlowRequest.test.ts new file mode 100644 index 00000000..f68f56bd --- /dev/null +++ b/packages/javascript/src/utils/__tests__/parseSelfFlowRequest.test.ts @@ -0,0 +1,71 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {describe, expect, it} from 'vitest'; +import parseSelfFlowRequest from '../parseSelfFlowRequest'; + +describe('parseSelfFlowRequest', () => { + it('keeps a request that starts a `SELF` flow by handle', () => { + expect( + parseSelfFlowRequest({ + flowType: 'SELF', + handle: 'default-self-attribute-verification-flow', + inputs: {attribute: 'email'}, + }), + ).toEqual({ + request: { + flowType: 'SELF', + handle: 'default-self-attribute-verification-flow', + inputs: {attribute: 'email'}, + }, + }); + }); + + it('keeps a continuation step', () => { + expect( + parseSelfFlowRequest({ + action: 'action_email_verify', + challengeToken: 'ct-1', + executionId: 'exec-1', + inputs: {otp: '123456'}, + }), + ).toEqual({ + request: {action: 'action_email_verify', challengeToken: 'ct-1', executionId: 'exec-1', inputs: {otp: '123456'}}, + }); + }); + + it('drops fields a step does not have, such as a user or application identifier', () => { + expect( + parseSelfFlowRequest({ + applicationId: 'app-1', + flowType: 'SELF', + handle: 'h', + sessionId: 'someone-elses-session', + userId: 'someone-else', + verbose: false, + }), + ).toEqual({request: {flowType: 'SELF', handle: 'h'}}); + + expect(parseSelfFlowRequest({executionId: 'exec-1', flowType: 'AUTHENTICATION', userId: 'u'})).toEqual({ + request: {executionId: 'exec-1'}, + }); + }); + + it.each([ + ['a flow of another type', {flowType: 'AUTHENTICATION', handle: 'h'}, 'flowType'], + ['no flow type', {handle: 'h'}, 'flowType'], + ['no handle', {flowType: 'SELF'}, 'handle'], + ['an empty handle', {flowType: 'SELF', handle: ''}, 'handle'], + ['a handle that is not a string', {flowType: 'SELF', handle: ['h']}, 'handle'], + ['an empty execution ID', {executionId: ''}, 'executionId'], + ['an execution ID that is not a string', {executionId: 1}, 'executionId'], + ['an action that is not a string', {action: {}, executionId: 'e'}, 'action'], + ['a challenge token that is not a string', {challengeToken: 1, executionId: 'e'}, 'challengeToken'], + ['inputs that are not strings', {executionId: 'e', inputs: {otp: 123456}}, 'inputs'], + ['inputs that are a list', {executionId: 'e', inputs: ['123456']}, 'inputs'], + ['a body that is not an object', 'executionId=e', 'body'], + ['no body', null, 'body'], + ])('refuses %s', (_label: string, raw: unknown, invalid: string) => { + expect(parseSelfFlowRequest(raw)).toEqual({invalid}); + }); +}); diff --git a/packages/javascript/src/utils/getFlowStepComponents.ts b/packages/javascript/src/utils/getFlowStepComponents.ts new file mode 100644 index 00000000..93fe4072 --- /dev/null +++ b/packages/javascript/src/utils/getFlowStepComponents.ts @@ -0,0 +1,56 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {EmbeddedFlowComponent, EmbeddedFlowComponentType} from '../models/embedded-flow'; + +// Component types that collect a value. +const INPUT_TYPES: string[] = [ + EmbeddedFlowComponentType.TextInput, + EmbeddedFlowComponentType.PasswordInput, + EmbeddedFlowComponentType.EmailInput, + EmbeddedFlowComponentType.PhoneInput, + EmbeddedFlowComponentType.OtpInput, + EmbeddedFlowComponentType.Select, + EmbeddedFlowComponentType.DateInput, +]; + +// Component types that submit a step. +const ACTION_TYPES: string[] = [EmbeddedFlowComponentType.Action, EmbeddedFlowComponentType.Resend]; + +/** + * The components of a flow step that collect a value or submit the step. + */ +export interface FlowStepComponents { + /** Components that submit the step, the resend action included. */ + actions: EmbeddedFlowComponent[]; + /** Components that collect a value. */ + inputs: EmbeddedFlowComponent[]; + /** The step's resend action, if it has one. */ + resend?: EmbeddedFlowComponent; +} + +/** + * Collects the inputs and actions of a flow step, nested components included, in document order. + * + * @param components - The step's components. + * @returns The step's inputs, actions and resend action. + * @example + * ```typescript + * const {actions, inputs} = getFlowStepComponents(response.data.components); + * const isAwaitingLink = inputs.length === 0 && actions.length === 0; + * ``` + */ +const getFlowStepComponents = ( + components: EmbeddedFlowComponent[], + step: FlowStepComponents = {actions: [], inputs: []}, +): FlowStepComponents => { + components.forEach((component: EmbeddedFlowComponent) => { + if (INPUT_TYPES.includes(component.type)) step.inputs.push(component); + if (ACTION_TYPES.includes(component.type)) step.actions.push(component); + if (component.type === EmbeddedFlowComponentType.Resend && !step.resend) step.resend = component; + if (Array.isArray(component.components)) getFlowStepComponents(component.components, step); + }); + return step; +}; + +export default getFlowStepComponents; diff --git a/packages/javascript/src/utils/getVerifiedAttributes.ts b/packages/javascript/src/utils/getVerifiedAttributes.ts new file mode 100644 index 00000000..9e3502e4 --- /dev/null +++ b/packages/javascript/src/utils/getVerifiedAttributes.ts @@ -0,0 +1,25 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import AttributeVerificationConstants from '../constants/AttributeVerificationConstants'; +import {User, VerifiedAttribute} from '../models/user'; + +/** + * Returns the verified attributes of a `/users/me` profile, keyed by attribute name. + * + * A profile without the field, from a server that does not report verification, leaves every status + * unknown, so the result is `undefined` rather than an empty record that would read as unverified. + * + * @param user - The profile to read. + * @returns The verified attributes, an empty object when none is verified, or `undefined` when the + * profile does not report them. + */ +const getVerifiedAttributes = (user?: User | null): Record | undefined => { + const value: unknown = user?.[AttributeVerificationConstants.VERIFIED_ATTRIBUTES_KEY]; + + return value && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : undefined; +}; + +export default getVerifiedAttributes; diff --git a/packages/javascript/src/utils/isVerifiableAttribute.ts b/packages/javascript/src/utils/isVerifiableAttribute.ts new file mode 100644 index 00000000..e2cf560e --- /dev/null +++ b/packages/javascript/src/utils/isVerifiableAttribute.ts @@ -0,0 +1,40 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {AttributeSchema} from '../api/account/getUsersMeMeta'; +import AttributeVerificationConstants from '../constants/AttributeVerificationConstants'; + +/** + * Whether the signed-in user can verify the given attribute: a top-level, single-valued property of + * type `email` or `mobile` in the user type schema from `GET /users/me/meta`. + * + * Schema keys are top-level properties, and a multi-valued property has type `array`, so both are + * settled by the property's own type. An absent schema, or one without the attribute, leaves the + * type unknown and resolves to `false`, so no verification is started on missing information. + * + * @param userSchema - The user type schema resolved by the provider, keyed by attribute. + * @param attribute - The attribute to check (e.g. `email`). + * @returns `true` only when the schema declares `attribute` with a verifiable type. + * @example + * ```typescript + * if (!isVerifiableAttribute(userSchema, 'given_name')) { + * return null; + * } + * ``` + */ +const isVerifiableAttribute = ( + userSchema: Record | null | undefined, + attribute: string, +): boolean => { + if (!userSchema || !attribute || !Object.prototype.hasOwnProperty.call(userSchema, attribute)) { + return false; + } + + const type: string | undefined = userSchema[attribute]?.type; + + return ( + typeof type === 'string' && AttributeVerificationConstants.VERIFIABLE_ATTRIBUTE_TYPES.includes(type.toLowerCase()) + ); +}; + +export default isVerifiableAttribute; diff --git a/packages/javascript/src/utils/parseSelfFlowRequest.ts b/packages/javascript/src/utils/parseSelfFlowRequest.ts new file mode 100644 index 00000000..0f7b4a70 --- /dev/null +++ b/packages/javascript/src/utils/parseSelfFlowRequest.ts @@ -0,0 +1,64 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {EmbeddedFlowType} from '../models/embedded-flow'; +import {EmbeddedSelfFlowRequest} from '../models/embedded-self-flow'; + +/** + * Result of {@link parseSelfFlowRequest}: the request to send, or the field that made it invalid. + */ +export type SelfFlowRequestParseResult = {invalid: string} | {request: EmbeddedSelfFlowRequest}; + +const isNonEmptyString = (value: unknown): value is string => typeof value === 'string' && value !== ''; + +const isStringRecord = (value: unknown): value is Record => + !!value && + typeof value === 'object' && + !Array.isArray(value) && + Object.values(value as Record).every((entry: unknown) => typeof entry === 'string'); + +/** + * Checks a `SELF` flow step that a browser sent to a server-side SDK, which attaches the + * session's access token before forwarding it. Such an endpoint is public, so its types protect + * nothing. + * + * Only the fields of a step are kept, so a caller cannot add a user or application identifier. A + * request without an `executionId` starts a flow, and must name a `SELF` flow by its handle, + * so the endpoint cannot start a flow of another type with the user's token. + * + * @param raw - The request body as received. + * @returns The request to send, or the name of the first invalid field. + */ +const parseSelfFlowRequest = (raw: unknown): SelfFlowRequestParseResult => { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { + return {invalid: 'body'}; + } + + const {action, challengeToken, executionId, flowType, handle, inputs} = raw as Record; + + if (inputs !== undefined && !isStringRecord(inputs)) { + return {invalid: 'inputs'}; + } + + if (executionId !== undefined) { + if (!isNonEmptyString(executionId)) return {invalid: 'executionId'}; + if (action !== undefined && typeof action !== 'string') return {invalid: 'action'}; + if (challengeToken !== undefined && typeof challengeToken !== 'string') return {invalid: 'challengeToken'}; + + return { + request: { + executionId, + ...(action ? {action} : {}), + ...(challengeToken ? {challengeToken} : {}), + ...(inputs ? {inputs} : {}), + }, + }; + } + + if (flowType !== EmbeddedFlowType.Self) return {invalid: 'flowType'}; + if (!isNonEmptyString(handle)) return {invalid: 'handle'}; + + return {request: {flowType: EmbeddedFlowType.Self, handle, ...(inputs ? {inputs} : {})}}; +}; + +export default parseSelfFlowRequest; diff --git a/packages/nextjs/src/ThunderIDNextClient.ts b/packages/nextjs/src/ThunderIDNextClient.ts index 0518f381..a784fc9e 100644 --- a/packages/nextjs/src/ThunderIDNextClient.ts +++ b/packages/nextjs/src/ThunderIDNextClient.ts @@ -6,6 +6,8 @@ import { ThunderIDRuntimeError, AttributeSchema, AuthClientConfig, + EmbeddedSelfFlowRequest, + EmbeddedSelfFlowResponse, EmbeddedSignInFlowResponse, ExtendedAuthorizeRequestUrlParams, IdToken, @@ -237,6 +239,21 @@ class ThunderIDNextClient e }); } + /** + * Executes one step of a `SELF` flow, such as self attribute verification, for the session + * identified by `userId`. + * + * Ensures the client is initialized first, like `getUsers`. Errors are not caught so the calling server action can map the real `ThunderIDAPIError`. + */ + override async executeEmbeddedSelfFlow( + payload: EmbeddedSelfFlowRequest, + userId?: string, + ): Promise { + await this.ensureInitialized(); + + return super.executeEmbeddedSelfFlow(payload, userId); + } + /** * Retrieves one page of the user directory for the session identified by `userId`. * diff --git a/packages/nextjs/src/client/components/account/UserProfile/UserProfile.tsx b/packages/nextjs/src/client/components/account/UserProfile/UserProfile.tsx index e7ad8543..5d6c0a33 100644 --- a/packages/nextjs/src/client/components/account/UserProfile/UserProfile.tsx +++ b/packages/nextjs/src/client/components/account/UserProfile/UserProfile.tsx @@ -3,17 +3,30 @@ 'use client'; -import {deepMerge, User} from '@thunderid/node'; -import {BaseUserProfile, BaseUserProfileProps, useUser, useTranslation} from '@thunderid/react'; -import {FC, ReactElement, useState} from 'react'; +import {AttributeVerificationConstants, deepMerge, User} from '@thunderid/node'; +import { + BaseUserProfile, + BaseUserProfileProps, + BaseUserProfileVerificationRenderProps, + useUser, + useTranslation, +} from '@thunderid/react'; +import {FC, ReactElement, ReactNode, useState} from 'react'; import useThunderID from '../../../contexts/ThunderID/useThunderID'; import getSessionId from '../../../../server/actions/getSessionId'; +import VerifyAttribute from '../VerifyAttribute/VerifyAttribute'; /** * Props for the UserProfile component. * Extends BaseUserProfileProps but makes the user prop optional since it will be obtained from useThunderID */ -export type UserProfileProps = Omit; +export type UserProfileProps = Omit & { + /** + * Handle of the `SELF` flow that verifies an attribute. Defaults to the server's shipped + * `default-self-attribute-verification-flow`. + */ + verificationFlowHandle?: string; +}; /** * UserProfile component displays the authenticated user's profile information in a @@ -38,7 +51,12 @@ export type UserProfileProps = Omit * ``` */ -const UserProfile: FC = ({preferences, editable = true, ...rest}: UserProfileProps): ReactElement => { +const UserProfile: FC = ({ + preferences, + editable = true, + verificationFlowHandle = AttributeVerificationConstants.DEFAULT_FLOW_HANDLE, + ...rest +}: UserProfileProps): ReactElement => { const {preferences: contextPreferences} = useThunderID(); const {profile, flattenedProfile, onUpdateProfile, updateProfile, userSchema} = useUser(); const resolvedPreferences = { @@ -77,8 +95,25 @@ const UserProfile: FC = ({preferences, editable = true, ...res } }; + const renderVerification = ({ + attribute, + attributeDisplayName, + onCancel, + }: BaseUserProfileVerificationRenderProps): ReactNode => ( + + ); + return ( & { + /** + * Name of the signed-in user's attribute to verify, such as `email`. Only a top-level, single-valued + * `email` or `mobile` property of the user's schema can be verified; any other is refused without a + * request, through `onError`, with `VerifyAttribute-handleInitialize-RuntimeError-001`. + */ + attribute: string; + /** + * Handle of the `SELF` flow to run. Defaults to the server's shipped + * `default-self-attribute-verification-flow`. + */ + flowHandle?: string; +}; + +// Runs one step through the server action, which attaches the session's access token. A failure +// raises `VerifyAttribute-execute-ResponseError-001` with the refusal's status, never the server's reply. +const execute = async (payload: EmbeddedSelfFlowRequest): Promise => { + const result: ExecuteSelfFlowActionResult = await executeSelfFlowAction(payload); + + if (!result.success) { + throw new ThunderIDAPIError( + 'SELF flow request failed', + 'VerifyAttribute-execute-ResponseError-001', + 'nextjs', + result.statusCode, + ); + } + + return result.data; +}; + +/** + * VerifyAttribute verifies one of the signed-in user's attributes, such as their email address, by + * running a self attribute verification flow named by `flowHandle`. + * + * This is the Next.js-specific implementation: it renders `BaseVerifyAttribute` from + * `@thunderid/react`, but runs every step through `executeSelfFlowAction` (a server action that + * attaches the server-held session's access token) instead of calling the ThunderID server from the + * browser. Once the server records the verification, and when the user selects Done after a link is + * sent, it re-reads the user's profile. + * + * Mount it with a `key` per attribute: each mount starts a new verification and sends a new code or + * link. + * + * @example + * ```tsx + * setOpen(false)} onCancel={() => setOpen(false)} /> + * ``` + */ +const VerifyAttribute: FC = ({ + attribute, + attributeDisplayName, + flowHandle = AttributeVerificationConstants.DEFAULT_FLOW_HANDLE, + onComplete, + onDone, + ...rest +}: VerifyAttributeProps): ReactElement => { + const {isLoading} = useThunderID(); + const {revalidateProfile, userSchema} = useUser(); + + // Re-reads the profile so the recorded verification shows; a failure leaves it stale, not broken. + const refreshProfile = useCallback(async (): Promise => { + try { + await revalidateProfile?.(); + } catch { + // Non-fatal: the profile stays stale until the next re-read. + } + }, [revalidateProfile]); + + const handleInitialize = useCallback((): Promise => { + if (!isVerifiableAttribute(userSchema, attribute)) { + return Promise.reject( + new ThunderIDRuntimeError( + `Attribute "${attribute}" is not a verifiable email or mobile attribute.`, + 'VerifyAttribute-handleInitialize-RuntimeError-001', + 'nextjs', + 'Only a top-level, single-valued email or mobile attribute of the user schema can be verified.', + ), + ); + } + + return execute({ + flowType: EmbeddedFlowType.Self, + handle: flowHandle, + inputs: {[AttributeVerificationConstants.ATTRIBUTE_INPUT]: attribute}, + }); + }, [attribute, flowHandle, userSchema]); + + return ( + { + void refreshProfile(); + onComplete?.(response); + }} + onDone={(): void => { + void refreshProfile(); + onDone?.(); + }} + /> + ); +}; + +export default VerifyAttribute; diff --git a/packages/nextjs/src/client/components/account/VerifyAttribute/__tests__/VerifyAttribute.test.ts b/packages/nextjs/src/client/components/account/VerifyAttribute/__tests__/VerifyAttribute.test.ts new file mode 100644 index 00000000..44b5cdf2 --- /dev/null +++ b/packages/nextjs/src/client/components/account/VerifyAttribute/__tests__/VerifyAttribute.test.ts @@ -0,0 +1,65 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ReactElement} from 'react'; +import {describe, expect, it, Mock, vi} from 'vitest'; + +const BaseVerifyAttribute: () => null = () => null; +const revalidateProfile: Mock = vi.fn().mockResolvedValue(undefined); + +vi.mock('react', async (importOriginal: () => Promise>) => ({ + ...(await importOriginal()), + // Lets the component run outside a renderer; it only memoises callbacks. + useCallback: (callback: T): T => callback, +})); + +vi.mock('@thunderid/react', () => ({ + BaseVerifyAttribute, + useUser: () => ({revalidateProfile, userSchema: {email: {type: 'email'}}}), +})); + +vi.mock('../../../../contexts/ThunderID/useThunderID', () => ({ + default: () => ({isLoading: false}), +})); + +const executeSelfFlowAction: Mock = vi.fn(); + +vi.mock('../../../../../server/actions/auth/executeSelfFlowAction', () => ({ + default: (...args: unknown[]): unknown => executeSelfFlowAction(...args) as unknown, +})); + +const {default: VerifyAttribute} = await import('../VerifyAttribute'); + +describe('VerifyAttribute', () => { + it('refuses an attribute the schema does not mark verifiable without calling the server', async () => { + const element: ReactElement Promise>> = VerifyAttribute({ + attribute: 'given_name', + }) as ReactElement Promise>>; + + await expect(element.props['onInitialize']()).rejects.toMatchObject({ + code: 'VerifyAttribute-handleInitialize-RuntimeError-001', + }); + expect(executeSelfFlowAction).not.toHaveBeenCalled(); + }); +}); + +describe('VerifyAttribute custom render', () => { + it('hands its children render function to BaseVerifyAttribute and keeps the profile re-read on completion', async () => { + const custom: Mock = vi.fn(); + const onComplete: Mock = vi.fn(); + const element: ReactElement unknown>> = VerifyAttribute({ + attribute: 'email', + children: custom, + onComplete, + }) as ReactElement unknown>>; + + expect(element.type).toBe(BaseVerifyAttribute); + expect(element.props['children']).toBe(custom); + + element.props['onComplete']({executionId: 'exec-1', flowStatus: 'COMPLETE'}); + await Promise.resolve(); + + expect(revalidateProfile).toHaveBeenCalledTimes(1); + expect(onComplete).toHaveBeenCalledWith({executionId: 'exec-1', flowStatus: 'COMPLETE'}); + }); +}); diff --git a/packages/nextjs/src/client/contexts/ThunderID/ThunderIDProvider.tsx b/packages/nextjs/src/client/contexts/ThunderID/ThunderIDProvider.tsx index 87cd9d19..6fa39b22 100644 --- a/packages/nextjs/src/client/contexts/ThunderID/ThunderIDProvider.tsx +++ b/packages/nextjs/src/client/contexts/ThunderID/ThunderIDProvider.tsx @@ -29,6 +29,8 @@ import {AppRouterInstance} from 'next/dist/shared/lib/app-router-context.shared- import {useRouter, useSearchParams} from 'next/navigation'; import {FC, PropsWithChildren, RefObject, useEffect, useMemo, useRef, useState} from 'react'; import ThunderIDContext, {ThunderIDContextProps} from './ThunderIDContext'; +import getUserProfileAction from '../../../server/actions/account/getUserProfileAction'; +import getSessionId from '../../../server/actions/getSessionId'; import {RefreshResult} from '../../../server/actions/refreshToken'; import logger from '../../../utils/logger'; import fetchFlowMetaFromServer from '../../fetchFlowMetaFromServer'; @@ -313,6 +315,22 @@ const ThunderIDClientProvider: FC => { + if (preferences?.user?.fetchUserProfile === false) return; + + const result: Awaited> = await getUserProfileAction( + (await getSessionId()) ?? '', + ); + + if (result.success) { + handleProfileUpdate(result.data.userProfile.profile); + } + }; + // Bridge into @thunderid/react's own ThunderIDContext. Internal react components rendered by // BaseSignIn/BaseSignUp — most notably FlowMetaProvider, which fetches `/flow/meta` and injects // its i18n bundle — call react's `useThunderID()` directly. Without this bridge they'd only see @@ -410,6 +428,7 @@ const ThunderIDClientProvider: FC {children} diff --git a/packages/nextjs/src/client/index.ts b/packages/nextjs/src/client/index.ts index cef9bb99..ca75758b 100644 --- a/packages/nextjs/src/client/index.ts +++ b/packages/nextjs/src/client/index.ts @@ -42,6 +42,9 @@ export type {UserProfileProps} from './components/account/UserProfile/UserProfil export {default as ChangeCredential} from './components/account/ChangeCredential/ChangeCredential'; export type {ChangeCredentialProps} from './components/account/ChangeCredential/ChangeCredential'; +export {default as VerifyAttribute} from './components/account/VerifyAttribute/VerifyAttribute'; +export type {VerifyAttributeProps} from './components/account/VerifyAttribute/VerifyAttribute'; + export {default as UserSelect} from './components/primitives/UserSelect/UserSelect'; export type {UserSelectProps} from './components/primitives/UserSelect/UserSelect'; diff --git a/packages/nextjs/src/server/actions/auth/__tests__/executeSelfFlowAction.test.ts b/packages/nextjs/src/server/actions/auth/__tests__/executeSelfFlowAction.test.ts new file mode 100644 index 00000000..d0e217b6 --- /dev/null +++ b/packages/nextjs/src/server/actions/auth/__tests__/executeSelfFlowAction.test.ts @@ -0,0 +1,91 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ThunderIDAPIError} from '@thunderid/node'; +import {beforeEach, describe, expect, it, vi} from 'vitest'; +import getClient from '../../../getClient'; +import getSessionId from '../../getSessionId'; +import executeSelfFlowAction from '../executeSelfFlowAction'; + +vi.mock('../../getSessionId', () => ({default: vi.fn()})); +vi.mock('../../../getClient', () => ({default: vi.fn()})); + +const mockExecute = vi.fn(); + +const start = { + flowType: 'SELF', + handle: 'default-self-attribute-verification-flow', + inputs: {attribute: 'email'}, +}; +const step = {challengeToken: 'ct-1', executionId: 'exec-1', flowStatus: 'INCOMPLETE'}; + +describe('executeSelfFlowAction', () => { + beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(getClient).mockReturnValue({ + executeEmbeddedSelfFlow: mockExecute, + } as unknown as ReturnType); + vi.mocked(getSessionId).mockResolvedValue('session-1'); + }); + + it('refuses without a session and never reaches the server', async () => { + vi.mocked(getSessionId).mockResolvedValue(undefined); + + expect(await executeSelfFlowAction(start)).toEqual({statusCode: 401, success: false}); + expect(mockExecute).not.toHaveBeenCalled(); + }); + + it('runs the step with the server-resolved session and returns it', async () => { + mockExecute.mockResolvedValue(step); + + const result = await executeSelfFlowAction(start); + + expect(mockExecute).toHaveBeenCalledWith(start, 'session-1'); + expect(result).toEqual({data: step, success: true}); + }); + + it('does not let the caller choose the session or add a user or application identifier', async () => { + mockExecute.mockResolvedValue(step); + + await executeSelfFlowAction({ + ...start, + applicationId: 'app-1', + sessionId: 'someone-elses-session', + userId: 'someone-else', + }); + + expect(mockExecute).toHaveBeenCalledWith(start, 'session-1'); + }); + + it.each([ + ['a flow of another type', {flowType: 'AUTHENTICATION', handle: 'h'}], + ['a new flow without a handle', {flowType: 'SELF'}], + ['inputs that are not strings', {executionId: 'exec-1', inputs: {otp: 1}}], + ['a body that is not an object', 'flowType=SELF'], + ])('refuses %s without reaching the server', async (_label: string, request: unknown) => { + expect(await executeSelfFlowAction(request)).toEqual({statusCode: 400, success: false}); + expect(mockExecute).not.toHaveBeenCalled(); + }); + + it("carries a refusal's status across the boundary, without the reply of the server", async () => { + mockExecute.mockRejectedValue( + new ThunderIDAPIError( + '{"code":"FES-1017","description":"internal detail"}', + 'x-ResponseError-001', + 'javascript', + 403, + ), + ); + + const result = await executeSelfFlowAction(start); + + expect(result).toEqual({statusCode: 403, success: false}); + expect(JSON.stringify(result)).not.toContain('internal detail'); + }); + + it('does not leak the text of an unexpected error', async () => { + mockExecute.mockRejectedValue(new Error('connect ECONNREFUSED 10.0.0.5:8090')); + + expect(await executeSelfFlowAction(start)).toEqual({success: false}); + }); +}); diff --git a/packages/nextjs/src/server/actions/auth/executeSelfFlowAction.ts b/packages/nextjs/src/server/actions/auth/executeSelfFlowAction.ts new file mode 100644 index 00000000..cfef2ed2 --- /dev/null +++ b/packages/nextjs/src/server/actions/auth/executeSelfFlowAction.ts @@ -0,0 +1,52 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +'use server'; + +import { + EmbeddedSelfFlowResponse, + SelfFlowRequestParseResult, + ThunderIDAPIError, + parseSelfFlowRequest, +} from '@thunderid/node'; +import getClient from '../../getClient'; +import getSessionId from '../getSessionId'; + +export type ExecuteSelfFlowActionResult = + | {data: EmbeddedSelfFlowResponse; success: true} + | {statusCode?: number; success: false}; + +/** + * Executes one step of a `SELF` flow, such as self attribute verification, for the signed-in + * user, with the access token of the server-held session. The request carries no session ID, token + * or user ID: the session comes from the request cookie, and only the fields of a step are forwarded. + * A new flow must be a `SELF` flow named by its handle. + * + * Never throws across the server action boundary. A failure carries only its HTTP status, never the + * reply of the server, which the browser reports as a refusal. + */ +const executeSelfFlowAction = async (request: unknown): Promise => { + const sessionId: string | undefined = await getSessionId(); + + if (!sessionId) { + return {statusCode: 401, success: false}; + } + + const checked: SelfFlowRequestParseResult = parseSelfFlowRequest(request); + + if ('invalid' in checked) { + return {statusCode: 400, success: false}; + } + + try { + const data: EmbeddedSelfFlowResponse = await getClient().executeEmbeddedSelfFlow(checked.request, sessionId); + + return {data, success: true}; + } catch (error) { + return error instanceof ThunderIDAPIError && error.statusCode + ? {statusCode: error.statusCode, success: false} + : {success: false}; + } +}; + +export default executeSelfFlowAction; diff --git a/packages/node/src/ThunderIDNodeClient.ts b/packages/node/src/ThunderIDNodeClient.ts index b8c31cd7..5801b995 100644 --- a/packages/node/src/ThunderIDNodeClient.ts +++ b/packages/node/src/ThunderIDNodeClient.ts @@ -5,6 +5,9 @@ import { AuthClientConfig, BackchannelLogoutConstants, BackchannelLogoutResult, + EmbeddedSelfFlowRequest, + EmbeddedSelfFlowResponse, + executeEmbeddedSelfFlow, ExtendedAuthorizeRequestUrlParams, getFlows, getUsers, @@ -443,6 +446,27 @@ class ThunderIDNodeClient e }); } + /** + * Executes one step of a `SELF` flow, such as self attribute verification, for the signed-in + * user of the session identified by `userId`. The server takes the user from the access token, which + * stays on the server; the caller sends no user identifier. + */ + public async executeEmbeddedSelfFlow( + payload: EmbeddedSelfFlowRequest, + userId?: string, + ): Promise { + const configData: AuthClientConfig = await this.getStorageManager().getConfigData(); + + return executeEmbeddedSelfFlow({ + baseUrl: configData?.baseUrl, + headers: { + Authorization: `Bearer ${await this.getAccessToken(userId)}`, + }, + payload, + url: resolveResourceEndpoint('flowExecute', configData), + }); + } + /** * The URL inputs and bearer token for a management request made on behalf of the session identified by * `userId`. The access token stays on the server. diff --git a/packages/node/src/__tests__/ThunderIDNodeClient.test.ts b/packages/node/src/__tests__/ThunderIDNodeClient.test.ts index 5c24b4ab..b1433332 100644 --- a/packages/node/src/__tests__/ThunderIDNodeClient.test.ts +++ b/packages/node/src/__tests__/ThunderIDNodeClient.test.ts @@ -12,6 +12,7 @@ vi.mock('@thunderid/javascript', async (importOriginal) => { getFlows: vi.fn().mockResolvedValue({count: 1, flows: [{id: 'f1'}], links: [], startIndex: 1, totalResults: 1}), getUsers: vi.fn().mockResolvedValue({count: 1, links: [], startIndex: 0, totalResults: 1, users: [{id: 'u1'}]}), getUserTypes: vi.fn().mockResolvedValue({count: 1, links: [], startIndex: 1, totalResults: 1, types: [{id: 't1'}]}), + executeEmbeddedSelfFlow: vi.fn().mockResolvedValue({executionId: 'exec-1', flowStatus: 'INCOMPLETE'}), updateMeCredentials: vi.fn().mockResolvedValue(undefined), }; }); @@ -41,6 +42,31 @@ describe('ThunderIDNodeClient', () => { }); }); + describe('executeEmbeddedSelfFlow', () => { + it("sends the step with the session's bearer token to the flow endpoint, and returns the step", async () => { + const client = new ThunderIDNodeClient(); + await client.initialize({ + baseUrl: 'https://auth.example.com', + clientId: 'test-client', + endpoints: {flowExecute: 'https://rs.example.com/flow/execute'}, + }); + + const getAccessToken = vi.spyOn(client, 'getAccessToken').mockResolvedValue('test-access-token'); + const payload = {flowType: 'SELF', handle: 'default-self-attribute-verification-flow'} as const; + + const step = await client.executeEmbeddedSelfFlow(payload as never, 'session-1'); + + expect(getAccessToken).toHaveBeenCalledWith('session-1'); + expect(thunderJS.executeEmbeddedSelfFlow).toHaveBeenCalledWith({ + baseUrl: 'https://auth.example.com', + headers: {Authorization: 'Bearer test-access-token'}, + payload, + url: 'https://rs.example.com/flow/execute', + }); + expect(step).toEqual({executionId: 'exec-1', flowStatus: 'INCOMPLETE'}); + }); + }); + describe('getUsers', () => { it('requests one page with the session bearer token and returns the response untouched', async () => { const client = new ThunderIDNodeClient(); diff --git a/packages/nuxt/src/module.ts b/packages/nuxt/src/module.ts index 48f51421..64061da1 100644 --- a/packages/nuxt/src/module.ts +++ b/packages/nuxt/src/module.ts @@ -206,6 +206,11 @@ export default defineNuxtModule({ method: 'patch' as const, route: NuxtAPIRoutes.USER_CREDENTIALS, }, + { + handler: resolve('./runtime/server/routes/auth/user/self-flow.post'), + method: 'post' as const, + route: NuxtAPIRoutes.USER_SELF_FLOW, + }, ]; // Opt-in: the route accepts unauthenticated requests, so it is served only when asked for. @@ -290,6 +295,7 @@ export default defineNuxtModule({ addComponent({filePath: resolve('./runtime/components/account/UserProfile'), name: 'UserProfile'}); addComponent({filePath: resolve('./runtime/components/account/UserDropdown'), name: 'UserDropdown'}); addComponent({filePath: resolve('./runtime/components/account/ChangeCredential'), name: 'ChangeCredential'}); + addComponent({filePath: resolve('./runtime/components/account/VerifyAttribute'), name: 'VerifyAttribute'}); // ── Management pickers ────────────────────────────────────────────────── // Opt-in: each picker's route lists a management resource with the signed-in user's session, so the route and its diff --git a/packages/nuxt/src/runtime/components/account/UserProfile.ts b/packages/nuxt/src/runtime/components/account/UserProfile.ts index 5648fec1..4a23d3b5 100644 --- a/packages/nuxt/src/runtime/components/account/UserProfile.ts +++ b/packages/nuxt/src/runtime/components/account/UserProfile.ts @@ -1,9 +1,10 @@ // Copyright 2025 The ThunderID Authors // SPDX-License-Identifier: Apache-2.0 -import {deepMerge, withVendorCSSClassPrefix} from '@thunderid/browser'; -import {BaseUserProfile} from '@thunderid/vue'; -import {type Component, type PropType, type SetupContext, type VNode, defineComponent, h} from 'vue'; +import {AttributeVerificationConstants, deepMerge, withVendorCSSClassPrefix} from '@thunderid/browser'; +import {BaseUserProfile, type BaseUserProfileVerificationSlotProps, useI18n} from '@thunderid/vue'; +import {type Component, type PropType, type Ref, type SetupContext, type VNode, defineComponent, h, ref} from 'vue'; +import VerifyAttribute from './VerifyAttribute'; import {useUser} from '#imports'; /** @@ -30,6 +31,8 @@ const UserProfile: Component = defineComponent({ hideFields: {default: () => [], type: Array as PropType}, showFields: {default: () => [], type: Array as PropType}, title: {default: 'Profile', type: String}, + /** Handle of the `SELF` flow that verifies an attribute. */ + verificationFlowHandle: {default: AttributeVerificationConstants.DEFAULT_FLOW_HANDLE, type: String}, }, setup( props: Readonly<{ @@ -39,10 +42,15 @@ const UserProfile: Component = defineComponent({ hideFields: string[]; showFields: string[]; title: string; + verificationFlowHandle: string; }>, {slots}: SetupContext, ): () => VNode { const {flattenedProfile, profile, updateProfile, userSchema} = useUser(); + const {t} = useI18n(); + + // A failed save is shown, and drops the verification the save would have opened. + const error: Ref = ref(null); // `BaseUserProfile` saves one field at a time (`onUpdate({fieldName: value})`), but // `PATCH /api/auth/user/profile` -> the backend's `PUT /users/me` validates the submitted @@ -65,9 +73,34 @@ const UserProfile: Component = defineComponent({ } }); - await updateProfile({payload: updatedAttributes} as any); + error.value = null; + const result: Awaited>> = await updateProfile({ + payload: updatedAttributes, + } as any); + // `result.error` carries the route and server text, which is not meant for the user. + if (result && !result.success) { + error.value = t('user.profile.update.generic.error'); + } } + const renderVerification = ({ + attribute, + attributeDisplayName, + onCancel, + onComplete, + }: BaseUserProfileVerificationSlotProps): VNode => + h(VerifyAttribute, { + attribute, + attributeDisplayName, + flowHandle: props.verificationFlowHandle, + key: attribute, + onCancel, + onComplete, + onDone: onCancel, + // The profile shows the panel in a dialog, which draws the frame. + variant: 'flat', + }); + return (): VNode => h( BaseUserProfile, @@ -76,15 +109,17 @@ const UserProfile: Component = defineComponent({ class: withVendorCSSClassPrefix('user-profile--styled'), className: props.className, editable: props.editable, + error: error.value, flattenedProfile: flattenedProfile?.value, hideFields: props.hideFields, onUpdate: updateProfile ? handleProfileUpdate : undefined, profile: profile?.value?.profile ?? flattenedProfile?.value, showFields: props.showFields, + t, title: props.title, userSchema: userSchema?.value, }, - slots, + updateProfile ? {verification: renderVerification, ...slots} : slots, ); }, }); diff --git a/packages/nuxt/src/runtime/components/account/VerifyAttribute.ts b/packages/nuxt/src/runtime/components/account/VerifyAttribute.ts new file mode 100644 index 00000000..5afa4072 --- /dev/null +++ b/packages/nuxt/src/runtime/components/account/VerifyAttribute.ts @@ -0,0 +1,154 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import { + AttributeVerificationConstants, + EmbeddedFlowType, + ThunderIDRuntimeError, + isVerifiableAttribute, + startCase, + withVendorCSSClassPrefix, + type EmbeddedSelfFlowRequest, + type EmbeddedSelfFlowResponse, + type Preferences, +} from '@thunderid/browser'; +import {BaseVerifyAttribute} from '@thunderid/vue'; +import { + type Component, + type PropType, + type Ref, + type SetupContext, + type VNode, + defineComponent, + h, + onMounted, + ref, +} from 'vue'; +import NuxtAPIRoutes from '../../constants/NuxtAPIRoutes'; +import {useUser} from '#imports'; + +type VerifyAttributeProps = Readonly<{ + attribute: string; + attributeDisplayName?: string; + className: string; + flowHandle: string; + onCancel?: () => void; + onComplete?: (response: EmbeddedSelfFlowResponse) => void; + onDone?: () => void; + onError?: (error: Error) => void; + preferences?: Preferences; + showSubtitle: boolean; + showTitle: boolean; + size: 'small' | 'medium' | 'large'; + variant: 'outlined' | 'flat'; +}>; + +/** + * Nuxt-specific VerifyAttribute container. + * + * Renders {@link BaseVerifyAttribute} from `@thunderid/vue`, but runs every step through + * `POST /api/auth/user/self-flow` (`NuxtAPIRoutes.USER_SELF_FLOW`), which attaches the + * access token of the server-held session, rather than calling the ThunderID server from the browser, + * where no token is held. Once the server records the verification, and when the user selects Done + * after a link is sent, it re-reads the profile through `useUser().revalidateProfile`. + * + * Preserves the same prop/event API as the Vue SDK's `VerifyAttribute` component. An attribute the schema + * does not declare as `email` or `mobile` is refused without a request, through `onError`, with + * `VerifyAttribute-handleInitialize-RuntimeError-001`. + * + * @example + * ```vue + * + * ``` + */ +const VerifyAttribute: Component = defineComponent({ + name: 'VerifyAttribute', + props: { + attribute: {required: true, type: String}, + attributeDisplayName: {default: undefined, type: String}, + className: {default: '', type: String}, + flowHandle: {default: AttributeVerificationConstants.DEFAULT_FLOW_HANDLE, type: String}, + onCancel: {default: undefined, type: Function as PropType<() => void>}, + onComplete: {default: undefined, type: Function as PropType<(response: EmbeddedSelfFlowResponse) => void>}, + onDone: {default: undefined, type: Function as PropType<() => void>}, + onError: {default: undefined, type: Function as PropType<(error: Error) => void>}, + preferences: {default: undefined, type: Object as PropType}, + showSubtitle: {default: true, type: Boolean}, + showTitle: {default: true, type: Boolean}, + size: {default: 'medium', type: String as PropType<'small' | 'medium' | 'large'>}, + variant: {default: 'outlined', type: String as PropType<'outlined' | 'flat'>}, + }, + setup(props: VerifyAttributeProps, {slots}: SetupContext): () => VNode { + const {revalidateProfile, userSchema} = useUser(); + // Start the flow on the client only; a server render would send a code that hydration sends again. + const isMounted: Ref = ref(false); + onMounted(() => { + isMounted.value = true; + }); + + const execute = (payload: EmbeddedSelfFlowRequest): Promise => + $fetch(NuxtAPIRoutes.USER_SELF_FLOW, {body: payload, method: 'POST'}); + + const handleInitialize = (): Promise => { + if (!isVerifiableAttribute(userSchema?.value, props.attribute)) { + return Promise.reject( + new ThunderIDRuntimeError( + `Attribute "${props.attribute}" is not a verifiable email or mobile attribute.`, + 'VerifyAttribute-handleInitialize-RuntimeError-001', + 'nuxt', + 'Only a top-level, single-valued email or mobile attribute of the user schema can be verified.', + ), + ); + } + + return execute({ + flowType: EmbeddedFlowType.Self, + handle: props.flowHandle, + inputs: {[AttributeVerificationConstants.ATTRIBUTE_INPUT]: props.attribute}, + }); + }; + + // Re-reads the profile so the recorded verification shows; a failure leaves it stale, not broken. + const refreshProfile = async (): Promise => { + try { + await revalidateProfile?.(); + } catch { + // Non-fatal: the profile stays stale until the next navigation. + } + }; + + return (): VNode => + h( + BaseVerifyAttribute, + { + attributeDisplayName: + props.attributeDisplayName ?? + userSchema?.value?.[props.attribute]?.displayName ?? + startCase(props.attribute), + class: withVendorCSSClassPrefix('verify-attribute--styled'), + className: props.className, + isInitialized: isMounted.value, + onCancel: props.onCancel, + onComplete: (response: EmbeddedSelfFlowResponse): void => { + void refreshProfile(); + props.onComplete?.(response); + }, + onDone: (): void => { + void refreshProfile(); + props.onDone?.(); + }, + onError: props.onError, + onInitialize: handleInitialize, + onSubmit: execute, + preferences: props.preferences, + showSubtitle: props.showSubtitle, + showTitle: props.showTitle, + size: props.size, + variant: props.variant, + }, + slots, + ); + }, +}); + +export default VerifyAttribute; diff --git a/packages/nuxt/src/runtime/constants/NuxtAPIRoutes.ts b/packages/nuxt/src/runtime/constants/NuxtAPIRoutes.ts index 0561c963..4c400b83 100644 --- a/packages/nuxt/src/runtime/constants/NuxtAPIRoutes.ts +++ b/packages/nuxt/src/runtime/constants/NuxtAPIRoutes.ts @@ -30,6 +30,7 @@ const NuxtAPIRoutes: { USER: string; USER_CREDENTIALS: string; USER_PROFILE: string; + USER_SELF_FLOW: string; USER_TYPES: string; USERS: string; } = { @@ -59,6 +60,8 @@ const NuxtAPIRoutes: { USER_CREDENTIALS: '/api/auth/user/credentials', /** Fetches (GET) and updates (PATCH) the user profile. */ USER_PROFILE: '/api/auth/user/profile', + /** Executes (POST) one step of a `SELF` flow, such as attribute verification, for the signed-in user. */ + USER_SELF_FLOW: '/api/auth/user/self-flow', /** Loads one page of user types for a `USER_TYPE_SELECT` picker, using the signed-in session. */ USER_TYPES: '/api/auth/user-types', /** Loads one page of the user directory for a `USER_SELECT` picker, using the signed-in session. */ diff --git a/packages/nuxt/src/runtime/server/routes/auth/user/self-flow.post.ts b/packages/nuxt/src/runtime/server/routes/auth/user/self-flow.post.ts new file mode 100644 index 00000000..55baee73 --- /dev/null +++ b/packages/nuxt/src/runtime/server/routes/auth/user/self-flow.post.ts @@ -0,0 +1,60 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import type {EmbeddedSelfFlowResponse, SelfFlowRequestParseResult} from '@thunderid/node'; +import {ThunderIDAPIError, parseSelfFlowRequest} from '@thunderid/node'; +import {defineEventHandler, readBody, createError} from 'h3'; +import type {H3Event} from 'h3'; +import ThunderIDNuxtClient from '../../../ThunderIDNuxtClient'; +import {verifyAndRehydrateSession} from '../../../utils/serverSession'; +import {useRuntimeConfig} from '#imports'; + +/** + * POST /api/auth/user/self-flow + * + * Executes one step of a `SELF` flow, such as self attribute verification, for the signed-in + * user, with the access token of the server-held session. Mirrors the + * `executeSelfFlowAction` Next.js server action. + * + * Request body: one flow step (`EmbeddedSelfFlowRequest`). Only the fields of a step are + * forwarded, so the caller cannot name a user, a session or an application; a new flow must be a + * `SELF` flow named by its handle. + * + * Response: the step (`EmbeddedSelfFlowResponse`). A failure answers with the status of the + * server's refusal and a fixed message, never the server's reply. + */ +export default defineEventHandler(async (event: H3Event): Promise => { + const config: ReturnType = useRuntimeConfig(); + const sessionSecret: string | undefined = config.thunderid?.sessionSecret; + + const session: Awaited> = await verifyAndRehydrateSession( + event, + sessionSecret, + ); + if (!session) { + throw createError({statusCode: 401, statusMessage: 'Unauthorized: Invalid or expired session.'}); + } + + let body: unknown; + try { + body = await readBody(event); + } catch { + throw createError({statusCode: 400, statusMessage: 'Invalid request body.'}); + } + + const checked: SelfFlowRequestParseResult = parseSelfFlowRequest(body); + if ('invalid' in checked) { + throw createError({statusCode: 400, statusMessage: 'Invalid request body.'}); + } + + try { + const client: ThunderIDNuxtClient = ThunderIDNuxtClient.getInstance(); + return await client.executeEmbeddedSelfFlow(checked.request, session.sessionId); + } catch (error) { + const statusCode: number | undefined = error instanceof ThunderIDAPIError ? error.statusCode : undefined; + throw createError({ + statusCode: statusCode && statusCode >= 400 ? statusCode : 502, + statusMessage: 'SELF flow request failed.', + }); + } +}); diff --git a/packages/nuxt/tests/unit/account-custom-render.test.ts b/packages/nuxt/tests/unit/account-custom-render.test.ts new file mode 100644 index 00000000..18decc67 --- /dev/null +++ b/packages/nuxt/tests/unit/account-custom-render.test.ts @@ -0,0 +1,127 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +/* eslint-disable @typescript-eslint/typedef, @typescript-eslint/no-explicit-any, @typescript-eslint/explicit-function-return-type */ + +import {describe, expect, it, vi} from 'vitest'; +import {effectScope, ref} from 'vue'; + +const BaseVerifyAttribute = {name: 'BaseVerifyAttribute'}; +const BaseUserProfile = {name: 'BaseUserProfile'}; +const revalidateProfile = vi.fn().mockResolvedValue(undefined); +const updateProfile = vi.fn(); + +vi.mock('@thunderid/vue', () => ({ + BaseUserProfile, + BaseVerifyAttribute, + useI18n: () => ({t: (key: string) => key}), +})); + +vi.mock('#imports', () => ({ + useUser: () => ({ + flattenedProfile: ref(null), + profile: ref(null), + revalidateProfile, + updateProfile, + userSchema: ref({email: {type: 'email'}}), + }), +})); + +const {default: VerifyAttribute} = await import('../../src/runtime/components/account/VerifyAttribute'); +const {default: UserProfile} = await import('../../src/runtime/components/account/UserProfile'); + +const renderSetup = (component: any, props: Record, slots: Record) => { + const scope = effectScope(); + const render = scope.run(() => component.setup(props, {attrs: {}, emit: vi.fn(), slots} as any)); + return {node: render(), render, scope}; +}; + +describe('VerifyAttribute custom render', () => { + it('hands its default slot to BaseVerifyAttribute and keeps the profile re-read on completion', async () => { + const custom = vi.fn(); + const onComplete = vi.fn(); + const {node, scope} = renderSetup( + VerifyAttribute, + {attribute: 'email', className: '', flowHandle: 'default', onComplete, showSubtitle: true, showTitle: true}, + {default: custom}, + ); + + expect(node.type).toBe(BaseVerifyAttribute); + expect(node.children.default).toBe(custom); + + node.props.onComplete({executionId: 'exec-1', flowStatus: 'COMPLETE'}); + await Promise.resolve(); + + expect(revalidateProfile).toHaveBeenCalledTimes(1); + expect(onComplete).toHaveBeenCalledWith({executionId: 'exec-1', flowStatus: 'COMPLETE'}); + scope.stop(); + }); +}); + +describe('VerifyAttribute server render', () => { + it('starts no verification before it is mounted on the client', () => { + const {node, scope} = renderSetup( + VerifyAttribute, + {attribute: 'email', className: '', flowHandle: 'default', showSubtitle: true, showTitle: true}, + {}, + ); + + expect(node.props.isInitialized).toBe(false); + scope.stop(); + }); +}); + +describe('UserProfile custom render', () => { + it('hands its default slot to BaseUserProfile with the verification slot that runs VerifyAttribute', () => { + const custom = vi.fn(); + const {node, scope} = renderSetup( + UserProfile, + { + cardLayout: true, + className: '', + editable: true, + hideFields: [], + showFields: [], + title: 'Profile', + verificationFlowHandle: 'email-link-verification', + }, + {default: custom}, + ); + + expect(node.type).toBe(BaseUserProfile); + expect(node.children.default).toBe(custom); + + const panel = node.children.verification({attribute: 'email', attributeDisplayName: 'Email', onCancel: vi.fn()}); + + expect(panel.type).toBe(VerifyAttribute); + expect(panel.props).toEqual(expect.objectContaining({attribute: 'email', flowHandle: 'email-link-verification'})); + scope.stop(); + }); +}); + +describe('UserProfile save failure', () => { + it('shows the generic message, never the route or server text', async () => { + updateProfile.mockResolvedValueOnce({ + error: 'FetchError: [PATCH] "/api/auth/user/profile": 500 db down', + success: false, + }); + const {node, render, scope} = renderSetup( + UserProfile, + { + cardLayout: true, + className: '', + editable: true, + hideFields: [], + showFields: [], + title: 'Profile', + verificationFlowHandle: 'default', + }, + {}, + ); + + await node.props.onUpdate({email: 'new@example.com'}); + + expect(render().props.error).toBe('user.profile.update.generic.error'); + scope.stop(); + }); +}); diff --git a/packages/nuxt/tests/unit/self-flow-post.test.ts b/packages/nuxt/tests/unit/self-flow-post.test.ts new file mode 100644 index 00000000..1ac0140d --- /dev/null +++ b/packages/nuxt/tests/unit/self-flow-post.test.ts @@ -0,0 +1,120 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +/* eslint-disable @typescript-eslint/typedef, sort-keys, @typescript-eslint/explicit-function-return-type */ + +import {ThunderIDAPIError} from '@thunderid/node'; +import {readBody} from 'h3'; +import {describe, it, expect, vi, beforeEach} from 'vitest'; + +import selfFlowHandler from '../../src/runtime/server/routes/auth/user/self-flow.post'; +import {verifyAndRehydrateSession} from '../../src/runtime/server/utils/serverSession'; + +// ── vi.hoisted ──────────────────────────────────────────────────────────────── +const mockClientInstance = vi.hoisted(() => ({ + executeEmbeddedSelfFlow: vi.fn<(...args: any[]) => Promise>(), +})); + +// ── Module mocks ────────────────────────────────────────────────────────────── + +vi.mock('h3', () => ({ + defineEventHandler: (fn: Function) => fn, + readBody: vi.fn(), + createError: vi.fn((opts: any) => Object.assign(new Error(opts.statusMessage), opts)), +})); + +vi.mock('#imports', () => ({ + useRuntimeConfig: vi.fn(() => ({thunderid: {sessionSecret: 'test-secret'}})), +})); + +vi.mock('../../src/runtime/server/ThunderIDNuxtClient', () => ({ + default: {getInstance: () => mockClientInstance}, +})); + +vi.mock('../../src/runtime/server/utils/serverSession', () => ({ + verifyAndRehydrateSession: vi.fn(), +})); + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +const mockEvent = {}; + +const start = { + flowType: 'SELF', + handle: 'default-self-attribute-verification-flow', + inputs: {attribute: 'email'}, +}; +const step = {challengeToken: 'ct-1', executionId: 'exec-1', flowStatus: 'INCOMPLETE'}; + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe('POST /api/auth/user/self-flow', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(verifyAndRehydrateSession).mockResolvedValue({sessionId: 'session-1'} as any); + vi.mocked(readBody).mockResolvedValue(start); + }); + + it('rejects an invalid or missing session with 401 and never reaches the server', async () => { + vi.mocked(verifyAndRehydrateSession).mockResolvedValue(null as any); + + await expect((selfFlowHandler as any)(mockEvent)).rejects.toMatchObject({statusCode: 401}); + expect(mockClientInstance.executeEmbeddedSelfFlow).not.toHaveBeenCalled(); + }); + + it('runs the step with the server-resolved session and returns it', async () => { + mockClientInstance.executeEmbeddedSelfFlow.mockResolvedValue(step); + + const result = await (selfFlowHandler as any)(mockEvent); + + expect(mockClientInstance.executeEmbeddedSelfFlow).toHaveBeenCalledWith(start, 'session-1'); + expect(result).toEqual(step); + }); + + it('does not let the caller choose the session or add a user or application identifier', async () => { + vi.mocked(readBody).mockResolvedValue({ + ...start, + applicationId: 'app-1', + sessionId: 'someone-elses-session', + userId: 'someone-else', + }); + mockClientInstance.executeEmbeddedSelfFlow.mockResolvedValue(step); + + await (selfFlowHandler as any)(mockEvent); + + expect(mockClientInstance.executeEmbeddedSelfFlow).toHaveBeenCalledWith(start, 'session-1'); + }); + + it.each([ + ['a flow of another type', {flowType: 'AUTHENTICATION', handle: 'h'}], + ['a new flow without a handle', {flowType: 'SELF'}], + ['inputs that are not strings', {executionId: 'exec-1', inputs: {otp: 1}}], + ['no body', undefined], + ])('refuses %s with 400 without reaching the server', async (_label: string, body: unknown) => { + vi.mocked(readBody).mockResolvedValue(body); + + await expect((selfFlowHandler as any)(mockEvent)).rejects.toMatchObject({statusCode: 400}); + expect(mockClientInstance.executeEmbeddedSelfFlow).not.toHaveBeenCalled(); + }); + + it("answers with the status of the server's refusal, never its reply", async () => { + mockClientInstance.executeEmbeddedSelfFlow.mockRejectedValue( + new ThunderIDAPIError('{"description":"internal detail"}', 'x-ResponseError-001', 'javascript', 403), + ); + + const failure: any = await (selfFlowHandler as any)(mockEvent).catch((error: unknown) => error); + + expect(failure).toMatchObject({statusCode: 403, statusMessage: 'SELF flow request failed.'}); + expect(JSON.stringify(failure)).not.toContain('internal detail'); + expect(String(failure.message)).not.toContain('internal detail'); + }); + + it('answers 502 for an unexpected failure, without its text', async () => { + mockClientInstance.executeEmbeddedSelfFlow.mockRejectedValue(new Error('connect ECONNREFUSED 10.0.0.5')); + + const failure: any = await (selfFlowHandler as any)(mockEvent).catch((error: unknown) => error); + + expect(failure).toMatchObject({statusCode: 502}); + expect(String(failure.message)).not.toContain('ECONNREFUSED'); + }); +}); diff --git a/packages/react/src/api/auth/__tests__/executeEmbeddedSelfFlow.test.ts b/packages/react/src/api/auth/__tests__/executeEmbeddedSelfFlow.test.ts new file mode 100644 index 00000000..1719e5eb --- /dev/null +++ b/packages/react/src/api/auth/__tests__/executeEmbeddedSelfFlow.test.ts @@ -0,0 +1,71 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {EmbeddedFlowType, EmbeddedSelfFlowRequest} from '@thunderid/browser'; +import {beforeEach, describe, expect, it, vi} from 'vitest'; +import executeEmbeddedSelfFlow from '../executeEmbeddedSelfFlow'; + +const mocks = vi.hoisted(() => ({ + authenticatedFetcher: vi.fn(), + baseExecute: vi.fn(), + createHttpClientFetcher: vi.fn(), +})); + +vi.mock('@thunderid/browser', async (importOriginal: () => Promise>) => ({ + ...(await importOriginal()), + createHttpClientFetcher: mocks.createHttpClientFetcher, + executeEmbeddedSelfFlow: mocks.baseExecute, +})); + +const payload: EmbeddedSelfFlowRequest = { + flowType: EmbeddedFlowType.Self, + inputs: {attribute: 'email'}, +}; + +describe('executeEmbeddedSelfFlow', () => { + beforeEach(() => { + mocks.createHttpClientFetcher.mockReset().mockReturnValue(mocks.authenticatedFetcher); + mocks.baseExecute.mockReset().mockResolvedValue({executionId: 'exec-1', flowStatus: 'INCOMPLETE'}); + }); + + it("sends through the SPA client's authenticated fetcher by default", async () => { + await executeEmbeddedSelfFlow({baseUrl: 'https://localhost:8090', payload}); + + expect(mocks.createHttpClientFetcher).toHaveBeenCalledWith(0); + expect(mocks.baseExecute).toHaveBeenCalledWith({ + baseUrl: 'https://localhost:8090', + fetcher: mocks.authenticatedFetcher, + payload, + }); + }); + + it('uses the fetcher of the given instance', async () => { + await executeEmbeddedSelfFlow({ + instanceId: 2, + payload, + url: 'https://localhost:8090/flow/execute', + }); + + expect(mocks.createHttpClientFetcher).toHaveBeenCalledWith(2); + expect(mocks.baseExecute).toHaveBeenCalledWith( + expect.objectContaining({fetcher: mocks.authenticatedFetcher, url: 'https://localhost:8090/flow/execute'}), + ); + expect(mocks.baseExecute.mock.calls[0][0]).not.toHaveProperty('instanceId'); + }); + + it('prefers a fetcher the caller supplies', async () => { + const fetcher = vi.fn(); + + await executeEmbeddedSelfFlow({baseUrl: 'https://localhost:8090', fetcher, payload}); + + expect(mocks.createHttpClientFetcher).not.toHaveBeenCalled(); + expect(mocks.baseExecute).toHaveBeenCalledWith(expect.objectContaining({fetcher})); + }); + + it('returns the step the core function returns', async () => { + await expect(executeEmbeddedSelfFlow({baseUrl: 'https://localhost:8090', payload})).resolves.toEqual({ + executionId: 'exec-1', + flowStatus: 'INCOMPLETE', + }); + }); +}); diff --git a/packages/react/src/api/auth/executeEmbeddedSelfFlow.ts b/packages/react/src/api/auth/executeEmbeddedSelfFlow.ts new file mode 100644 index 00000000..1b20b13b --- /dev/null +++ b/packages/react/src/api/auth/executeEmbeddedSelfFlow.ts @@ -0,0 +1,54 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import { + EmbeddedSelfFlowResponse, + createHttpClientFetcher, + executeEmbeddedSelfFlow as baseExecuteEmbeddedSelfFlow, + ExecuteEmbeddedSelfFlowConfig as BaseExecuteEmbeddedSelfFlowConfig, +} from '@thunderid/browser'; + +/** + * Configuration for the executeEmbeddedSelfFlow request (React-specific) + */ +export interface ExecuteEmbeddedSelfFlowConfig extends Omit { + /** + * Optional custom fetcher function. If not provided, the ThunderID SPA client's httpClient is used, + * which attaches the signed-in user's access token. + */ + fetcher?: (url: string, config: RequestInit) => Promise; + /** + * Optional instance ID for multi-instance support. Defaults to 0. + */ + instanceId?: number; +} + +/** + * Executes one step of a `SELF` flow for the signed-in user. Uses the ThunderID SPA + * client's httpClient by default, which attaches the access token the flow identifies the user by. + * + * @param config - Configuration object with URL, payload and optional request config. + * @returns A promise that resolves with the flow step. + * @example + * ```typescript + * const step = await executeEmbeddedSelfFlow({ + * baseUrl: "https://localhost:8090", + * payload: { + * flowType: EmbeddedFlowType.Self, + * handle: "default-self-attribute-verification-flow", + * inputs: {attribute: "email"}, + * }, + * }); + * ``` + */ +const executeEmbeddedSelfFlow = async ({ + fetcher, + instanceId = 0, + ...requestConfig +}: ExecuteEmbeddedSelfFlowConfig): Promise => + baseExecuteEmbeddedSelfFlow({ + ...requestConfig, + fetcher: fetcher ?? createHttpClientFetcher(instanceId), + }); + +export default executeEmbeddedSelfFlow; diff --git a/packages/react/src/components/account/UserProfile/BaseUserProfile.styles.ts b/packages/react/src/components/account/UserProfile/BaseUserProfile.styles.ts index 913613a0..1833febe 100644 --- a/packages/react/src/components/account/UserProfile/BaseUserProfile.styles.ts +++ b/packages/react/src/components/account/UserProfile/BaseUserProfile.styles.ts @@ -197,6 +197,58 @@ const useStyles = (theme: Theme, colorScheme: string): Record => margin-bottom: calc(${theme.vars.spacing.unit} * 3); `; + const verificationStatus: string = css` + display: inline-flex; + flex-shrink: 0; + gap: calc(${theme.vars.spacing.unit} / 2); + align-items: center; + padding: 0 ${theme.vars.spacing.unit}; + border-radius: 999px; + font-size: 0.75rem; + font-weight: 500; + line-height: 22px; + white-space: nowrap; + `; + + const verified: string = css` + color: ${theme.vars.colors.success.dark ?? theme.vars.colors.success.main}; + background: color-mix(in srgb, ${theme.vars.colors.success.main} 12%, transparent); + `; + + // Amber marks an action the user can take. + const unverified: string = css` + color: ${theme.vars.colors.warning.contrastText}; + background: color-mix(in srgb, ${theme.vars.colors.warning.main} 14%, transparent); + `; + + const verification: string = css` + padding: ${theme.vars.spacing.unit} 0; + `; + + // Starts where the value column starts: the label's width plus the row gap. + const verificationInline: string = css` + margin-inline-start: calc(120px + ${theme.vars.spacing.unit}); + `; + + const verificationDialog: string = css` + box-sizing: border-box; + width: min(448px, calc(100vw - calc(${theme.vars.spacing.unit} * 4))); + padding: calc(${theme.vars.spacing.unit} * 2); + `; + + // Hidden from sight, read by assistive technology. + const visuallyHidden: string = css` + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; + `; + return { alert, card, @@ -220,15 +272,27 @@ const useStyles = (theme: Theme, colorScheme: string): Record => sectionLabel, sectionRow, sectionValue, + unverified, value, valuePlaceholder, + verification, + verificationDialog, + verificationInline, + verificationStatus, + verified, + visuallyHidden, }; }, [ theme.vars.colors.background.surface, theme.vars.colors.text.primary, theme.vars.colors.text.secondary, theme.vars.colors.border, + theme.vars.colors.success.main, + theme.vars.colors.success.dark, + theme.vars.colors.warning.contrastText, + theme.vars.colors.warning.main, theme.vars.borderRadius.large, + theme.vars.borderRadius.small, theme.vars.borderRadius.medium, theme.vars.spacing.unit, theme.vars.typography.fontFamily, diff --git a/packages/react/src/components/account/UserProfile/BaseUserProfile.tsx b/packages/react/src/components/account/UserProfile/BaseUserProfile.tsx index 7a0446fe..c2d07ac6 100644 --- a/packages/react/src/components/account/UserProfile/BaseUserProfile.tsx +++ b/packages/react/src/components/account/UserProfile/BaseUserProfile.tsx @@ -1,8 +1,19 @@ -// Copyright 2025 The ThunderID Authors +// Copyright 2025-2026 The ThunderID Authors // SPDX-License-Identifier: Apache-2.0 -import {User, withVendorCSSClassPrefix, bem, Preferences, startCase, AttributeSchema} from '@thunderid/browser'; -import {FC, ReactElement, useState, useCallback, useEffect} from 'react'; +import { + User, + withVendorCSSClassPrefix, + bem, + Preferences, + startCase, + AttributeSchema, + AttributeVerificationConstants, + VerifiedAttribute, + getVerifiedAttributes, + isVerifiableAttribute, +} from '@thunderid/browser'; +import {FC, ReactElement, ReactNode, useState, useCallback, useEffect, useMemo} from 'react'; import useStyles from './BaseUserProfile.styles'; import useTheme from '../../../contexts/Theme/useTheme'; import useTranslation from '../../../hooks/useTranslation'; @@ -17,6 +28,7 @@ import Checkbox from '../../primitives/Checkbox/Checkbox'; import DatePicker from '../../primitives/DatePicker/DatePicker'; import DialogPrimitive from '../../primitives/Dialog/Dialog'; import Divider from '../../primitives/Divider/Divider'; +import {CircleAlert, CircleCheck} from '../../primitives/Icons'; import MultiInput from '../../primitives/MultiInput/MultiInput'; import TextField from '../../primitives/TextField/TextField'; import Typography from '../../primitives/Typography/Typography'; @@ -71,12 +83,39 @@ export interface BaseUserProfileProps { */ preferences?: Preferences; profile?: User; + /** + * Renders the verification panel for an attribute. When omitted, or when the profile is not + * editable, verification statuses are shown without a Verify action. + */ + renderVerification?: (props: BaseUserProfileVerificationRenderProps) => ReactNode; showFields?: string[]; title?: string; userSchema?: Record | null; } +/** + * Props passed to `renderVerification`. + */ +export interface BaseUserProfileVerificationRenderProps { + /** + * Name of the attribute to verify. + */ + attribute: string; + /** + * Label of the attribute. + */ + attributeDisplayName: string; + /** + * Closes the panel. + */ + onCancel: () => void; + /** + * Closes the panel and confirms the recorded verification. + */ + onComplete: () => void; +} + // Fields to skip based on schema.name const fieldsToSkip: string[] = [ 'roles.default', @@ -101,11 +140,18 @@ const fieldsToSkip: string[] = [ 'picture', 'isReadOnly', 'isReadonly', + AttributeVerificationConstants.VERIFIED_ATTRIBUTES_KEY, ]; // Fields that should be readonly const readonlyFields: string[] = ['attributes', 'id', 'isReadOnly', 'ouId', 'username', 'sub']; +const isStatusType = (type?: string): boolean => + !!type && AttributeVerificationConstants.VERIFIABLE_ATTRIBUTE_TYPES.includes(type.toLowerCase()); + +const isVerifiedAfterSave = (type?: string): boolean => + !!type && AttributeVerificationConstants.VERIFY_AFTER_SAVE_ATTRIBUTE_TYPES.includes(type.toLowerCase()); + const BaseUserProfile: FC = ({ fallback = null, className = '', @@ -126,12 +172,62 @@ const BaseUserProfile: FC = ({ showFields = [], hideFields = [], displayNameAttributes = [], + renderVerification = undefined, }: BaseUserProfileProps): ReactElement | null => { const {theme, colorScheme} = useTheme(); const [editedUser, setEditedUser] = useState(flattenedProfile || profile); const [editingFields, setEditingFields] = useState>({}); const [fieldErrors, setFieldErrors] = useState>({}); - const {t} = useTranslation(preferences?.i18n); + // Attribute whose verification panel is open. + const [verifyingField, setVerifyingField] = useState(null); + // Label of the attribute whose verification was just recorded. + const [verifiedNotice, setVerifiedNotice] = useState(null); + // A saved attribute to verify once the profile reflects the change. + const [pendingVerification, setPendingVerification] = useState<{name: string; previousValue: unknown} | null>(null); + // Fields whose save is in flight; their stored value is about to change. + const [savingFields, setSavingFields] = useState>({}); + const {t, currentLanguage} = useTranslation(preferences?.i18n); + + const reportedVerifiedAttributes: Record | undefined = useMemo( + () => getVerifiedAttributes(profile ?? flattenedProfile), + [profile, flattenedProfile], + ); + // A server without verification support omits the field, so no status is known. + const supportsVerification: boolean = reportedVerifiedAttributes !== undefined; + const verifiedAttributes: Record = useMemo( + () => reportedVerifiedAttributes ?? {}, + [reportedVerifiedAttributes], + ); + + useEffect(() => { + if (!pendingVerification) return; + + if (error) { + setPendingVerification(null); + return; + } + + const {name, previousValue} = pendingVerification; + const storedUser: User | undefined = flattenedProfile ?? profile; + const storedAttributes: Record | undefined = storedUser?.['attributes'] as + | Record + | undefined; + const storedValue: unknown = storedAttributes?.[name] ?? storedUser?.[name]; + + if (storedValue === previousValue) return; + + // An absent verification record leaves the status unknown, so nothing opens on that basis. + if ( + supportsVerification && + typeof storedValue === 'string' && + storedValue.trim() !== '' && + !verifiedAttributes[name] + ) { + setVerifiedNotice(null); + setVerifyingField(name); + } + setPendingVerification(null); + }, [pendingVerification, flattenedProfile, profile, error, verifiedAttributes, supportsVerification]); useEffect(() => { const nextUser = flattenedProfile ?? profile; @@ -224,6 +320,8 @@ const BaseUserProfile: FC = ({ ); const styles: any = useStyles(theme, colorScheme); + // Typed view of the same classes for the verification markup. + const verificationStyles: Record = styles as Record; const ObjectDisplay: FC<{data: unknown}> = ({data}: {data: unknown}): ReactElement | null => { if (!data || typeof data !== 'object') return null; @@ -323,11 +421,40 @@ const BaseUserProfile: FC = ({ let payload: Record = {}; set(payload, fieldName, fieldValue); - onUpdate(payload); + setSavingFields((prev: Record) => ({...prev, [fieldName]: true})); + // A failed save is reported through `error`; it only drops the verification it would have opened. + Promise.resolve(onUpdate(payload)) + .catch((): void => setPendingVerification(null)) + .finally((): void => + setSavingFields((prev: Record) => { + const next: Record = {...prev}; + delete next[fieldName]; + return next; + }), + ); toggleFieldEdit(fieldName); + + // Offer verification once a changed, verifiable value is saved. + const savedUser: User | undefined = flattenedProfile ?? profile; + const savedAttributes: Record | undefined = savedUser?.['attributes'] as + | Record + | undefined; + const storedValue: unknown = savedAttributes?.[fieldName] ?? savedUser?.[fieldName]; + if ( + renderVerification && + isVerifiedAfterSave(schema.type) && + isVerifiableAttribute(userSchema, fieldName) && + typeof fieldValue === 'string' && + fieldValue.trim() !== '' && + fieldValue !== storedValue + ) { + setPendingVerification({name: fieldName, previousValue: storedValue}); + } else { + setPendingVerification(null); + } }, - [editedUser, flattenedProfile, profile, onUpdate, toggleFieldEdit, t], + [editedUser, flattenedProfile, profile, onUpdate, toggleFieldEdit, t, renderVerification, userSchema], ); const handleFieldCancel: any = useCallback( @@ -581,6 +708,87 @@ const BaseUserProfile: FC = ({ ); }; + const renderVerificationStatus = (schema: Schema): ReactElement | null => { + const verification: VerifiedAttribute | undefined = verifiedAttributes[schema.name!]; + let verifiedAtTitle: string | undefined; + + if (verification?.verifiedAt) { + const verifiedAt: Date = new Date(verification.verifiedAt); + const date: string = Number.isNaN(verifiedAt.getTime()) + ? verification.verifiedAt + : verifiedAt.toLocaleString(currentLanguage); + verifiedAtTitle = t('user.verification.status.verified_at', {date}); + } + + return ( + + {verification ? ( + + ); + }; + + const renderVerificationPanel = (schema: Schema): ReactNode => { + if (!renderVerification || !schema.name || verifyingField !== schema.name) { + return null; + } + + const attributeDisplayName: string = schema.displayName ?? startCase(schema.name); + const panel: ReactElement = ( +
+ {renderVerification({ + attribute: schema.name, + attributeDisplayName, + onCancel: (): void => setVerifyingField(null), + onComplete: (): void => { + setVerifyingField(null); + setVerifiedNotice(attributeDisplayName); + }, + })} +
+ ); + + // Popup mode keeps the panel under the row, inside the profile's own dialog. + if (mode === 'popup') return panel; + + return ( + { + if (!isOpen) setVerifyingField(null); + }} + > + {/* Content's own props replace its class, so the size goes on a wrapper. */} + +
{panel}
+
+
+ ); + }; + const renderUserInfo = (schema: Schema): any => { if (!schema?.name) return null; @@ -594,6 +802,30 @@ const BaseUserProfile: FC = ({ return null; } + // Status describes the stored value, so it is shown only while the row shows that value and no + // save is about to replace it. + const storedUser: User | undefined = flattenedProfile ?? profile; + const storedAttributes: Record | undefined = storedUser?.['attributes'] as + | Record + | undefined; + const storedValue: unknown = storedAttributes?.[schema.name] ?? storedUser?.[schema.name]; + const showsVerificationStatus: boolean = + supportsVerification && + !!hasValue && + !isFieldEditing && + !savingFields[schema.name] && + schema.value === storedValue && + isStatusType(schema.type); + // A read-only profile shows the status only. + const canVerify: boolean = + editable && + !!renderVerification && + showsVerificationStatus && + !verifiedAttributes[schema.name] && + verifyingField !== schema.name && + isVerifiableAttribute(userSchema, schema.name) && + typeof schema.value === 'string'; + return (
@@ -607,12 +839,30 @@ const BaseUserProfile: FC = ({ }, () => toggleFieldEdit(schema.name), )} + {showsVerificationStatus && renderVerificationStatus(schema)} {fieldErrors[schema.name] && (
{fieldErrors[schema.name]}
)}
+ {canVerify && ( +
+ +
+ )} {editable && schema.mutability !== 'READ_ONLY' && !isReadonlyField && (
{isFieldEditing && ( @@ -635,7 +885,11 @@ const BaseUserProfile: FC = ({ size="small" color="tertiary" variant="icon" - onClick={(): any => toggleFieldEdit(schema.name)} + onClick={(): any => { + if (verifyingField === schema.name) setVerifyingField(null); + setVerifiedNotice(null); + toggleFieldEdit(schema.name); + }} title="Edit" className={styles.editButton} > @@ -712,6 +966,21 @@ const BaseUserProfile: FC = ({ return ( <> + {verifiedNotice && ( + + + {t('user.verification.success', {attribute: verifiedNotice})} + + + )}
= ({ {schemaItems.map((schemaWithValue: Schema) => (
{renderUserInfo(schemaWithValue)} + {renderVerificationPanel(schemaWithValue)}
))} diff --git a/packages/react/src/components/account/UserProfile/UserProfile.tsx b/packages/react/src/components/account/UserProfile/UserProfile.tsx index b6952ff0..d18411d0 100644 --- a/packages/react/src/components/account/UserProfile/UserProfile.tsx +++ b/packages/react/src/components/account/UserProfile/UserProfile.tsx @@ -1,20 +1,33 @@ -// Copyright 2025 The ThunderID Authors +// Copyright 2025-2026 The ThunderID Authors // SPDX-License-Identifier: Apache-2.0 -import {ThunderIDError, User, deepMerge, resolveResourceEndpoint} from '@thunderid/browser'; -import {FC, ReactElement, useState} from 'react'; +import { + AttributeVerificationConstants, + ThunderIDError, + User, + deepMerge, + resolveResourceEndpoint, +} from '@thunderid/browser'; +import {FC, ReactElement, ReactNode, useState} from 'react'; // eslint-disable-next-line import/no-named-as-default -import BaseUserProfile, {BaseUserProfileProps} from './BaseUserProfile'; +import BaseUserProfile, {BaseUserProfileProps, BaseUserProfileVerificationRenderProps} from './BaseUserProfile'; import updateMeProfile from '../../../api/account/updateMeProfile'; import useThunderID from '../../../contexts/ThunderID/useThunderID'; import useUser from '../../../contexts/User/useUser'; import useTranslation from '../../../hooks/useTranslation'; +import VerifyAttribute from '../VerifyAttribute/VerifyAttribute'; /** * Props for the UserProfile component. * Extends BaseUserProfileProps but makes the user prop optional since it will be obtained from useThunderID */ -export type UserProfileProps = Omit; +export type UserProfileProps = Omit & { + /** + * Handle of the `SELF` flow that verifies an attribute. Defaults to the server's shipped + * `default-self-attribute-verification-flow`. + */ + verificationFlowHandle?: string; +}; /** * UserProfile component displays the authenticated user's profile information in a @@ -49,7 +62,12 @@ export type UserProfileProps = Omit * ``` */ -const UserProfile: FC = ({preferences, editable = true, ...rest}: UserProfileProps): ReactElement => { +const UserProfile: FC = ({ + preferences, + editable = true, + verificationFlowHandle = AttributeVerificationConstants.DEFAULT_FLOW_HANDLE, + ...rest +}: UserProfileProps): ReactElement => { const {baseUrl, endpoints, instanceId, preferences: contextPreferences} = useThunderID(); const {profile, flattenedProfile, onUpdateProfile, userSchema} = useUser(); const resolvedPreferences = { @@ -98,8 +116,29 @@ const UserProfile: FC = ({preferences, editable = true, ...res } }; + const renderVerification = ({ + attribute, + attributeDisplayName, + onCancel, + onComplete, + }: BaseUserProfileVerificationRenderProps): ReactNode => ( + + ); + return ( = { + email: {displayName: 'Email', type: 'email'}, + given_name: {displayName: 'First name', type: 'string'}, + mobile: {displayName: 'Mobile', type: 'mobile'}, +}; + +// A /users/me profile: attributes are nested and spread to the top level. +const profileOf = (attributes: Record, verifiedAttributes?: Record): User => ({ + attributes, + id: 'user-1', + type: 'person', + ...attributes, + ...(verifiedAttributes ? {verifiedAttributes} : {}), +}); + +const storedProfile: User = profileOf( + {email: 'alex@example.com', given_name: 'Alex', mobile: '+94771234567'}, + {email: {verifiedAt: VERIFIED_AT}}, +); + +const unverifiedProfile: User = profileOf({email: 'alex@example.com', given_name: 'Alex', mobile: '+94771234567'}, {}); + +// The server drops the verification of a changed attribute. +const serverUpdate = (profile: User, payload: Record): User => { + const attributes: Record = {...(profile['attributes'] as Record), ...payload}; + const verifiedAttributes: Record = {...(profile['verifiedAttributes'] as Record)}; + Object.keys(payload).forEach((key: string) => { + if (payload[key] !== (profile['attributes'] as Record)[key]) delete verifiedAttributes[key]; + }); + return profileOf(attributes, verifiedAttributes); +}; + +const verificationPanel = (props: BaseUserProfileVerificationRenderProps): ReactNode => ( +
+ {`Verifying ${props.attributeDisplayName} (${props.attribute})`} + + +
+); + +const withProviders = (ui: ReactElement): ReactElement => ( + + {ui} + +); + +const renderProfile = (props: Partial = {}): ReturnType => + render( + withProviders( + , + ), + ); + +interface HarnessProps { + initialProfile: User; + // A profile the next reload returns, as a revalidation from elsewhere would. + reloadedProfile?: User; + renderVerification?: BaseUserProfileProps['renderVerification']; + update: (profile: User, payload: Record) => Promise; +} + +// Holds the profile and the save error as UserProfile does: a failed save sets `error` and keeps the profile. +const Harness = ({ + initialProfile, + reloadedProfile = undefined, + renderVerification = undefined, + update, +}: HarnessProps): ReactElement => { + const [profile, setProfile] = useState(initialProfile); + const [error, setError] = useState(null); + + const onUpdate = async (payload: Record): Promise => { + setError(null); + try { + setProfile(await update(profile, payload)); + } catch { + setError('Could not update your profile.'); + } + }; + + return ( + <> + + {reloadedProfile && ( + + )} + + ); +}; + +const renderHarness = (props: HarnessProps): ReturnType => render(withProviders()); + +// The row of a field: its label, value, status, actions and verification panel. +const row = (label: string): HTMLElement => { + const labelElement: HTMLElement = screen.getAllByText(label).find((el: HTMLElement) => el.tagName === 'SPAN')!; + return labelElement.parentElement!.parentElement!.parentElement!; +}; + +const editAndSave = (label: string, value: string): void => { + fireEvent.click(within(row(label)).getByTitle('Edit')); + fireEvent.change(within(row(label)).getByRole('textbox'), {target: {value}}); + fireEvent.click(within(row(label)).getByText('Save')); +}; + +describe('BaseUserProfile verification', () => { + afterEach(() => { + cleanup(); + }); + + describe('status', () => { + it('shows Verified or Unverified for each email and mobile field with a value', () => { + renderProfile(); + + expect(within(row('Email')).getByText('Verified')).toBeTruthy(); + expect(within(row('Mobile')).getByText('Unverified')).toBeTruthy(); + expect(within(row('First name')).queryByText(/Verified|Unverified/)).toBeNull(); + }); + + it('gives the verification time of a verified field, also as text for keyboard and touch users', () => { + renderProfile(); + + const verifiedOn = `Verified on ${new Date(VERIFIED_AT).toLocaleString('en-US')}`; + const status: HTMLElement = within(row('Email')).getByText('Verified'); + expect(status.getAttribute('title')).toBe(verifiedOn); + expect(status.textContent).toBe(`Verified (${verifiedOn})`); + expect(within(row('Mobile')).getByText('Unverified').getAttribute('title')).toBeNull(); + expect(within(row('Mobile')).getByText('Unverified').textContent).toBe('Unverified'); + }); + + it('shows no status when the profile does not report verification', () => { + const legacyProfile: User = profileOf({email: 'alex@example.com', mobile: '+94771234567'}); + + renderProfile({flattenedProfile: legacyProfile, profile: legacyProfile}); + + expect(screen.queryByText(/Verified|Unverified/)).toBeNull(); + }); + + it('does not list the verification record as a profile field', () => { + renderProfile({userSchema: undefined}); + + expect(screen.getByText('alex@example.com', {selector: 'div'})).toBeTruthy(); + expect(screen.queryByText('Verified Attributes')).toBeNull(); + expect(screen.queryByText(/verifiedAt/)).toBeNull(); + }); + + it('shows no status for an empty field', () => { + const noMobile: User = profileOf({email: 'alex@example.com', mobile: ''}, {}); + + renderProfile({editable: true, flattenedProfile: noMobile, onUpdate: vi.fn(), profile: noMobile}); + + expect(within(row('Mobile')).queryByText(/Verified|Unverified/)).toBeNull(); + expect(within(row('Email')).getByText('Unverified')).toBeTruthy(); + }); + + it('hides the status while the field is being edited', () => { + renderProfile({editable: true, onUpdate: vi.fn()}); + + fireEvent.click(within(row('Email')).getByTitle('Edit')); + + expect(within(row('Email')).queryByText('Verified')).toBeNull(); + }); + }); + + describe('Verify action', () => { + it('is offered for an unverified email and mobile', () => { + renderProfile({ + flattenedProfile: unverifiedProfile, + profile: unverifiedProfile, + renderVerification: verificationPanel, + }); + + expect(within(row('Email')).getByText('Verify')).toBeTruthy(); + expect(within(row('Mobile')).getByText('Verify')).toBeTruthy(); + expect(within(row('Mobile')).getByText('Unverified')).toBeTruthy(); + }); + + it('carries test identifiers on the status and the Verify action', () => { + renderProfile({ + flattenedProfile: unverifiedProfile, + profile: unverifiedProfile, + renderVerification: verificationPanel, + }); + + expect(within(row('Email')).getByText('Verify').closest('button')!.getAttribute('data-testid')).toBe( + 'thunderid-verify-email', + ); + expect(within(row('Mobile')).getByText('Unverified').getAttribute('data-testid')).toBe( + 'thunderid-verification-status-mobile', + ); + }); + + it('is not offered, and no status shown, for a string attribute such as a first name', () => { + const renderVerification: Mock = vi.fn(verificationPanel); + renderProfile({flattenedProfile: unverifiedProfile, profile: unverifiedProfile, renderVerification}); + + expect(within(row('First name')).getByText('Alex')).toBeTruthy(); + expect(within(row('First name')).queryByText('Verify')).toBeNull(); + expect(within(row('First name')).queryByText(/Verified|Unverified/)).toBeNull(); + expect(renderVerification).not.toHaveBeenCalled(); + }); + + it('is not offered when the profile does not report verification', () => { + const legacyProfile: User = profileOf({email: 'alex@example.com', given_name: 'Alex', mobile: '+94771234567'}); + + renderProfile({flattenedProfile: legacyProfile, profile: legacyProfile, renderVerification: verificationPanel}); + + expect(within(row('Email')).getByText('alex@example.com')).toBeTruthy(); + expect(screen.queryByText('Verify')).toBeNull(); + expect(screen.queryByText(/Verified|Unverified/)).toBeNull(); + }); + + it('is not offered for a verified email', () => { + renderProfile({renderVerification: verificationPanel}); + + expect(within(row('Email')).queryByText('Verify')).toBeNull(); + expect(within(row('Mobile')).getByText('Verify')).toBeTruthy(); + }); + + it('is not offered on a profile that cannot be edited', () => { + const renderVerification: Mock = vi.fn(verificationPanel); + renderProfile({ + editable: false, + flattenedProfile: unverifiedProfile, + profile: unverifiedProfile, + renderVerification, + }); + + expect(within(row('Email')).getByText('Unverified')).toBeTruthy(); + expect(screen.queryByText('Verify')).toBeNull(); + }); + + it('is not offered without a verification renderer', () => { + renderProfile({flattenedProfile: unverifiedProfile, profile: unverifiedProfile}); + + expect(within(row('Email')).getByText('Unverified')).toBeTruthy(); + expect(screen.queryByText('Verify')).toBeNull(); + }); + + it('opens the verification panel for the field and closes it on dismiss', () => { + const renderVerification: Mock = vi.fn(verificationPanel); + renderProfile({flattenedProfile: unverifiedProfile, profile: unverifiedProfile, renderVerification}); + + fireEvent.click(within(row('Email')).getByText('Verify')); + + // Inline mode opens the panel in a dialog, not in the row. + expect(within(screen.getByRole('dialog')).getByText('Verifying Email (email)')).toBeTruthy(); + expect(within(row('Email')).queryByTestId('verification-panel')).toBeNull(); + expect(within(row('Email')).queryByText('Verify')).toBeNull(); + + fireEvent.click(screen.getByText('Not now')); + + expect(screen.queryByTestId('verification-panel')).toBeNull(); + expect(within(row('Email')).getByText('Verify')).toBeTruthy(); + }); + + it('opens the verification panel for a mobile field', () => { + renderProfile({ + flattenedProfile: unverifiedProfile, + profile: unverifiedProfile, + renderVerification: verificationPanel, + }); + + fireEvent.click(within(row('Mobile')).getByText('Verify')); + + expect(within(screen.getByRole('dialog')).getByText('Verifying Mobile (mobile)')).toBeTruthy(); + }); + + it('closes the dialog and confirms the attribute once the verification is recorded', () => { + renderProfile({ + flattenedProfile: unverifiedProfile, + profile: unverifiedProfile, + renderVerification: verificationPanel, + }); + + fireEvent.click(within(row('Mobile')).getByText('Verify')); + fireEvent.click(screen.getByText('Recorded')); + + expect(screen.queryByRole('dialog')).toBeNull(); + expect(screen.getByTestId('thunderid-verification-notice').textContent).toContain('Mobile verified.'); + }); + + it('opens the panel under the row inside the popup, without a second dialog', () => { + renderProfile({ + flattenedProfile: unverifiedProfile, + mode: 'popup', + open: true, + profile: unverifiedProfile, + renderVerification: verificationPanel, + }); + + fireEvent.click(within(row('Email')).getByText('Verify')); + + expect(screen.getAllByRole('dialog')).toHaveLength(1); + expect(within(row('Email')).getByText('Verifying Email (email)')).toBeTruthy(); + expect(within(row('Mobile')).getByText('Verify')).toBeTruthy(); + + fireEvent.click(screen.getByText('Not now')); + + expect(screen.queryByTestId('verification-panel')).toBeNull(); + expect(within(row('Email')).getByText('Verify')).toBeTruthy(); + }); + + it('closes the panel when the field is edited', () => { + renderProfile({ + editable: true, + flattenedProfile: unverifiedProfile, + onUpdate: vi.fn(), + profile: unverifiedProfile, + renderVerification: verificationPanel, + }); + + fireEvent.click(within(row('Email')).getByText('Verify')); + fireEvent.click(within(row('Email')).getByTitle('Edit')); + + expect(screen.queryByTestId('verification-panel')).toBeNull(); + }); + }); + + describe('after saving', () => { + it('opens the verification panel once the profile shows a changed email as unverified', async () => { + let finishUpdate: (profile: User) => void = () => undefined; + const update: Mock = vi.fn( + () => + new Promise((resolve: (profile: User) => void) => { + finishUpdate = resolve; + }), + ); + renderHarness({initialProfile: storedProfile, renderVerification: verificationPanel, update}); + + editAndSave('Email', 'new@example.com'); + + expect(update).toHaveBeenCalledWith(storedProfile, {email: 'new@example.com'}); + expect(screen.queryByTestId('verification-panel')).toBeNull(); + + finishUpdate(serverUpdate(storedProfile, {email: 'new@example.com'})); + + expect(await screen.findByText('Verifying Email (email)')).toBeTruthy(); + expect(within(row('Email')).getByText('Unverified')).toBeTruthy(); + }); + + it('does not reopen the panel after it is dismissed', async () => { + const update: Mock = vi.fn((profile: User, payload: Record) => + Promise.resolve(serverUpdate(profile, payload)), + ); + renderHarness({initialProfile: storedProfile, renderVerification: verificationPanel, update}); + + editAndSave('Email', 'new@example.com'); + await screen.findByText('Verifying Email (email)'); + fireEvent.click(screen.getByText('Not now')); + editAndSave('First name', 'Alexa'); + + await waitFor(() => expect(update).toHaveBeenCalledTimes(2)); + await waitFor(() => expect(within(row('First name')).getByText('Alexa')).toBeTruthy()); + expect(screen.queryByTestId('verification-panel')).toBeNull(); + }); + + it('starts no verification when the save fails, even once the email later changes', async () => { + const update: Mock = vi.fn(() => Promise.reject(new Error('rejected'))); + renderHarness({ + initialProfile: storedProfile, + reloadedProfile: serverUpdate(storedProfile, {email: 'other@example.com'}), + renderVerification: verificationPanel, + update, + }); + + editAndSave('Email', 'new@example.com'); + + expect(await screen.findByText('Could not update your profile.')).toBeTruthy(); + expect(screen.queryByTestId('verification-panel')).toBeNull(); + + fireEvent.click(screen.getByText('Reload profile')); + + await waitFor(() => expect(within(row('Email')).getByText('Unverified')).toBeTruthy()); + expect(screen.queryByTestId('verification-panel')).toBeNull(); + }); + + it('opens no panel when the profile shows the new email already verified', async () => { + const update: Mock = vi.fn((profile: User, payload: Record) => + Promise.resolve( + profileOf( + {...(profile['attributes'] as Record), ...payload}, + { + email: {verifiedAt: VERIFIED_AT}, + }, + ), + ), + ); + renderHarness({initialProfile: unverifiedProfile, renderVerification: verificationPanel, update}); + + editAndSave('Email', 'new@example.com'); + + await waitFor(() => expect(within(row('Email')).getByText('Verified')).toBeTruthy()); + expect(screen.queryByTestId('verification-panel')).toBeNull(); + }); + + it('starts no verification when the email is saved unchanged, even once it later changes', async () => { + const update: Mock = vi.fn((profile: User, payload: Record) => + Promise.resolve(serverUpdate(profile, payload)), + ); + renderHarness({ + initialProfile: unverifiedProfile, + reloadedProfile: serverUpdate(unverifiedProfile, {email: 'other@example.com'}), + renderVerification: verificationPanel, + update, + }); + + editAndSave('Email', 'alex@example.com'); + + await waitFor(() => expect(update).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(within(row('Email')).getByText('Unverified')).toBeTruthy()); + expect(screen.queryByTestId('verification-panel')).toBeNull(); + + fireEvent.click(screen.getByText('Reload profile')); + + await waitFor(() => expect(within(row('Email')).getByText('other@example.com')).toBeTruthy()); + expect(screen.queryByTestId('verification-panel')).toBeNull(); + }); + + it('starts no verification for a changed mobile', async () => { + const update: Mock = vi.fn((profile: User, payload: Record) => + Promise.resolve(serverUpdate(profile, payload)), + ); + renderHarness({initialProfile: storedProfile, renderVerification: verificationPanel, update}); + + editAndSave('Mobile', '+94770000000'); + + await waitFor(() => expect(within(row('Mobile')).getByText('+94770000000')).toBeTruthy()); + expect(screen.queryByTestId('verification-panel')).toBeNull(); + }); + + it('starts no verification for a changed email when the profile does not report verification', async () => { + const legacyProfile: User = profileOf({email: 'alex@example.com', given_name: 'Alex', mobile: '+94771234567'}); + const update: Mock = vi.fn((profile: User, payload: Record) => + Promise.resolve(profileOf({...(profile['attributes'] as Record), ...payload})), + ); + renderHarness({initialProfile: legacyProfile, renderVerification: verificationPanel, update}); + + editAndSave('Email', 'new@example.com'); + + await waitFor(() => expect(within(row('Email')).getByText('new@example.com')).toBeTruthy()); + expect(screen.queryByTestId('verification-panel')).toBeNull(); + expect(screen.queryByText(/Verified|Unverified/)).toBeNull(); + }); + + it('starts no verification for a changed first name', async () => { + const update: Mock = vi.fn((profile: User, payload: Record) => + Promise.resolve(serverUpdate(profile, payload)), + ); + renderHarness({initialProfile: unverifiedProfile, renderVerification: verificationPanel, update}); + + editAndSave('First name', 'Alexa'); + + await waitFor(() => expect(within(row('First name')).getByText('Alexa')).toBeTruthy()); + expect(screen.queryByTestId('verification-panel')).toBeNull(); + }); + + it('starts no verification without a verification renderer', async () => { + const update: Mock = vi.fn((profile: User, payload: Record) => + Promise.resolve(serverUpdate(profile, payload)), + ); + renderHarness({initialProfile: storedProfile, update}); + + editAndSave('Email', 'new@example.com'); + + await waitFor(() => expect(within(row('Email')).getByText('Unverified')).toBeTruthy()); + expect(screen.queryByText('Verify')).toBeNull(); + }); + + it('drops a pending verification when a later save does not qualify', async () => { + const finishers: ((profile: User) => void)[] = []; + const update: Mock = vi.fn( + () => + new Promise((resolve: (profile: User) => void) => { + finishers.push(resolve); + }), + ); + renderHarness({initialProfile: storedProfile, renderVerification: verificationPanel, update}); + + editAndSave('Email', 'new@example.com'); + editAndSave('First name', 'Alexa'); + const changedEmail: User = serverUpdate(storedProfile, {email: 'new@example.com'}); + finishers[0](changedEmail); + finishers[1](serverUpdate(changedEmail, {given_name: 'Alexa'})); + + await waitFor(() => expect(within(row('Email')).getByText('Unverified')).toBeTruthy()); + expect(screen.queryByTestId('verification-panel')).toBeNull(); + }); + }); + + describe('while a save is in flight', () => { + it('shows neither the status nor Verify on the saved field until the profile reflects the save', async () => { + let finishUpdate: (profile: User) => void = () => undefined; + const update: Mock = vi.fn( + () => + new Promise((resolve: (profile: User) => void) => { + finishUpdate = resolve; + }), + ); + renderHarness({initialProfile: unverifiedProfile, renderVerification: verificationPanel, update}); + + editAndSave('Email', 'new@example.com'); + + expect(within(row('Email')).queryByText(/Verified|Unverified/)).toBeNull(); + expect(within(row('Email')).queryByText('Verify')).toBeNull(); + expect(within(row('Mobile')).getByText('Verify')).toBeTruthy(); + + finishUpdate(serverUpdate(unverifiedProfile, {email: 'new@example.com'})); + + await waitFor(() => expect(within(row('Email')).getByText('Unverified')).toBeTruthy()); + expect(within(row('Email')).getByText('new@example.com')).toBeTruthy(); + }); + + it('does not show the old Verified status while the changed value is saved', () => { + const update: Mock = vi.fn(() => new Promise(() => undefined)); + renderHarness({initialProfile: storedProfile, renderVerification: verificationPanel, update}); + + editAndSave('Email', 'new@example.com'); + + expect(within(row('Email')).queryByText(/Verified|Unverified/)).toBeNull(); + }); + + it('shows the stored value with its own status once the save fails, never the typed one', async () => { + const update: Mock = vi.fn(() => Promise.reject(new Error('rejected'))); + renderHarness({initialProfile: unverifiedProfile, renderVerification: verificationPanel, update}); + + editAndSave('Email', 'new@example.com'); + await screen.findByText('Could not update your profile.'); + + await waitFor(() => expect(within(row('Email')).getByText('Unverified')).toBeTruthy()); + expect(within(row('Email')).getByText('alex@example.com')).toBeTruthy(); + expect(within(row('Email')).queryByText('new@example.com')).toBeNull(); + expect(within(row('Email')).getByText('Verify')).toBeTruthy(); + }); + + it('settles a rejected update from the caller and shows the stored status again', async () => { + const onUpdate: Mock = vi.fn(() => Promise.reject(new Error('rejected'))); + renderProfile({ + editable: true, + flattenedProfile: unverifiedProfile, + onUpdate, + profile: unverifiedProfile, + renderVerification: verificationPanel, + }); + + editAndSave('Email', 'new@example.com'); + + expect(onUpdate).toHaveBeenCalledWith({email: 'new@example.com'}); + await waitFor(() => expect(within(row('Email')).getByText('Unverified')).toBeTruthy()); + expect(screen.queryByTestId('verification-panel')).toBeNull(); + }); + + it('shows no status or Verify for a value the row shows that is not the stored one', () => { + // A profile whose top-level value, such as an ID token claim, differs from the stored attribute. + const mismatched: User = {...unverifiedProfile, email: 'claim@example.com'}; + + renderProfile({flattenedProfile: mismatched, profile: mismatched, renderVerification: verificationPanel}); + + expect(within(row('Email')).queryByText(/Verified|Unverified/)).toBeNull(); + expect(within(row('Email')).queryByText('Verify')).toBeNull(); + }); + }); +}); diff --git a/packages/react/src/components/account/UserProfile/__tests__/UserProfile.test.tsx b/packages/react/src/components/account/UserProfile/__tests__/UserProfile.test.tsx new file mode 100644 index 00000000..b7cb2784 --- /dev/null +++ b/packages/react/src/components/account/UserProfile/__tests__/UserProfile.test.tsx @@ -0,0 +1,140 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {cleanup, fireEvent, render, screen, waitFor, within} from '@testing-library/react'; +import {AttributeSchema, User} from '@thunderid/browser'; +import {afterEach, beforeEach, describe, expect, it, Mock, vi} from 'vitest'; +import I18nProvider from '../../../../contexts/I18n/I18nProvider'; +import ThemeProvider from '../../../../contexts/Theme/ThemeProvider'; +import ThunderIDContext, {ThunderIDContextProps} from '../../../../contexts/ThunderID/ThunderIDContext'; +import UserContext, {UserContextProps} from '../../../../contexts/User/UserContext'; +import UserProfile, {UserProfileProps} from '../UserProfile'; + +const mockExecute = vi.fn() as Mock; +const mockRevalidateProfile = vi.fn() as Mock; + +vi.mock('../../../../api/auth/executeEmbeddedSelfFlow', () => ({ + default: (...args: unknown[]): unknown => mockExecute(...args) as unknown, +})); + +const thunderIDContext: ThunderIDContextProps = { + baseUrl: 'https://localhost:8090', + instanceId: 0, + isInitialized: true, + isLoading: false, + meta: null, + vendor: 'thunderid', +} as unknown as ThunderIDContextProps; + +const userSchema: Record = { + email: {displayName: 'Email', type: 'email'}, + given_name: {displayName: 'First name', type: 'string'}, +}; + +const attributes: Record = {email: 'alex@example.com', given_name: 'Alex'}; +const profile: User = {attributes, id: 'user-1', ...attributes, verifiedAttributes: {}}; + +const renderUserProfile = (props: UserProfileProps = {}): ReturnType => + render( + + + + + + + + + , + ); + +// The row of a field: its label, value, status, actions and verification panel. +const row = (label: string): HTMLElement => { + const labelElement: HTMLElement = screen.getAllByText(label).find((el: HTMLElement) => el.tagName === 'SPAN')!; + return labelElement.parentElement!.parentElement!.parentElement!; +}; + +describe('UserProfile verification', () => { + beforeEach(() => { + mockExecute.mockReset(); + mockRevalidateProfile.mockReset().mockResolvedValue(undefined); + }); + + afterEach(() => { + cleanup(); + }); + + it('offers Verify for the email only, not for a first name', () => { + renderUserProfile(); + + expect(within(row('Email')).getByText('Verify')).toBeTruthy(); + expect(within(row('First name')).queryByText('Verify')).toBeNull(); + expect(mockExecute).not.toHaveBeenCalled(); + }); + + it('verifies the email through the self attribute verification flow and re-reads the profile', async () => { + mockExecute.mockResolvedValue({executionId: 'exec-1', flowStatus: 'COMPLETE'}); + renderUserProfile(); + + fireEvent.click(within(row('Email')).getByText('Verify')); + + await waitFor(() => expect(mockExecute).toHaveBeenCalledTimes(1)); + expect(mockExecute).toHaveBeenCalledWith( + expect.objectContaining({ + payload: { + flowType: 'SELF', + handle: 'default-self-attribute-verification-flow', + inputs: {attribute: 'email'}, + }, + }), + ); + expect(await screen.findByText('Email verified.')).toBeTruthy(); + expect(mockRevalidateProfile).toHaveBeenCalledTimes(1); + }); + + it('starts the flow the app names by verificationFlowHandle', async () => { + renderUserProfile({verificationFlowHandle: 'email-link-verification'}); + + fireEvent.click(within(row('Email')).getByText('Verify')); + + await waitFor(() => expect(mockExecute).toHaveBeenCalledTimes(1)); + expect(mockExecute).toHaveBeenCalledWith( + expect.objectContaining({ + payload: {flowType: 'SELF', handle: 'email-link-verification', inputs: {attribute: 'email'}}, + }), + ); + }); + + it('re-reads the profile and closes the panel when Done is selected after a link is sent', async () => { + mockExecute.mockResolvedValue({ + data: {additionalData: {maskedDestination: 'a•••@example.com'}, components: []}, + executionId: 'exec-1', + flowStatus: 'INCOMPLETE', + }); + renderUserProfile(); + + fireEvent.click(within(row('Email')).getByText('Verify')); + fireEvent.click(await screen.findByText('Done')); + + expect(mockRevalidateProfile).toHaveBeenCalledTimes(1); + await waitFor(() => expect(screen.queryByText('Done')).toBeNull()); + expect(mockExecute).toHaveBeenCalledTimes(1); + }); + + it('offers no Verify when the profile is not editable', () => { + renderUserProfile({editable: false}); + + expect(within(row('Email')).getByText('Unverified')).toBeTruthy(); + expect(screen.queryByText('Verify')).toBeNull(); + }); +}); diff --git a/packages/react/src/components/account/VerifyAttribute/BaseVerifyAttribute.styles.ts b/packages/react/src/components/account/VerifyAttribute/BaseVerifyAttribute.styles.ts new file mode 100644 index 00000000..f0751e75 --- /dev/null +++ b/packages/react/src/components/account/VerifyAttribute/BaseVerifyAttribute.styles.ts @@ -0,0 +1,91 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {Theme, withVendorCSSClassPrefix} from '@thunderid/browser'; +import {css} from '../../../styles/emotion'; + +/** + * Creates styles for the BaseVerifyAttribute component + * @param theme - The theme object containing design tokens + * @param colorScheme - The current color scheme (used for memoization) + * @returns Object containing CSS class names for component styling + */ +const useStyles = (theme: Theme, colorScheme: string): Record => { + const root: string = css` + display: flex; + flex-direction: column; + gap: calc(${theme.vars.spacing.unit} * 2); + width: 100%; + font-family: ${theme.vars.typography.fontFamily}; + `; + + const card: string = css` + padding: calc(${theme.vars.spacing.unit} * 2); + border: 1px solid ${theme.vars.colors.border}; + border-radius: ${theme.vars.borderRadius.medium}; + `; + + const header: string = css` + display: flex; + flex-direction: column; + gap: calc(${theme.vars.spacing.unit} / 2); + `; + + const heading: string = css` + margin: 0; + `; + + const subheading: string = css` + margin: 0; + color: ${theme.vars.colors.text.secondary}; + `; + + const content: string = css` + display: flex; + flex-direction: column; + gap: calc(${theme.vars.spacing.unit} * 1.5); + + .${withVendorCSSClassPrefix('otp-field__input-container')} { + justify-content: flex-start; + + > input { + box-sizing: border-box; + flex: 1 1 calc(${theme.vars.spacing.unit} * 4); + max-width: calc(${theme.vars.spacing.unit} * 6); + min-width: 0; + } + } + `; + + const alert: string = css` + width: 100%; + `; + + const loading: string = css` + display: flex; + justify-content: center; + padding: calc(${theme.vars.spacing.unit} * 2); + `; + + const actions: string = css` + display: flex; + gap: ${theme.vars.spacing.unit}; + align-items: center; + justify-content: flex-end; + `; + + return { + actions, + alert, + card, + colorScheme, + content, + header, + heading, + loading, + root, + subheading, + }; +}; + +export default useStyles; diff --git a/packages/react/src/components/account/VerifyAttribute/BaseVerifyAttribute.tsx b/packages/react/src/components/account/VerifyAttribute/BaseVerifyAttribute.tsx new file mode 100644 index 00000000..c3d8f5ac --- /dev/null +++ b/packages/react/src/components/account/VerifyAttribute/BaseVerifyAttribute.tsx @@ -0,0 +1,657 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import { + AttributeVerificationConstants, + EmbeddedFlowComponent, + EmbeddedFlowComponentType, + EmbeddedSelfFlowRequest, + EmbeddedSelfFlowResponse, + EmbeddedSelfFlowStatus, + FieldError, + FlowStepComponents, + Preferences, + bem, + buildValidatorFromRules, + getFlowStepComponents, + resolveFlowTemplateLiterals, + withVendorCSSClassPrefix, +} from '@thunderid/browser'; +import { + FC, + MutableRefObject, + ReactElement, + ReactNode, + cloneElement, + isValidElement, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import useStyles from './BaseVerifyAttribute.styles'; +import ComponentRendererContext, { + ComponentRendererMap, +} from '../../../contexts/ComponentRenderer/ComponentRendererContext'; +import ComponentPreferencesContext from '../../../contexts/I18n/ComponentPreferencesContext'; +import useTheme from '../../../contexts/Theme/useTheme'; +import useThunderID from '../../../contexts/ThunderID/useThunderID'; +import {FormField, useForm} from '../../../hooks/useForm'; +import useTranslation from '../../../hooks/useTranslation'; +import {cx} from '../../../styles/emotion'; +import composeAffixedInputs from '../../../utils/composeAffixedInputs'; +import {extractErrorMessage, normalizeFlowResponse} from '../../../utils/flowTransformer'; +import getAuthComponentHeadings from '../../../utils/getAuthComponentHeadings'; +import {renderVerificationComponents} from '../../auth/flow/AuthOptionFactory'; +import AlertPrimitive from '../../primitives/Alert/Alert'; +import Button from '../../primitives/Button/Button'; +import Spinner from '../../primitives/Spinner/Spinner'; +import Typography from '../../primitives/Typography/Typography'; + +type VerificationState = 'loading' | 'active' | 'complete' | 'failed'; + +interface FlowIds { + actions: Set; + // Input name (`ref`, else `id`) to component type. + fields: Map; +} + +// Flow identifiers of the step's inputs and actions. +const collectFlowIds = ({actions, inputs}: FlowStepComponents): FlowIds => ({ + actions: new Set(actions.map((component: EmbeddedFlowComponent) => component.id)), + fields: new Map(inputs.map((component: EmbeddedFlowComponent) => [component.ref ?? component.id, component.type])), +}); + +// Tags the elements rendered from the flow with `thunderid-field-` and `thunderid-action-`. +// The shared factory keys an element by its component id and names an input by its ref. +const withFlowTestIds = (node: ReactNode, ids: FlowIds): ReactNode => { + if (Array.isArray(node)) return node.map((child: ReactNode) => withFlowTestIds(child, ids)); + if (!isValidElement(node)) return node; + + const props: {children?: ReactNode; name?: unknown} = node.props as {children?: ReactNode; name?: unknown}; + if (typeof props.name === 'string' && ids.fields.has(props.name)) { + const testId = `thunderid-field-${props.name}`; + + // The OTP field renders one input per character and takes no test identifier, so wrap it. + if (ids.fields.get(props.name) === EmbeddedFlowComponentType.OtpInput) { + return ( +
+ {node} +
+ ); + } + + return cloneElement(node as ReactElement>, {'data-testid': testId}); + } + + const testId: string | undefined = + node.key !== null && ids.actions.has(String(node.key)) ? `thunderid-action-${String(node.key)}` : undefined; + const children: ReactNode = props.children === undefined ? undefined : withFlowTestIds(props.children, ids); + if (!testId && children === props.children) return node; + + return cloneElement(node as ReactElement>, { + ...(testId && {'data-testid': testId}), + ...(props.children !== undefined && {children}), + }); +}; + +/** + * What {@link BaseVerifyAttribute} hands its `children` render function, so the steps can be drawn + * with any UI. + */ +export interface BaseVerifyAttributeRenderProps { + additionalData: Record; + /** + * The step's components, without its headings. + */ + components: EmbeddedFlowComponent[]; + /** + * Masked destination the code or link was sent to, when the flow reports it. + */ + destination: string | null; + /** + * Translated "Sent to …" line, set when the flow has its own prompt in `subtitle`. + */ + destinationText: string | null; + /** + * Translated error of the last request, or the refusal. + */ + error: string | null; + fieldErrors: Record; + handleInputChange: (name: string, value: string) => void; + /** + * Sends a new code. Set only while the step has a resend action. + */ + handleResend?: () => Promise; + /** + * Submits a step. Pass `skipValidation` for an action, such as a resend, that sends no inputs. + */ + handleSubmit: ( + component: EmbeddedFlowComponent, + data?: Record, + skipValidation?: boolean, + ) => Promise; + isAwaitingLink: boolean; + isComplete: boolean; + /** + * Whether the server refused the verification or the request failed; the flow cannot continue. + */ + isFailed: boolean; + isLoading: boolean; + isValid: boolean; + /** + * Closes the panel. Set only when the app passes `onCancel`. + */ + onCancel?: () => void; + /** + * Confirms a link the user opened outside the app. Set only while the step waits for a link and + * the app passes `onDone`. + */ + onDone?: () => void; + subtitle: string; + /** + * Translated message for a recorded verification. + */ + successMessage: string; + title: string; + touched: Record; + values: Record; +} + +/** + * Props for the BaseVerifyAttribute component. + */ +export interface BaseVerifyAttributeProps { + /** + * Label of the attribute being verified, used in the default heading and success message. + */ + attributeDisplayName?: string; + buttonClassName?: string; + /** + * Renders custom UI in place of the default steps. Receives the step, the values and their setters, + * submit, resend, cancel, the destination, and the loading, error and completed state. + */ + children?: (props: BaseVerifyAttributeRenderProps) => ReactNode; + className?: string; + inputClassName?: string; + isInitialized?: boolean; + /** + * Called when the user closes the panel. The close action is shown only when this is set. + */ + onCancel?: () => void; + /** + * Called once the server has recorded the verification. + */ + onComplete?: (response: EmbeddedSelfFlowResponse) => void; + /** + * Called when the user selects Done after the flow sent a link, which they confirm outside the app. + * The Done action is shown only when this is set. + */ + onDone?: () => void; + /** + * Called when the server refuses the verification or the request fails. + */ + onError?: (error: Error) => void; + onFlowChange?: (response: EmbeddedSelfFlowResponse) => void; + onInitialize?: () => Promise; + onSubmit?: (payload: EmbeddedSelfFlowRequest) => Promise; + /** + * Component-level preferences to override global i18n and theme settings. + */ + preferences?: Preferences; + showSubtitle?: boolean; + showTitle?: boolean; + size?: 'small' | 'medium' | 'large'; + variant?: 'outlined' | 'flat'; +} + +/** + * Internal component that renders the verification steps and manages flow state. + * + * @internal + */ +const BaseVerifyAttributeContent: FC> = ({ + attributeDisplayName = '', + buttonClassName = '', + children = undefined, + className = '', + inputClassName = '', + isInitialized = true, + onCancel = undefined, + onComplete = undefined, + onDone = undefined, + onError = undefined, + onFlowChange = undefined, + onInitialize = undefined, + onSubmit = undefined, + showSubtitle = true, + showTitle = true, + size = 'medium', + variant = 'outlined', +}: Omit): ReactElement => { + const {theme, colorScheme} = useTheme(); + const customRenderers: ComponentRendererMap = useContext(ComponentRendererContext); + const {t} = useTranslation(); + const {meta, vendor} = useThunderID(); + const styles: Record = useStyles(theme, colorScheme); + + const [state, setState] = useState('loading'); + const [isSubmitting, setIsSubmitting] = useState(false); + const [currentFlow, setCurrentFlow] = useState(null); + const [error, setError] = useState(null); + // Set before the first await, so a second call from a custom render cannot send a second code. + const isSubmittingRef: MutableRefObject = useRef(false); + + // Guards against a second initiation, which would send a second code. + const initializationAttemptedRef: MutableRefObject = useRef(false); + const challengeTokenRef: MutableRefObject = useRef(null); + + const labelParams: Record = {attribute: attributeDisplayName}; + + const normalize = useCallback( + (response: EmbeddedSelfFlowResponse): EmbeddedSelfFlowResponse => { + if (!response?.data || Array.isArray(response.data.components)) { + return response; + } + + const {components} = normalizeFlowResponse( + response, + t, + {defaultErrorKey: 'user.verification.error', resolveTranslations: false, throwOnError: false}, + meta, + ); + + return {...response, data: {...response.data, components}}; + }, + [t, meta], + ); + + const extractFormFields = useCallback( + (components: EmbeddedFlowComponent[]): FormField[] => + getFlowStepComponents(components).inputs.map((component: EmbeddedFlowComponent) => { + const ruleValidator = buildValidatorFromRules(component.validation); + return { + initialValue: '', + name: component.ref ?? component.id, + required: component.required ?? false, + validator: (value: string) => { + if (component.required && (!value || value.trim() === '')) { + return t('validations.required.field.error'); + } + if (ruleValidator && value) { + const ruleMessage = ruleValidator(value); + if (ruleMessage) { + return t(ruleMessage); + } + } + return null; + }, + }; + }), + [t], + ); + + const formFields: FormField[] = useMemo( + () => extractFormFields(currentFlow?.data?.components ?? []), + [currentFlow, extractFormFields], + ); + + const { + values: formValues, + touched: touchedFields, + errors: formErrors, + isValid: isFormValid, + setValue: setFormValue, + setTouched: setFormTouched, + setTouchedFields, + setErrors: setFormErrors, + clearErrors: clearFormErrors, + validateForm, + touchAllFields, + reset: resetForm, + } = useForm>({ + fields: formFields, + initialValues: {}, + requiredMessage: t('validations.required.field.error'), + validateOnBlur: true, + validateOnChange: false, + }); + + // Project server-side field errors into form state. + useEffect(() => { + clearFormErrors(); + const responseFieldErrors: FieldError[] | undefined = currentFlow?.data?.fieldErrors; + if (!responseFieldErrors || responseFieldErrors.length === 0) { + return; + } + const errors: Record = {}; + const touched: Record = {}; + responseFieldErrors.forEach((fe: FieldError) => { + if (!(fe.identifier in errors)) { + errors[fe.identifier] = fe.message; + touched[fe.identifier] = true; + } + }); + setTouchedFields(touched); + setFormErrors(errors); + }, [currentFlow, setFormErrors, setTouchedFields, clearFormErrors]); + + const handleResponse = useCallback( + (rawResponse: EmbeddedSelfFlowResponse): void => { + const response: EmbeddedSelfFlowResponse = normalize(rawResponse); + onFlowChange?.(response); + + if (response.challengeToken !== undefined) { + challengeTokenRef.current = response.challengeToken ?? null; + } + + if (response.flowStatus === EmbeddedSelfFlowStatus.Error) { + const message: string = extractErrorMessage(response, t, 'user.verification.error'); + setError(message); + setState('failed'); + onError?.(new Error(message)); + return; + } + + if (response.flowStatus === EmbeddedSelfFlowStatus.Complete) { + setError(null); + setState('complete'); + onComplete?.(response); + return; + } + + // INCOMPLETE: render the step; a rejected code comes back with an error and without + // additionalData, so keep the destination and code length of the step that sent the code. + setCurrentFlow( + (previous: EmbeddedSelfFlowResponse | null): EmbeddedSelfFlowResponse => + response.data?.additionalData || !previous?.data?.additionalData + ? response + : {...response, data: {...response.data, additionalData: previous.data.additionalData}}, + ); + resetForm(); + setError(response.error ? extractErrorMessage(response, t, 'user.verification.error') : null); + setState('active'); + }, + [normalize, onFlowChange, onError, onComplete, resetForm, t], + ); + + const handleFailure = useCallback( + (caughtError: unknown): void => { + // Show a generic message; an API error body is not meant for the user. + setError(t('user.verification.error')); + setState('failed'); + onError?.(caughtError instanceof Error ? caughtError : new Error(String(caughtError))); + }, + [onError, t], + ); + + useEffect(() => { + if (!isInitialized || initializationAttemptedRef.current) { + return; + } + initializationAttemptedRef.current = true; + + void (async (): Promise => { + try { + const response: EmbeddedSelfFlowResponse | undefined = await onInitialize?.(); + if (response) { + handleResponse(response); + } + } catch (caughtError) { + handleFailure(caughtError); + } + })(); + }, [isInitialized, onInitialize, handleResponse, handleFailure]); + + const handleInputChange = (name: string, value: string): void => { + setFormValue(name, value); + }; + + const handleInputBlur = (name: string): void => { + setFormTouched(name, true); + }; + + const handleSubmit = async ( + component: EmbeddedFlowComponent, + data?: Record, + skipValidation?: boolean, + ): Promise => { + if (!currentFlow || isSubmittingRef.current) return; + + if (!skipValidation) { + touchAllFields(); + if (!validateForm().isValid) return; + } + + isSubmittingRef.current = true; + setIsSubmitting(true); + setError(null); + + try { + const composedData: Record = data ? composeAffixedInputs(data, vendor) : {}; + const inputs: Record = Object.fromEntries( + Object.entries(composedData).filter( + ([, value]: [string, string]) => value !== null && value !== undefined && value !== '', + ), + ); + + const response: EmbeddedSelfFlowResponse | undefined = await onSubmit?.({ + executionId: currentFlow.executionId, + ...(component.id && {action: component.id}), + ...(challengeTokenRef.current ? {challengeToken: challengeTokenRef.current} : {}), + inputs, + }); + + if (response) { + handleResponse(response); + } + } catch (caughtError) { + handleFailure(caughtError); + } finally { + isSubmittingRef.current = false; + setIsSubmitting(false); + } + }; + + const additionalData: Record = currentFlow?.data?.additionalData ?? {}; + const maskedDestination: unknown = additionalData[AttributeVerificationConstants.MASKED_DESTINATION_KEY]; + const destination: string | null = + typeof maskedDestination === 'string' && maskedDestination ? maskedDestination : null; + const headings: ReturnType = getAuthComponentHeadings( + currentFlow?.data?.components ?? [], + undefined, + undefined, + t('user.verification.heading', labelParams), + t('user.verification.subheading.generic'), + ); + const {componentsWithoutHeadings} = headings; + const step: FlowStepComponents = getFlowStepComponents(componentsWithoutHeadings ?? []); + // A step with nothing to enter or submit waits for the user to open a link sent to them. + const isAwaitingLink: boolean = state === 'active' && formFields.length === 0 && step.actions.length === 0; + const title: string = resolveFlowTemplateLiterals(headings.title, {meta, t}); + // The flow's own prompt wins; the destination is added beside it, or fills in when the flow has none. + const hasFlowSubtitle = !!headings.headingComponents.subheading; + let subtitle: string = resolveFlowTemplateLiterals(headings.subtitle, {meta, t}); + let destinationText: string | null = null; + if (destination && hasFlowSubtitle) { + destinationText = t('user.verification.destination', {destination}); + } else if (destination) { + subtitle = t(isAwaitingLink ? 'user.verification.subheading.link' : 'user.verification.subheading', {destination}); + } + + const isLoading: boolean = state === 'loading' || isSubmitting; + + const containerClasses: string = cx( + withVendorCSSClassPrefix('verify-attribute'), + withVendorCSSClassPrefix(`verify-attribute--${size}`), + withVendorCSSClassPrefix(`verify-attribute--${variant}`), + styles['root'], + variant === 'outlined' ? styles['card'] : '', + className, + ); + + if (children) { + const resend: EmbeddedFlowComponent | undefined = state === 'active' ? step.resend : undefined; + + return ( +
+ {children({ + additionalData, + components: componentsWithoutHeadings ?? [], + destination, + destinationText, + error, + fieldErrors: formErrors, + handleInputChange, + handleResend: resend ? (): Promise => handleSubmit(resend, undefined, true) : undefined, + handleSubmit, + isAwaitingLink, + isComplete: state === 'complete', + isFailed: state === 'failed', + isLoading, + isValid: isFormValid, + onCancel, + onDone: isAwaitingLink ? onDone : undefined, + subtitle, + successMessage: t('user.verification.success', labelParams), + title, + touched: touchedFields, + values: formValues, + })} +
+ ); + } + + const alertClasses: string = cx(withVendorCSSClassPrefix(bem('verify-attribute', 'alert')), styles['alert']); + + return ( +
+ {state === 'active' && (showTitle || showSubtitle) && ( +
+ {showTitle && ( + + {title} + + )} + {showSubtitle && ( + + {subtitle} + {destinationText && ( + + {` ${destinationText}`} + + )} + + )} +
+ )} + + {state === 'loading' && ( +
+ +
+ )} + + {error && ( + + {error} + + )} + + {state === 'complete' && ( + + {t('user.verification.success', labelParams)} + + )} + + {state === 'active' && ( +
+ {withFlowTestIds( + renderVerificationComponents( + componentsWithoutHeadings ?? [], + formValues, + touchedFields, + formErrors, + isLoading, + isFormValid, + resetForm, + handleInputChange, + { + _customRenderers: customRenderers, + _theme: theme, + additionalData, + buttonClassName: cx(withVendorCSSClassPrefix(bem('verify-attribute', 'button')), buttonClassName), + inputClassName: cx(withVendorCSSClassPrefix(bem('verify-attribute', 'input')), inputClassName), + meta, + onInputBlur: handleInputBlur, + onSubmit: (component: EmbeddedFlowComponent, data?: Record, skip?: boolean): void => { + void handleSubmit(component, data, skip); + }, + size, + t, + variant, + vendor, + }, + ), + collectFlowIds(step), + )} +
+ )} + + {(!!onCancel || (isAwaitingLink && !!onDone)) && ( +
+ {isAwaitingLink && onDone && ( + + )} + {onCancel && ( + + )} +
+ )} +
+ ); +}; + +/** + * BaseVerifyAttribute renders the server-described steps of a self attribute verification flow. + * Accepts API functions as props to stay framework independent. + */ +const BaseVerifyAttribute: FC = ({ + preferences = undefined, + ...rest +}: BaseVerifyAttributeProps): ReactElement => { + const content: ReactElement = ; + + if (!preferences) return content; + + return {content}; +}; + +export default BaseVerifyAttribute; diff --git a/packages/react/src/components/account/VerifyAttribute/VerifyAttribute.tsx b/packages/react/src/components/account/VerifyAttribute/VerifyAttribute.tsx new file mode 100644 index 00000000..80985fce --- /dev/null +++ b/packages/react/src/components/account/VerifyAttribute/VerifyAttribute.tsx @@ -0,0 +1,142 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import { + AttributeVerificationConstants, + EmbeddedFlowType, + EmbeddedSelfFlowRequest, + EmbeddedSelfFlowResponse, + ThunderIDRuntimeError, + createPackageComponentLogger, + isVerifiableAttribute, + resolveResourceEndpoint, + startCase, +} from '@thunderid/browser'; +import {FC, ReactElement, useCallback} from 'react'; +import BaseVerifyAttribute, {BaseVerifyAttributeProps} from './BaseVerifyAttribute'; +import executeEmbeddedSelfFlow from '../../../api/auth/executeEmbeddedSelfFlow'; +import useThunderID from '../../../contexts/ThunderID/useThunderID'; +import useUser from '../../../contexts/User/useUser'; + +const logger: ReturnType = createPackageComponentLogger( + '@thunderid/react', + 'VerifyAttribute', +); + +export type VerifyAttributeProps = Omit & { + /** + * Name of the signed-in user's attribute to verify, such as `email`. Only a top-level, single-valued + * `email` or `mobile` property of the user's schema can be verified; any other is refused without a + * request, through `onError`, with `VerifyAttribute-handleInitialize-RuntimeError-001`. + */ + attribute: string; + /** + * Handle of the `SELF` flow to run. Defaults to the server's shipped + * `default-self-attribute-verification-flow`. + */ + flowHandle?: string; +}; + +/** + * VerifyAttribute verifies one of the signed-in user's attributes, such as their email address, + * by running a self attribute verification flow, named by `flowHandle`. The flow picks the email or + * mobile path from the attribute's type, sends a code or a link to the attribute's current value, + * and the component renders the steps it describes; for a link it shows a Done action that calls + * `onDone`. Every request carries the user's access token. Once the server records the verification, + * and when the user selects Done, it re-reads the user's profile so `useUser()` shows the new status. + * + * It waits for the user schema to load, and refuses an attribute the schema does not declare as an + * `email` or `mobile` property, since starting a verification sends a message. + * + * Mount it with a `key` per attribute: each mount starts a new verification and sends a new code + * or link. + * + * @example + * ```tsx + * setOpen(false)} + * onCancel={() => setOpen(false)} + * /> + * ``` + */ +const VerifyAttribute: FC = ({ + attribute, + attributeDisplayName, + flowHandle = AttributeVerificationConstants.DEFAULT_FLOW_HANDLE, + onComplete, + onDone, + ...rest +}: VerifyAttributeProps): ReactElement => { + const {baseUrl, endpoints, instanceId, isInitialized, isLoading} = useThunderID(); + const {revalidateProfile, userSchema} = useUser(); + + const flowExecuteUrl: string = resolveResourceEndpoint('flowExecute', {endpoints}) ?? `${baseUrl}/flow/execute`; + + // Re-reads the profile so the recorded verification shows; a failure leaves it stale, not broken. + const refreshProfile = useCallback(async (): Promise => { + try { + await revalidateProfile?.(); + } catch (caughtError: unknown) { + logger.warn('Failed to re-read the user profile after verification:', caughtError); + } + }, [revalidateProfile]); + + const handleComplete = useCallback( + (response: EmbeddedSelfFlowResponse): void => { + void refreshProfile(); + onComplete?.(response); + }, + [refreshProfile, onComplete], + ); + + const handleDone = useCallback((): void => { + void refreshProfile(); + onDone?.(); + }, [refreshProfile, onDone]); + + const execute = useCallback( + (payload: EmbeddedSelfFlowRequest): Promise => + executeEmbeddedSelfFlow({ + baseUrl, + instanceId, + payload, + url: flowExecuteUrl, + }), + [baseUrl, flowExecuteUrl, instanceId], + ); + + const handleInitialize = useCallback((): Promise => { + if (!isVerifiableAttribute(userSchema, attribute)) { + return Promise.reject( + new ThunderIDRuntimeError( + `Attribute "${attribute}" is not a verifiable email or mobile attribute.`, + 'VerifyAttribute-handleInitialize-RuntimeError-001', + 'react', + 'Only a top-level, single-valued email or mobile attribute of the user schema can be verified.', + ), + ); + } + + return execute({ + flowType: EmbeddedFlowType.Self, + handle: flowHandle, + inputs: {[AttributeVerificationConstants.ATTRIBUTE_INPUT]: attribute}, + }); + }, [execute, attribute, flowHandle, userSchema]); + + return ( + + ); +}; + +export default VerifyAttribute; diff --git a/packages/react/src/components/account/VerifyAttribute/__tests__/BaseVerifyAttribute.test.tsx b/packages/react/src/components/account/VerifyAttribute/__tests__/BaseVerifyAttribute.test.tsx new file mode 100644 index 00000000..faed6476 --- /dev/null +++ b/packages/react/src/components/account/VerifyAttribute/__tests__/BaseVerifyAttribute.test.tsx @@ -0,0 +1,627 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {act, cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react'; +import {EmbeddedFlowComponent, EmbeddedSelfFlowResponse, EmbeddedSelfFlowStatus, I18nBundle} from '@thunderid/browser'; +import {ReactElement, StrictMode} from 'react'; +import {afterEach, beforeEach, describe, expect, it, Mock, vi} from 'vitest'; +import I18nProvider from '../../../../contexts/I18n/I18nProvider'; +import ThemeProvider from '../../../../contexts/Theme/ThemeProvider'; +import ThunderIDContext, {ThunderIDContextProps} from '../../../../contexts/ThunderID/ThunderIDContext'; +import BaseVerifyAttribute, {BaseVerifyAttributeProps, BaseVerifyAttributeRenderProps} from '../BaseVerifyAttribute'; + +const thunderIDContext: ThunderIDContextProps = { + baseUrl: 'https://localhost:8090', + instanceId: 0, + isInitialized: true, + isLoading: false, + meta: null, + vendor: 'thunderid', +} as unknown as ThunderIDContextProps; + +// The shipped email flow's code prompt (`prompt_otp`), as the server returns it. +const codePrompt: EmbeddedFlowComponent[] = [ + { + align: 'left', + id: 'prompt_otp_heading', + label: '{{ t(verification:self.email.title) }}', + type: 'TEXT', + variant: 'HEADING_1', + }, + { + align: 'left', + id: 'prompt_otp_message', + label: '{{ t(verification:self.email.message) }}', + type: 'TEXT', + variant: 'HEADING_6', + }, + { + components: [ + { + id: 'input_otp', + label: '{{ t(verification:self.email.code.label) }}', + ref: 'otp', + required: true, + type: 'OTP_INPUT', + }, + { + eventType: 'SUBMIT', + id: 'action_verify', + label: '{{ t(verification:self.email.verify) }}', + type: 'ACTION', + variant: 'PRIMARY', + }, + {eventType: 'SUBMIT', id: 'action_resend', label: '{{ t(verification:self.email.resend) }}', type: 'RESEND'}, + ], + id: 'block_otp', + type: 'BLOCK', + }, +] as unknown as EmbeddedFlowComponent[]; + +const codePromptData = { + actions: [ + {nextNode: 'verify_otp', ref: 'action_verify'}, + {nextNode: 'prepare_verification', ref: 'action_resend'}, + ], + inputs: [{identifier: 'otp', ref: 'input_otp', required: true, type: 'OTP_INPUT'}], + meta: {components: codePrompt}, +}; + +// The server's verification translations, which flow metadata adds to the SDK's bundles. +const flowTranslations: I18nBundle = { + translations: { + verification: { + 'self.email.code.label': 'Verification code', + 'self.email.message': 'Enter the verification code sent to your email address.', + 'self.email.resend': 'Resend code', + 'self.email.title': 'Verify your email address', + 'self.email.verify': 'Verify', + }, + }, +} as unknown as I18nBundle; + +const codeSent = (destination = 'a•••@example.com', challengeToken = 'ct-1'): EmbeddedSelfFlowResponse => ({ + challengeToken, + data: { + ...codePromptData, + additionalData: {emailSent: 'true', maskedDestination: destination}, + }, + executionId: 'exec-1', + flowStatus: EmbeddedSelfFlowStatus.Incomplete, +}); + +// A link flow's step names where the link went and asks for nothing. +const linkSent: EmbeddedSelfFlowResponse = { + challengeToken: 'ct-1', + data: { + additionalData: {emailSent: 'true', maskedDestination: 'a•••@example.com'}, + components: [ + {id: 'link_sent_heading', label: 'Check your email', type: 'TEXT', variant: 'HEADING_1'}, + {id: 'link_sent_message', label: 'Open the link we sent you.', type: 'TEXT', variant: 'HEADING_6'}, + ], + }, + executionId: 'exec-1', + flowStatus: EmbeddedSelfFlowStatus.Incomplete, +} as unknown as EmbeddedSelfFlowResponse; + +const codeRejected: EmbeddedSelfFlowResponse = { + challengeToken: 'ct-2', + data: codePromptData, + error: {message: {defaultValue: 'The code is incorrect.', key: 'otp.invalid'}}, + executionId: 'exec-1', + flowStatus: EmbeddedSelfFlowStatus.Incomplete, +} as unknown as EmbeddedSelfFlowResponse; + +const verified: EmbeddedSelfFlowResponse = { + executionId: 'exec-1', + flowStatus: EmbeddedSelfFlowStatus.Complete, +}; + +const refused: EmbeddedSelfFlowResponse = { + error: {message: {defaultValue: 'Verification is not allowed.', key: 'verification.refused'}}, + executionId: 'exec-1', + flowStatus: EmbeddedSelfFlowStatus.Error, +} as unknown as EmbeddedSelfFlowResponse; + +const withProviders = (ui: ReactElement): ReactElement => ( + + + {ui} + + +); + +const renderVerify = (props: Partial = {}): ReturnType => + render(withProviders()); + +const enterCode = (code: string): void => { + const event: Event = new Event('paste', {bubbles: true, cancelable: true}); + Object.defineProperty(event, 'clipboardData', {value: {getData: () => code}}); + fireEvent(screen.getAllByRole('textbox')[0], event); +}; + +describe('BaseVerifyAttribute', () => { + let onInitialize: Mock; + let onSubmit: Mock; + + beforeEach(() => { + onInitialize = vi.fn().mockResolvedValue(codeSent()); + onSubmit = vi.fn(); + }); + + afterEach(() => { + cleanup(); + }); + + it('shows a spinner and no prompt while the code is being sent', () => { + onInitialize.mockReturnValue(new Promise(() => undefined)); + + const {container} = renderVerify({onInitialize, onSubmit}); + + expect(container.querySelector('[aria-busy="true"]')).not.toBeNull(); + expect(screen.getByRole('status', {name: 'Loading'})).toBeTruthy(); + expect(screen.queryByRole('textbox')).toBeNull(); + }); + + it('starts the verification once per mount', async () => { + render( + {withProviders()}, + ); + + await screen.findByRole('button', {name: 'Verify'}); + expect(onInitialize).toHaveBeenCalledTimes(1); + }); + + it('waits for the SDK to initialize before starting', async () => { + const {rerender} = renderVerify({isInitialized: false, onInitialize, onSubmit}); + + expect(onInitialize).not.toHaveBeenCalled(); + + rerender(withProviders()); + + await waitFor(() => expect(onInitialize).toHaveBeenCalledTimes(1)); + }); + + it('prompts for the code and names where it was sent', async () => { + renderVerify({onInitialize, onSubmit}); + + expect(await screen.findByText('Verify your email address')).toBeTruthy(); + expect(screen.getByText('Enter the verification code sent to your email address.')).toBeTruthy(); + expect(screen.getByText('Sent to a•••@example.com.')).toBeTruthy(); + expect(screen.getAllByRole('textbox')).toHaveLength(6); + expect(screen.getByRole('button', {name: 'Resend code'})).toBeTruthy(); + }); + + it('submits the code to the same execution with its challenge token', async () => { + onSubmit.mockResolvedValue(verified); + renderVerify({onInitialize, onSubmit}); + await screen.findByRole('button', {name: 'Verify'}); + + enterCode('123456'); + fireEvent.click(screen.getByRole('button', {name: 'Verify'})); + + await waitFor(() => + expect(onSubmit).toHaveBeenCalledWith({ + action: 'action_verify', + challengeToken: 'ct-1', + executionId: 'exec-1', + inputs: {otp: '123456'}, + }), + ); + }); + + it('does not submit without a code', async () => { + renderVerify({onInitialize, onSubmit}); + await screen.findByRole('button', {name: 'Verify'}); + + fireEvent.click(screen.getByRole('button', {name: 'Verify'})); + + expect(onSubmit).not.toHaveBeenCalled(); + }); + + it('confirms a recorded verification and reports it', async () => { + const onComplete = vi.fn(); + onSubmit.mockResolvedValue(verified); + renderVerify({onCancel: vi.fn(), onComplete, onInitialize, onSubmit}); + await screen.findByRole('button', {name: 'Verify'}); + + enterCode('123456'); + fireEvent.click(screen.getByRole('button', {name: 'Verify'})); + + expect(await screen.findByText('Email verified.')).toBeTruthy(); + expect(onComplete).toHaveBeenCalledWith(verified); + expect(screen.queryByRole('textbox')).toBeNull(); + expect(screen.getByText('Close')).toBeTruthy(); + }); + + it('keeps the prompt and the destination when a code is rejected', async () => { + const onComplete = vi.fn(); + onSubmit.mockResolvedValue(codeRejected); + renderVerify({onComplete, onInitialize, onSubmit}); + await screen.findByRole('button', {name: 'Verify'}); + + enterCode('000000'); + fireEvent.click(screen.getByRole('button', {name: 'Verify'})); + + expect(await screen.findByText('The code is incorrect.')).toBeTruthy(); + expect(screen.getByText('Sent to a•••@example.com.')).toBeTruthy(); + expect(screen.getAllByRole('textbox')).toHaveLength(6); + expect(onComplete).not.toHaveBeenCalled(); + }); + + it('sends the next code attempt with the token of the rejected step', async () => { + onSubmit.mockResolvedValueOnce(codeRejected).mockResolvedValueOnce(verified); + renderVerify({onInitialize, onSubmit}); + await screen.findByRole('button', {name: 'Verify'}); + + enterCode('000000'); + fireEvent.click(screen.getByRole('button', {name: 'Verify'})); + await screen.findByText('The code is incorrect.'); + enterCode('123456'); + fireEvent.click(screen.getByRole('button', {name: 'Verify'})); + + await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(2)); + expect(onSubmit.mock.calls[1][0]).toMatchObject({challengeToken: 'ct-2', inputs: {otp: '123456'}}); + }); + + it('resends without asking for a code and shows the new destination', async () => { + onSubmit.mockResolvedValue(codeSent('b•••@example.com', 'ct-3')); + renderVerify({onInitialize, onSubmit}); + + fireEvent.click(await screen.findByRole('button', {name: 'Resend code'})); + + await waitFor(() => + expect(onSubmit).toHaveBeenCalledWith({ + action: 'action_resend', + challengeToken: 'ct-1', + executionId: 'exec-1', + inputs: {}, + }), + ); + expect(await screen.findByText('Sent to b•••@example.com.')).toBeTruthy(); + }); + + it('leaves a partly typed code out of a resend', async () => { + onSubmit.mockResolvedValue(codeSent('a•••@example.com', 'ct-3')); + renderVerify({onInitialize, onSubmit}); + await screen.findByRole('button', {name: 'Resend code'}); + + enterCode('12'); + fireEvent.click(screen.getByRole('button', {name: 'Resend code'})); + + await waitFor(() => + expect(onSubmit).toHaveBeenCalledWith({ + action: 'action_resend', + challengeToken: 'ct-1', + executionId: 'exec-1', + inputs: {}, + }), + ); + }); + + it('shows a refusal, reports it and stops prompting', async () => { + const onError = vi.fn(); + onSubmit.mockResolvedValue(refused); + renderVerify({onCancel: vi.fn(), onError, onInitialize, onSubmit}); + await screen.findByRole('button', {name: 'Verify'}); + + enterCode('123456'); + fireEvent.click(screen.getByRole('button', {name: 'Verify'})); + + expect(await screen.findByText('Verification is not allowed.')).toBeTruthy(); + expect(onError).toHaveBeenCalledWith(expect.objectContaining({message: 'Verification is not allowed.'})); + expect(screen.queryByRole('textbox')).toBeNull(); + expect(screen.getByText('Close')).toBeTruthy(); + }); + + it('shows a generic message, not the response body, when the request fails', async () => { + const onError = vi.fn(); + const failure = new Error('{"code":"FLW-1001","description":"internal detail"}'); + onInitialize.mockRejectedValue(failure); + renderVerify({onError, onInitialize, onSubmit}); + + expect(await screen.findByText('Verification failed. Please try again.')).toBeTruthy(); + expect(screen.queryByText(/internal detail/)).toBeNull(); + expect(onError).toHaveBeenCalledWith(failure); + }); + + it('offers Cancel while verifying when the panel can be dismissed', async () => { + const onCancel = vi.fn(); + renderVerify({onCancel, onInitialize, onSubmit}); + await screen.findByRole('button', {name: 'Verify'}); + + fireEvent.click(screen.getByText('Cancel')); + + expect(onCancel).toHaveBeenCalledTimes(1); + }); + + it('tells the user to open the link and reports Done for a link flow', async () => { + const onDone = vi.fn(); + onInitialize.mockResolvedValue(linkSent); + renderVerify({onDone, onInitialize, onSubmit}); + + await screen.findByText('Open the link we sent you.'); + expect(screen.getByText('Sent to a•••@example.com.')).toBeTruthy(); + expect(screen.queryAllByRole('textbox')).toHaveLength(0); + fireEvent.click(screen.getByText('Done')); + + expect(onDone).toHaveBeenCalledTimes(1); + expect(onSubmit).not.toHaveBeenCalled(); + }); + + it('offers Done only for a link flow', async () => { + renderVerify({onDone: vi.fn(), onInitialize, onSubmit}); + await screen.findByRole('button', {name: 'Verify'}); + + expect(screen.queryByText('Done')).toBeNull(); + }); + + it('names the destination in its own words when the flow gives no prompt text', async () => { + onInitialize.mockResolvedValue({ + ...codeSent(), + data: { + ...codeSent().data, + components: codePrompt.filter((c: EmbeddedFlowComponent) => c.variant !== 'HEADING_6'), + }, + } as unknown as EmbeddedSelfFlowResponse); + renderVerify({onInitialize, onSubmit}); + + expect(await screen.findByText('Enter the code sent to a•••@example.com.')).toBeTruthy(); + expect(screen.queryByText('Sent to a•••@example.com.')).toBeNull(); + }); + + it('tells the user to open the link when a link flow gives no prompt text', async () => { + onInitialize.mockResolvedValue({ + ...linkSent, + data: {...linkSent.data, components: [linkSent.data!.components![0]]}, + } as unknown as EmbeddedSelfFlowResponse); + renderVerify({onDone: vi.fn(), onInitialize, onSubmit}); + + expect( + await screen.findByText('We sent a verification link to a•••@example.com. Open it, then select Done.'), + ).toBeTruthy(); + }); + + it('lets a custom renderer resend without validating the code', async () => { + onSubmit.mockResolvedValue(codeSent('a•••@example.com', 'ct-3')); + renderVerify({ + children: ({components, destination, handleSubmit}) => { + const resend: EmbeddedFlowComponent | undefined = components[0]?.components?.find( + (component: EmbeddedFlowComponent) => component.type === 'RESEND', + ); + if (!resend) return null; + return ( + + ); + }, + onInitialize, + onSubmit, + }); + + fireEvent.click(await screen.findByText('Resend to a•••@example.com')); + + await waitFor(() => + expect(onSubmit).toHaveBeenCalledWith({ + action: 'action_resend', + challengeToken: 'ct-1', + executionId: 'exec-1', + inputs: {}, + }), + ); + }); + + it('treats a step with an action and no input as a step to submit, not a link', async () => { + onInitialize.mockResolvedValue({ + ...linkSent, + data: { + ...linkSent.data, + components: [{eventType: 'SUBMIT', id: 'action_confirm', label: 'Confirm', type: 'ACTION', variant: 'PRIMARY'}], + }, + } as unknown as EmbeddedSelfFlowResponse); + renderVerify({onDone: vi.fn(), onInitialize, onSubmit}); + await screen.findByText('Confirm'); + + expect(screen.queryByText('Done')).toBeNull(); + }); + + it('tags the inputs and actions rendered from the flow, and its own actions, with flow identifiers', async () => { + const {container} = renderVerify({onCancel: vi.fn(), onInitialize, onSubmit}); + const verify: HTMLElement = await screen.findByRole('button', {name: 'Verify'}); + + expect(verify.getAttribute('data-testid')).toBe('thunderid-action-action_verify'); + expect(screen.getByRole('button', {name: 'Resend code'}).getAttribute('data-testid')).toBe( + 'thunderid-action-action_resend', + ); + expect(screen.getByRole('button', {name: 'Cancel'}).getAttribute('data-testid')).toBe( + 'thunderid-action-verificationCancel', + ); + expect(container.querySelector('[data-testid="thunderid-field-otp"]')).not.toBeNull(); + expect(container.querySelector('[data-testid^="thunderid-signin-"]')).toBeNull(); + }); + + it('tags Done for a link flow', async () => { + onInitialize.mockResolvedValue(linkSent); + renderVerify({onDone: vi.fn(), onInitialize, onSubmit}); + + expect((await screen.findByRole('button', {name: 'Done'})).getAttribute('data-testid')).toBe( + 'thunderid-action-verificationDone', + ); + }); + + it('offers no dismiss action when none is handled', async () => { + renderVerify({onInitialize, onSubmit}); + await screen.findByRole('button', {name: 'Verify'}); + + expect(screen.queryByText('Cancel')).toBeNull(); + expect(screen.queryByText('Close')).toBeNull(); + }); + + describe('custom render', () => { + // Draws the step with plain elements and keeps the last render props for assertions. + const renderCustom = ( + props: Partial = {}, + ): {latest: () => BaseVerifyAttributeRenderProps} => { + let latest: BaseVerifyAttributeRenderProps | undefined; + renderVerify({ + children: (renderProps: BaseVerifyAttributeRenderProps) => { + latest = renderProps; + const verify: EmbeddedFlowComponent | undefined = renderProps.components[0]?.components?.find( + (component: EmbeddedFlowComponent) => component.type === 'ACTION', + ); + return ( +
+

{renderProps.title}

+

{renderProps.subtitle}

+ {renderProps.destinationText &&

{renderProps.destinationText}

} + {renderProps.error &&

{renderProps.error}

} + {renderProps.isComplete &&

{renderProps.successMessage}

} + renderProps.handleInputChange('otp', event.target.value)} + /> + {verify && ( + + )} + {renderProps.handleResend && ( + + )} + {renderProps.onCancel && ( + + )} + {renderProps.onDone && ( + + )} +
+ ); + }, + onInitialize, + onSubmit, + ...props, + }); + return {latest: () => latest!}; + }; + + it('replaces the built-in steps and hands over the step, headings and destination', async () => { + renderCustom(); + + expect(await screen.findByText('Verify your email address')).toBeTruthy(); + expect(screen.getByText('Enter the verification code sent to your email address.')).toBeTruthy(); + expect(screen.getByText('Sent to a•••@example.com.')).toBeTruthy(); + expect(screen.queryByRole('button', {name: 'Verify'})).toBeNull(); + expect(screen.queryAllByRole('textbox')).toHaveLength(1); + }); + + it('submits the values the custom UI set, with the challenge token', async () => { + onSubmit.mockResolvedValue(verified); + const onComplete: Mock = vi.fn(); + renderCustom({onComplete}); + fireEvent.change(await screen.findByLabelText('Code'), {target: {value: '123456'}}); + fireEvent.click(screen.getByRole('button', {name: 'Check'})); + + await waitFor(() => + expect(onSubmit).toHaveBeenCalledWith({ + action: 'action_verify', + challengeToken: 'ct-1', + executionId: 'exec-1', + inputs: {otp: '123456'}, + }), + ); + expect(await screen.findByText('Email verified.')).toBeTruthy(); + expect(onComplete).toHaveBeenCalledWith(verified); + }); + + it('keeps validation: an empty code is not sent', async () => { + renderCustom(); + fireEvent.click(await screen.findByRole('button', {name: 'Check'})); + + await waitFor(() => expect(screen.getByLabelText('Code')).toBeTruthy()); + expect(onSubmit).not.toHaveBeenCalled(); + }); + + it('resends through handleResend without validating the code', async () => { + onSubmit.mockResolvedValue(codeSent('a•••@example.com', 'ct-3')); + renderCustom(); + fireEvent.click(await screen.findByRole('button', {name: 'Send again'})); + + await waitFor(() => + expect(onSubmit).toHaveBeenCalledWith({ + action: 'action_resend', + challengeToken: 'ct-1', + executionId: 'exec-1', + inputs: {}, + }), + ); + }); + + it('sends one resend when a custom UI calls handleResend twice at once', async () => { + onSubmit.mockResolvedValue(codeSent('a•••@example.com', 'ct-3')); + const {latest} = renderCustom(); + await screen.findByRole('button', {name: 'Send again'}); + + await act(async () => { + await Promise.all([latest().handleResend?.(), latest().handleResend?.()]); + }); + + expect(onSubmit).toHaveBeenCalledTimes(1); + }); + + it('hands over cancel, and Done only while a link is awaited', async () => { + const onCancel: Mock = vi.fn(); + const onDone: Mock = vi.fn(); + const {latest} = renderCustom({onCancel, onDone}); + fireEvent.click(await screen.findByRole('button', {name: 'Dismiss'})); + + expect(onCancel).toHaveBeenCalledTimes(1); + expect(latest().onDone).toBeUndefined(); + expect(latest().isAwaitingLink).toBe(false); + }); + + it('hands over Done and no resend for a link step', async () => { + onInitialize.mockResolvedValue(linkSent); + const onDone: Mock = vi.fn(); + const {latest} = renderCustom({onDone}); + fireEvent.click(await screen.findByRole('button', {name: 'Opened it'})); + + expect(onDone).toHaveBeenCalledTimes(1); + expect(latest().isAwaitingLink).toBe(true); + expect(latest().handleResend).toBeUndefined(); + }); + + it('hands over a refusal as a translated error and the failed state', async () => { + onSubmit.mockResolvedValue(refused); + const {latest} = renderCustom(); + fireEvent.change(await screen.findByLabelText('Code'), {target: {value: '123456'}}); + fireEvent.click(screen.getByRole('button', {name: 'Check'})); + + expect((await screen.findByRole('alert')).textContent).toBe('Verification is not allowed.'); + expect(latest().isFailed).toBe(true); + expect(latest().handleResend).toBeUndefined(); + }); + + it('starts the verification once per mount', async () => { + render( + + {withProviders( + + {({title}: BaseVerifyAttributeRenderProps) =>

{title}

} +
, + )} +
, + ); + + await screen.findByText('Verify your email address'); + expect(onInitialize).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/packages/react/src/components/account/VerifyAttribute/__tests__/VerifyAttribute.test.tsx b/packages/react/src/components/account/VerifyAttribute/__tests__/VerifyAttribute.test.tsx new file mode 100644 index 00000000..62d32622 --- /dev/null +++ b/packages/react/src/components/account/VerifyAttribute/__tests__/VerifyAttribute.test.tsx @@ -0,0 +1,410 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react'; +import {ReactElement, StrictMode} from 'react'; +import {afterEach, beforeEach, describe, expect, it, Mock, vi} from 'vitest'; +import I18nProvider from '../../../../contexts/I18n/I18nProvider'; +import ThemeProvider from '../../../../contexts/Theme/ThemeProvider'; +import ThunderIDContext, {ThunderIDContextProps} from '../../../../contexts/ThunderID/ThunderIDContext'; +import UserContext, {UserContextProps} from '../../../../contexts/User/UserContext'; +import {BaseVerifyAttributeRenderProps} from '../BaseVerifyAttribute'; +import VerifyAttribute, {VerifyAttributeProps} from '../VerifyAttribute'; + +const mockExecute = vi.fn() as Mock; + +vi.mock('../../../../api/auth/executeEmbeddedSelfFlow', () => ({ + default: (...args: unknown[]): unknown => mockExecute(...args) as unknown, +})); + +const thunderIDContext: ThunderIDContextProps = { + baseUrl: 'https://localhost:8090', + endpoints: {flowExecute: 'https://auth.example.com/flow/execute'}, + instanceId: 3, + isInitialized: true, + isLoading: false, + meta: null, + vendor: 'thunderid', +} as unknown as ThunderIDContextProps; + +const workEmailSchema: UserContextProps['userSchema'] = { + given_name: {displayName: 'First name', type: 'string'}, + workEmail: {type: 'email'}, +}; + +const mockRevalidateProfile = vi.fn() as Mock; + +const userContext = (userSchema: UserContextProps['userSchema']): UserContextProps => + ({ + flattenedProfile: null, + onUpdateProfile: vi.fn(), + profile: null, + revalidateProfile: mockRevalidateProfile, + updateProfile: vi.fn(), + userSchema, + }) as unknown as UserContextProps; + +interface TestProps { + attribute: string; + attributeDisplayName?: string; + children?: VerifyAttributeProps['children']; + flowHandle?: string; + onComplete?: () => void; + onDone?: () => void; + onError?: (error: Error) => void; +} + +const tree = ( + props: TestProps, + schema: UserContextProps['userSchema'] = workEmailSchema, + context: Partial = {}, +): ReactElement => ( + + + + + + + + + +); + +const renderVerifyAttribute = ( + props: TestProps, + schema: UserContextProps['userSchema'] = workEmailSchema, + context: Partial = {}, +): ReturnType => render(tree(props, schema, context)); + +describe('VerifyAttribute', () => { + beforeEach(() => { + mockExecute.mockReset().mockReturnValue(new Promise(() => undefined)); + mockRevalidateProfile.mockReset().mockResolvedValue(undefined); + }); + + afterEach(() => { + cleanup(); + }); + + it('starts the default self attribute verification flow for the attribute through the configured endpoint', async () => { + renderVerifyAttribute({attribute: 'workEmail'}); + + await waitFor(() => expect(mockExecute).toHaveBeenCalledTimes(1)); + expect(mockExecute).toHaveBeenCalledWith({ + baseUrl: 'https://localhost:8090', + instanceId: 3, + payload: { + flowType: 'SELF', + handle: 'default-self-attribute-verification-flow', + inputs: {attribute: 'workEmail'}, + }, + url: 'https://auth.example.com/flow/execute', + }); + }); + + it('posts to /flow/execute on baseUrl when no flowExecute endpoint is configured', async () => { + renderVerifyAttribute({attribute: 'workEmail'}, workEmailSchema, {endpoints: undefined}); + + await waitFor(() => expect(mockExecute).toHaveBeenCalledTimes(1)); + expect(mockExecute).toHaveBeenCalledWith(expect.objectContaining({url: 'https://localhost:8090/flow/execute'})); + }); + + it('starts the flow the app names by handle', async () => { + renderVerifyAttribute({attribute: 'workEmail', flowHandle: 'email-link-verification'}); + + await waitFor(() => expect(mockExecute).toHaveBeenCalledTimes(1)); + expect(mockExecute).toHaveBeenCalledWith( + expect.objectContaining({ + payload: { + flowType: 'SELF', + handle: 'email-link-verification', + inputs: {attribute: 'workEmail'}, + }, + }), + ); + }); + + it('names the attribute with its schema display name', async () => { + mockExecute.mockResolvedValue({ + data: {additionalData: {maskedDestination: 'a•••@example.com'}, components: []}, + executionId: 'exec-1', + flowStatus: 'INCOMPLETE', + }); + + renderVerifyAttribute({attribute: 'workEmail'}, {workEmail: {displayName: 'Work email', type: 'email'}}); + + expect(await screen.findByText('Verify Work email')).toBeTruthy(); + }); + + it('falls back to a readable attribute name without a schema display name', async () => { + mockExecute.mockResolvedValue({ + data: {additionalData: {maskedDestination: 'a•••@example.com'}, components: []}, + executionId: 'exec-1', + flowStatus: 'INCOMPLETE', + }); + + renderVerifyAttribute({attribute: 'workEmail'}); + + expect(await screen.findByText('Verify Work Email')).toBeTruthy(); + }); + + describe('once per showing', () => { + it('starts one verification under StrictMode, which runs effects twice', async () => { + render({tree({attribute: 'workEmail'})}); + + await waitFor(() => expect(mockExecute).toHaveBeenCalledTimes(1)); + await new Promise((resolve: (value: unknown) => void) => { + setTimeout(resolve, 50); + }); + expect(mockExecute).toHaveBeenCalledTimes(1); + }); + + it('does not retry a start that failed', async () => { + const onError: Mock = vi.fn(); + mockExecute.mockRejectedValue(new Error('network down')); + const {rerender} = renderVerifyAttribute({attribute: 'workEmail', onError}); + + await waitFor(() => expect(onError).toHaveBeenCalledTimes(1)); + rerender(tree({attribute: 'workEmail', onError}, workEmailSchema, {baseUrl: 'https://localhost:8091'})); + await new Promise((resolve: (value: unknown) => void) => { + setTimeout(resolve, 50); + }); + + expect(mockExecute).toHaveBeenCalledTimes(1); + expect(screen.getByText('Verification failed. Please try again.')).toBeTruthy(); + }); + }); + + describe('profile re-read', () => { + it('re-reads the profile once the server records the verification, then reports it', async () => { + const onComplete: Mock = vi.fn(); + mockExecute.mockResolvedValue({executionId: 'exec-1', flowStatus: 'COMPLETE'}); + + renderVerifyAttribute({attribute: 'workEmail', onComplete}); + + await waitFor(() => expect(mockRevalidateProfile).toHaveBeenCalledTimes(1)); + expect(onComplete).toHaveBeenCalledWith({executionId: 'exec-1', flowStatus: 'COMPLETE'}); + }); + + it('still reports the verification when the re-read fails', async () => { + const onComplete: Mock = vi.fn(); + mockRevalidateProfile.mockRejectedValue(new Error('offline')); + mockExecute.mockResolvedValue({executionId: 'exec-1', flowStatus: 'COMPLETE'}); + + renderVerifyAttribute({attribute: 'workEmail', onComplete}); + + await waitFor(() => expect(onComplete).toHaveBeenCalledTimes(1)); + expect(await screen.findByText('Work Email verified.')).toBeTruthy(); + }); + + it('offers Done for a link and re-reads the profile when it is selected, without polling', async () => { + const onDone: Mock = vi.fn(); + mockExecute.mockResolvedValue({ + data: {additionalData: {maskedDestination: 'a•••@example.com'}, components: []}, + executionId: 'exec-1', + flowStatus: 'INCOMPLETE', + }); + + renderVerifyAttribute({attribute: 'workEmail', onDone}); + fireEvent.click(await screen.findByText('Done')); + + expect(mockRevalidateProfile).toHaveBeenCalledTimes(1); + expect(onDone).toHaveBeenCalledTimes(1); + expect(mockExecute).toHaveBeenCalledTimes(1); + }); + + it('offers Done for a link even when the app handles none', async () => { + mockExecute.mockResolvedValue({ + data: {additionalData: {maskedDestination: 'a•••@example.com'}, components: []}, + executionId: 'exec-1', + flowStatus: 'INCOMPLETE', + }); + + renderVerifyAttribute({attribute: 'workEmail'}); + fireEvent.click(await screen.findByText('Done')); + + expect(mockRevalidateProfile).toHaveBeenCalledTimes(1); + }); + + it('does not re-read the profile before the verification completes', async () => { + mockExecute.mockResolvedValue({ + data: {additionalData: {maskedDestination: 'a•••@example.com'}, components: []}, + executionId: 'exec-1', + flowStatus: 'INCOMPLETE', + }); + + renderVerifyAttribute({attribute: 'workEmail'}); + await screen.findByText('Done'); + + expect(mockRevalidateProfile).not.toHaveBeenCalled(); + }); + }); + + describe('attribute eligibility', () => { + it('starts nothing for a string attribute such as a first name and reports the refusal', async () => { + const onError: Mock = vi.fn(); + renderVerifyAttribute({attribute: 'given_name', onError}); + + await waitFor(() => expect(onError).toHaveBeenCalledTimes(1)); + expect((onError.mock.calls[0][0] as {code: string}).code).toBe( + 'VerifyAttribute-handleInitialize-RuntimeError-001', + ); + expect(screen.getByText('Verification failed. Please try again.')).toBeTruthy(); + expect(mockExecute).not.toHaveBeenCalled(); + }); + + it('starts nothing for a multi-valued attribute', async () => { + const onError: Mock = vi.fn(); + renderVerifyAttribute({attribute: 'emails', onError}, {emails: {type: 'array'}}); + + await waitFor(() => expect(onError).toHaveBeenCalledTimes(1)); + expect(mockExecute).not.toHaveBeenCalled(); + }); + + it('starts nothing for an attribute the schema does not declare', async () => { + const onError: Mock = vi.fn(); + renderVerifyAttribute({attribute: 'email', onError}); + + await waitFor(() => expect(onError).toHaveBeenCalledTimes(1)); + expect(mockExecute).not.toHaveBeenCalled(); + }); + + it('waits, without refusing, while the schema is unknown after the session loads', async () => { + const onError: Mock = vi.fn(); + const {rerender} = renderVerifyAttribute({attribute: 'workEmail', onError}, null); + + await new Promise((resolve: (value: unknown) => void) => { + setTimeout(resolve, 50); + }); + expect(onError).not.toHaveBeenCalled(); + expect(mockExecute).not.toHaveBeenCalled(); + + rerender(tree({attribute: 'workEmail', onError})); + + await waitFor(() => expect(mockExecute).toHaveBeenCalledTimes(1)); + expect(onError).not.toHaveBeenCalled(); + }); + + it('waits for the user schema to load before deciding', async () => { + const onError: Mock = vi.fn(); + const {rerender} = renderVerifyAttribute({attribute: 'workEmail', onError}, null, {isLoading: true}); + + await new Promise((resolve: (value: unknown) => void) => { + setTimeout(resolve, 50); + }); + expect(onError).not.toHaveBeenCalled(); + expect(mockExecute).not.toHaveBeenCalled(); + + rerender( + + + + + + + + + , + ); + + await waitFor(() => expect(mockExecute).toHaveBeenCalledTimes(1)); + expect(onError).not.toHaveBeenCalled(); + }); + }); + + describe('custom render', () => { + const codeStep = { + challengeToken: 'ct-1', + data: { + additionalData: {maskedDestination: 'a•••@example.com'}, + components: [ + { + components: [ + {id: 'input_otp', label: 'Code', ref: 'otp', required: true, type: 'OTP_INPUT'}, + {eventType: 'SUBMIT', id: 'action_verify', label: 'Verify', type: 'ACTION', variant: 'PRIMARY'}, + ], + id: 'block_otp', + type: 'BLOCK', + }, + ], + }, + executionId: 'exec-1', + flowStatus: 'INCOMPLETE', + }; + + const custom: VerifyAttributeProps['children'] = ({ + components, + handleInputChange, + handleSubmit, + isComplete, + onDone, + subtitle, + successMessage, + title, + values, + }: BaseVerifyAttributeRenderProps) => ( +
+

{title}

+

{subtitle}

+ {isComplete &&

{successMessage}

} + handleInputChange('otp', event.target.value)} /> + {components[0]?.components?.[1] && ( + + )} + {onDone && ( + + )} +
+ ); + + it('passes the step the connected component started to its render function', async () => { + mockExecute.mockResolvedValue(codeStep); + renderVerifyAttribute({attribute: 'workEmail', children: custom}); + + expect(await screen.findByText('Verify Work Email')).toBeTruthy(); + expect(screen.getByText('Enter the code sent to a•••@example.com.')).toBeTruthy(); + expect(screen.queryByRole('button', {name: 'Verify'})).toBeNull(); + expect(mockExecute).toHaveBeenCalledTimes(1); + expect(mockExecute.mock.calls[0][0]).toMatchObject({instanceId: 3, payload: {flowType: 'SELF'}}); + }); + + it('re-reads the profile and reports the verification a custom UI submitted', async () => { + const onComplete: Mock = vi.fn(); + mockExecute + .mockResolvedValueOnce(codeStep) + .mockResolvedValueOnce({executionId: 'exec-1', flowStatus: 'COMPLETE'}); + renderVerifyAttribute({attribute: 'workEmail', children: custom, onComplete}); + + fireEvent.change(await screen.findByLabelText('Code'), {target: {value: '123456'}}); + fireEvent.click(screen.getByRole('button', {name: 'Check'})); + + expect(await screen.findByText('Work Email verified.')).toBeTruthy(); + expect(mockExecute).toHaveBeenLastCalledWith( + expect.objectContaining({ + payload: {action: 'action_verify', challengeToken: 'ct-1', executionId: 'exec-1', inputs: {otp: '123456'}}, + }), + ); + expect(mockRevalidateProfile).toHaveBeenCalledTimes(1); + expect(onComplete).toHaveBeenCalledWith({executionId: 'exec-1', flowStatus: 'COMPLETE'}); + }); + + it('re-reads the profile when a custom UI confirms a link', async () => { + const onDone: Mock = vi.fn(); + mockExecute.mockResolvedValue({ + data: {additionalData: {maskedDestination: 'a•••@example.com'}, components: []}, + executionId: 'exec-1', + flowStatus: 'INCOMPLETE', + }); + renderVerifyAttribute({attribute: 'workEmail', children: custom, onDone}); + + fireEvent.click(await screen.findByRole('button', {name: 'Opened it'})); + + expect(mockRevalidateProfile).toHaveBeenCalledTimes(1); + expect(onDone).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/packages/react/src/components/auth/Callback/__tests__/__screenshots__/TokenCallback.test.tsx/TokenCallback-verifies-the-token-as-a-registration-flow-when-type-REGISTRATION-is-present-1.png b/packages/react/src/components/auth/Callback/__tests__/__screenshots__/TokenCallback.test.tsx/TokenCallback-verifies-the-token-as-a-registration-flow-when-type-REGISTRATION-is-present-1.png deleted file mode 100644 index 47767d2f..00000000 Binary files a/packages/react/src/components/auth/Callback/__tests__/__screenshots__/TokenCallback.test.tsx/TokenCallback-verifies-the-token-as-a-registration-flow-when-type-REGISTRATION-is-present-1.png and /dev/null differ diff --git a/packages/react/src/components/auth/Callback/__tests__/__screenshots__/TokenCallback.test.tsx/TokenCallback-verifies-the-token-as-an-authentication-flow-when-type-AUTHENTICATION-is-present-1.png b/packages/react/src/components/auth/Callback/__tests__/__screenshots__/TokenCallback.test.tsx/TokenCallback-verifies-the-token-as-an-authentication-flow-when-type-AUTHENTICATION-is-present-1.png deleted file mode 100644 index 47767d2f..00000000 Binary files a/packages/react/src/components/auth/Callback/__tests__/__screenshots__/TokenCallback.test.tsx/TokenCallback-verifies-the-token-as-an-authentication-flow-when-type-AUTHENTICATION-is-present-1.png and /dev/null differ diff --git a/packages/react/src/components/auth/flow/AuthOptionFactory.tsx b/packages/react/src/components/auth/flow/AuthOptionFactory.tsx index 7cee89a3..ad7c294d 100644 --- a/packages/react/src/components/auth/flow/AuthOptionFactory.tsx +++ b/packages/react/src/components/auth/flow/AuthOptionFactory.tsx @@ -125,7 +125,7 @@ function getAuthOptionIconClass(): string { return authOptionIconClassCache; } -export type AuthType = 'signin' | 'signup' | 'recovery'; +export type AuthType = 'signin' | 'signup' | 'recovery' | 'verification'; /** * Get the appropriate FieldType for an input component. @@ -259,6 +259,8 @@ const createAuthComponentFromFlow = ( /** Called with the error of a failed picker load. The form itself shows no error for it. */ onPickerError?: (error: Error) => void; onSubmit?: (component: EmbeddedFlowComponent, data?: Record, skipValidation?: boolean) => void; + /** Renders RESEND actions; only attribute verification sets it, so other flows skip them as before. */ + renderResend?: boolean; size?: 'small' | 'medium' | 'large'; /** Translation function for resolving {{t(...)}} expressions at render time */ t?: UseTranslation['t']; @@ -331,6 +333,25 @@ const createAuthComponentFromFlow = ( return null; }; + // A resend asks for a new code, so it submits its own action without the step's values or validation. + if (component.type === EmbeddedFlowComponentType.Resend && options.renderResend) { + return ( + + ); + } + switch (component.type) { case EmbeddedFlowComponentType.TextInput: case EmbeddedFlowComponentType.PasswordInput: @@ -1149,6 +1170,41 @@ export const renderRecoveryComponents = ( ) .filter((x): x is ReactElement => x !== null); +/** + * Processes an array of components and renders them as React elements for attribute verification. + */ +export const renderVerificationComponents = ( + components: EmbeddedFlowComponent[], + formValues: Record, + touchedFields: Record, + formErrors: Record, + isLoading: boolean, + isFormValid: boolean, + resetForm: () => void, + onInputChange: (name: string, value: string) => void, + options?: Parameters[8], +): ReactElement[] => + components + .map((component: EmbeddedFlowComponent, index: number) => + createAuthComponentFromFlow( + component, + formValues, + touchedFields, + formErrors, + isLoading, + isFormValid, + resetForm, + onInputChange, + 'verification', + { + ...options, + key: component.id ?? index, + renderResend: true, + }, + ), + ) + .filter((x): x is ReactElement => x !== null); + /** * Processes an array of components and renders them as React elements for invite user. * This is used by both InviteUser and AcceptInvite components. diff --git a/packages/react/src/components/auth/flow/__tests__/AuthOptionFactory.test.tsx b/packages/react/src/components/auth/flow/__tests__/AuthOptionFactory.test.tsx index 4b875fae..b7a403f6 100644 --- a/packages/react/src/components/auth/flow/__tests__/AuthOptionFactory.test.tsx +++ b/packages/react/src/components/auth/flow/__tests__/AuthOptionFactory.test.tsx @@ -17,7 +17,7 @@ import I18nProvider from '../../../../contexts/I18n/I18nProvider'; import ThemeContext, {ThemeContextValue} from '../../../../contexts/Theme/ThemeContext'; import {UseTranslation} from '../../../../hooks/useTranslation'; import {chooseOption} from '../../../primitives/Select/__tests__/selectTestUtils'; -import {renderInviteUserComponents, renderSignInComponents} from '../AuthOptionFactory'; +import {renderInviteUserComponents, renderSignInComponents, renderVerificationComponents} from '../AuthOptionFactory'; const richTextWithLink = (label: string, action?: {ref: string; eventType?: string}): EmbeddedFlowComponent => ({ action, @@ -940,3 +940,110 @@ describe('AuthOptionFactory custom renderers', () => { errorSpy.mockRestore(); }); }); + +describe('AuthOptionFactory RESEND', () => { + afterEach(cleanup); + + const resend: EmbeddedFlowComponent = { + eventType: EmbeddedFlowEventType.Submit, + id: 'action_resend', + label: 'Resend code', + type: EmbeddedFlowComponentType.Resend, + }; + + // The code prompt the server ships: a required code, the primary submit and a resend in one block. + const codeBlock: EmbeddedFlowComponent = { + components: [ + {id: 'input_otp', label: 'Code', ref: 'otp', required: true, type: EmbeddedFlowComponentType.OtpInput}, + { + eventType: EmbeddedFlowEventType.Submit, + id: 'action_verify', + label: 'Verify', + type: EmbeddedFlowComponentType.Action, + variant: 'PRIMARY', + }, + resend, + ], + id: 'block_otp', + type: EmbeddedFlowComponentType.Block, + }; + + // Renders a step whose code is only partly typed, so the step is not valid yet. + const renderStep = ( + components: EmbeddedFlowComponent[], + onSubmit: (submitted: EmbeddedFlowComponent, data?: Record, skipValidation?: boolean) => void, + { + isLoading = false, + isTimeoutDisabled = false, + renderer = renderVerificationComponents, + }: {isLoading?: boolean; isTimeoutDisabled?: boolean; renderer?: typeof renderSignInComponents} = {}, + ): ReturnType => + render( + + + {renderer( + components, + {otp: '12'}, + {otp: true}, + {otp: 'Enter the full code.'}, + isLoading, + false, + () => undefined, + () => undefined, + {isTimeoutDisabled, onSubmit}, + )} + + , + ); + + it('submits its own action from the code block without the entered code or its validation', () => { + const onSubmit = vi.fn(); + renderStep([codeBlock], onSubmit); + + expect(screen.getByRole('button', {name: 'Verify'})).toBeDisabled(); + fireEvent.click(screen.getByRole('button', {name: 'Resend code'})); + + expect(onSubmit).toHaveBeenCalledTimes(1); + expect(onSubmit).toHaveBeenCalledWith(resend, {}, true); + }); + + it('submits its own action when it stands outside a block', () => { + const onSubmit = vi.fn(); + renderStep([resend], onSubmit); + + fireEvent.click(screen.getByRole('button', {name: 'Resend code'})); + + expect(onSubmit).toHaveBeenCalledWith(resend, {}, true); + }); + + it('cannot be pressed while a request is in flight', () => { + renderStep([resend], vi.fn(), {isLoading: true}); + + expect(screen.getByRole('button', {name: 'Resend code'})).toBeDisabled(); + }); + + it('cannot be pressed once the time allowed for the step has run out', () => { + renderStep([resend], vi.fn(), {isTimeoutDisabled: true}); + + expect(screen.getByRole('button', {name: 'Resend code'})).toBeDisabled(); + }); + + it('is skipped by sign-in, which renders the step as if it had no resend', () => { + const {container} = renderStep([codeBlock], vi.fn(), {renderer: renderSignInComponents}); + expect(screen.queryByRole('button', {name: 'Resend code'})).toBeNull(); + const withResend: string = container.innerHTML; + cleanup(); + + const withoutResend: string = renderStep( + [{...codeBlock, components: codeBlock.components!.filter((c: EmbeddedFlowComponent) => c !== resend)}], + vi.fn(), + {renderer: renderSignInComponents}, + ).container.innerHTML; + + // Each render gets its own React-generated ids. + const withoutIds = (html: string): string => html.replace(/_r_[^_]+_/g, '_r_'); + expect(withoutIds(withResend)).toBe(withoutIds(withoutResend)); + }); +}); diff --git a/packages/react/src/components/auth/flow/__tests__/__screenshots__/ImageComponent.test.tsx/ImageComponent-renders-an-emoji--spec-as-its-glyph--not-an-image-1.png b/packages/react/src/components/auth/flow/__tests__/__screenshots__/ImageComponent.test.tsx/ImageComponent-renders-an-emoji--spec-as-its-glyph--not-an-image-1.png deleted file mode 100644 index 539d54cd..00000000 Binary files a/packages/react/src/components/auth/flow/__tests__/__screenshots__/ImageComponent.test.tsx/ImageComponent-renders-an-emoji--spec-as-its-glyph--not-an-image-1.png and /dev/null differ diff --git a/packages/react/src/components/common/Attribution/__tests__/__screenshots__/Attribution.test.tsx/Attribution-hangs-a-ribbon-off-the-edge-of-a-card-1.png b/packages/react/src/components/common/Attribution/__tests__/__screenshots__/Attribution.test.tsx/Attribution-hangs-a-ribbon-off-the-edge-of-a-card-1.png deleted file mode 100644 index 1ee76e02..00000000 Binary files a/packages/react/src/components/common/Attribution/__tests__/__screenshots__/Attribution.test.tsx/Attribution-hangs-a-ribbon-off-the-edge-of-a-card-1.png and /dev/null differ diff --git a/packages/react/src/contexts/ComponentRenderer/ComponentRendererContext.ts b/packages/react/src/contexts/ComponentRenderer/ComponentRendererContext.ts index eb04ef8d..b97c5d38 100644 --- a/packages/react/src/contexts/ComponentRenderer/ComponentRendererContext.ts +++ b/packages/react/src/contexts/ComponentRenderer/ComponentRendererContext.ts @@ -6,7 +6,7 @@ import {Context, createContext, ReactElement} from 'react'; export interface ComponentRenderContext { additionalData?: Record; - authType: 'signin' | 'signup' | 'recovery'; + authType: 'signin' | 'signup' | 'recovery' | 'verification'; formErrors: Record; formValues: Record; isFormValid: boolean; diff --git a/packages/react/src/contexts/ThunderID/ThunderIDProvider.tsx b/packages/react/src/contexts/ThunderID/ThunderIDProvider.tsx index c98828c2..17cb101b 100644 --- a/packages/react/src/contexts/ThunderID/ThunderIDProvider.tsx +++ b/packages/react/src/contexts/ThunderID/ThunderIDProvider.tsx @@ -429,6 +429,23 @@ const ThunderIDProvider: FC> = ({ })); }; + /** + * Re-reads `/users/me` into the session profile, so a change made outside the profile API, such as a + * recorded verification, shows. Like a profile update, the result replaces the profile; ID token + * claims are not merged back in, since they may hold a value the user has since changed. + */ + const revalidateProfile = async (): Promise => { + if (preferences?.user?.fetchUserProfile === false) return; + + handleProfileUpdate( + await getUsersMe({ + baseUrl, + instanceId, + url: resolveResourceEndpoint('usersMe', config), + }), + ); + }; + const getDecodedIdToken: () => Promise = useCallback( async (): Promise => client.getDecodedIdToken(), [client], @@ -605,7 +622,12 @@ const ThunderIDProvider: FC> = ({ }} > - + {children} diff --git a/packages/react/src/contexts/ThunderID/__tests__/ThunderIDProvider.test.tsx b/packages/react/src/contexts/ThunderID/__tests__/ThunderIDProvider.test.tsx index c31a5fab..8f118899 100644 --- a/packages/react/src/contexts/ThunderID/__tests__/ThunderIDProvider.test.tsx +++ b/packages/react/src/contexts/ThunderID/__tests__/ThunderIDProvider.test.tsx @@ -2,8 +2,10 @@ // SPDX-License-Identifier: Apache-2.0 import {cleanup, render, screen, waitFor} from '@testing-library/react'; -import {ReactElement} from 'react'; +import {ReactElement, useEffect} from 'react'; import {Mock, afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {UserContextProps} from '../../User/UserContext'; +import useUser from '../../User/useUser'; import ThunderIDProvider from '../ThunderIDProvider'; import useThunderID from '../useThunderID'; @@ -274,3 +276,55 @@ describe('ThunderIDProvider in backend mode', () => { expect(screen.getByTestId('base-url').textContent).toBe('https://app.example.com/gateway/api'); }); }); + +describe('ThunderIDProvider revalidateProfile', () => { + let user: UserContextProps | null = null; + + const Probe = (): ReactElement => { + const current: UserContextProps | null = useUser(); + + useEffect(() => { + user = current; + }); + + return
; + }; + + const renderWithProbe = (fetchUserProfile?: boolean): void => { + render( + + + , + ); + }; + + afterEach(() => { + user = null; + }); + + it('re-reads /users/me into the profile', async () => { + mocks.client.signedIn = true; + renderWithProbe(); + await waitFor(() => expect(user?.profile?.['userName']).toBe('alice')); + + mocks.getUsersMe = vi.fn(() => Promise.resolve({sub: 'user-1', userName: 'alice', verified: 'yes'})); + await user!.revalidateProfile(); + + await waitFor(() => expect(user?.profile?.['verified']).toBe('yes')); + expect(user?.flattenedProfile?.['verified']).toBe('yes'); + expect(mocks.getUsersMe).toHaveBeenCalledTimes(1); + }); + + it('does not fetch the profile when the app turned profile fetching off', async () => { + renderWithProbe(false); + await waitFor(() => expect(user).not.toBeNull()); + + await user!.revalidateProfile(); + + expect(mocks.getUsersMe).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 6d78cf74..61d84a01 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -181,6 +181,12 @@ export * from './components/account/UserProfile/BaseUserProfile'; export {default as UserProfile} from './components/account/UserProfile/UserProfile'; export * from './components/account/UserProfile/UserProfile'; +export {default as BaseVerifyAttribute} from './components/account/VerifyAttribute/BaseVerifyAttribute'; +export * from './components/account/VerifyAttribute/BaseVerifyAttribute'; + +export {default as VerifyAttribute} from './components/account/VerifyAttribute/VerifyAttribute'; +export * from './components/account/VerifyAttribute/VerifyAttribute'; + export * from './components/admin/shared/AdminListRenderProps'; export type {AdminSearchValue} from './components/admin/shared/AdminSearch'; @@ -406,6 +412,8 @@ export {default as updateMeProfile} from './api/account/updateMeProfile'; export type {UpdateMeProfileConfig} from './api/account/updateMeProfile'; export {default as updateMeCredentials} from './api/account/updateMeCredentials'; export type {UpdateMeCredentialsConfig} from './api/account/updateMeCredentials'; +export {default as executeEmbeddedSelfFlow} from './api/auth/executeEmbeddedSelfFlow'; +export type {ExecuteEmbeddedSelfFlowConfig} from './api/auth/executeEmbeddedSelfFlow'; export {default as getMeProfile} from './api/account/getUsersMe'; export * from './api/account/getUsersMe'; export {default as getUsersMeMeta} from './api/account/getUsersMeMeta'; @@ -477,6 +485,13 @@ export { type EmbeddedRecoveryFlowInitiateRequest, type EmbeddedRecoveryFlowRequest, type EmbeddedRecoveryFlowErrorResponse, + EmbeddedSelfFlowStatus, + type EmbeddedSelfFlowResponse, + type EmbeddedSelfFlowRequest, + AttributeVerificationConstants, + getVerifiedAttributes, + isVerifiableAttribute, + type VerifiedAttribute, normalizeSelectOptions, toSelectOption, toSelectOptions, diff --git a/packages/vue/src/__tests__/api/auth/execute-embedded-self-flow.test.ts b/packages/vue/src/__tests__/api/auth/execute-embedded-self-flow.test.ts new file mode 100644 index 00000000..59a83559 --- /dev/null +++ b/packages/vue/src/__tests__/api/auth/execute-embedded-self-flow.test.ts @@ -0,0 +1,82 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ThunderIDAPIError} from '@thunderid/browser'; +import {afterEach, describe, expect, it, vi} from 'vitest'; +import executeEmbeddedSelfFlow from '../../../api/auth/executeEmbeddedSelfFlow'; + +// The adapter itself lives in @thunderid/browser's createHttpClientFetcher and is covered there. This +// file checks the wrapper's job: the authenticated adapter is the default transport, and the core +// function's own handling of a refusal runs end to end through it. +const mockFetcher = vi.fn(); +const mockCreateHttpClientFetcher = vi.fn(() => mockFetcher); + +vi.mock('@thunderid/browser', async () => { + const actual = await vi.importActual('@thunderid/browser'); + return { + ...actual, + createHttpClientFetcher: (instanceId: number) => mockCreateHttpClientFetcher(instanceId), + }; +}); + +const start = { + flowType: 'SELF', + handle: 'default-self-attribute-verification-flow', + inputs: {attribute: 'email'}, +}; + +describe('executeEmbeddedSelfFlow (vue)', () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it("sends the step through the instance's authenticated transport, with no user or application ID", async () => { + mockFetcher.mockResolvedValueOnce({ + json: () => Promise.resolve({executionId: 'exec-1', flowStatus: 'INCOMPLETE'}), + ok: true, + status: 200, + } as Response); + + const step = await executeEmbeddedSelfFlow({ + instanceId: 2, + payload: start as never, + url: 'https://localhost:8090/flow/execute', + }); + + expect(step).toEqual({executionId: 'exec-1', flowStatus: 'INCOMPLETE'}); + expect(mockCreateHttpClientFetcher).toHaveBeenCalledWith(2); + const [url, init] = mockFetcher.mock.calls[0] as [string, RequestInit]; + expect(url).toBe('https://localhost:8090/flow/execute'); + expect(JSON.parse(init.body as string)).toEqual({...start, verbose: true}); + }); + + it('reports a 4xx as a refusal with its status', async () => { + mockFetcher.mockResolvedValueOnce({ + ok: false, + status: 403, + statusText: 'Forbidden', + text: () => Promise.resolve('{"code":"FES-1017"}'), + } as Response); + + const failure: unknown = await executeEmbeddedSelfFlow({ + payload: start as never, + url: 'https://localhost:8090/flow/execute', + }).catch((error: unknown) => error); + + expect(failure).toBeInstanceOf(ThunderIDAPIError); + expect((failure as ThunderIDAPIError).statusCode).toBe(403); + }); + + it('prefers a fetcher the caller supplies', async () => { + const fetcher = vi.fn().mockResolvedValue({json: () => Promise.resolve({}), ok: true, status: 200} as Response); + + await executeEmbeddedSelfFlow({ + fetcher, + payload: start as never, + url: 'https://localhost:8090/flow/execute', + }); + + expect(fetcher).toHaveBeenCalledTimes(1); + expect(mockCreateHttpClientFetcher).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/vue/src/__tests__/components/account/user-profile-verification.test.ts b/packages/vue/src/__tests__/components/account/user-profile-verification.test.ts new file mode 100644 index 00000000..966c2f5e --- /dev/null +++ b/packages/vue/src/__tests__/components/account/user-profile-verification.test.ts @@ -0,0 +1,599 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {AttributeSchema, User, getDefaultI18nBundles, substituteTranslationParams} from '@thunderid/browser'; +import {DOMWrapper, VueWrapper, config, flushPromises, mount} from '@vue/test-utils'; +import {Mock, beforeEach, describe, expect, it, vi} from 'vitest'; +import {type PropType, type Ref, type VNode, defineComponent, h, ref} from 'vue'; +import BaseUserProfile, { + type BaseUserProfileVerificationSlotProps, + type BaseUserProfileVerificationState, +} from '../../../components/account/user-profile/BaseUserProfile'; +import UserProfile from '../../../components/account/user-profile/UserProfile'; +import {FLOW_META_KEY, I18N_KEY, THUNDERID_KEY, USER_KEY} from '../../../keys'; +import type {I18nContextValue, ThunderIDContext, UserContextValue} from '../../../models/contexts'; + +const mockExecute = vi.fn() as Mock; + +vi.mock('../../../api/auth/executeEmbeddedSelfFlow', () => ({ + default: (...args: unknown[]): unknown => mockExecute(...args) as unknown, +})); + +// Renders the verification dialog in place, so the wrapper can find it. +config.global.stubs = {teleport: true}; + +const VERIFIED_AT = '2026-10-16T08:30:00Z'; + +const userSchema: Record = { + email: {displayName: 'Email', type: 'email'}, + given_name: {displayName: 'First name', type: 'string'}, + mobile: {displayName: 'Mobile', type: 'mobile'}, +} as Record; + +// A /users/me profile: attributes are nested and spread to the top level. +const profileOf = (attributes: Record, verifiedAttributes?: Record): User => ({ + attributes, + id: 'user-1', + ...attributes, + ...(verifiedAttributes ? {verifiedAttributes} : {}), +}); + +const attributes = {email: 'alex@example.com', given_name: 'Alex', mobile: '+94771234567'}; +const storedProfile: User = profileOf(attributes, {email: {verifiedAt: VERIFIED_AT}}); +const unverifiedProfile: User = profileOf(attributes, {}); +const legacyProfile: User = profileOf(attributes); + +// The server drops the verification of a changed attribute. +const serverUpdate = (profile: User, payload: Record): User => { + const previous: Record = profile['attributes'] as Record; + const verifiedAttributes: Record = {...(profile['verifiedAttributes'] as Record)}; + Object.keys(payload).forEach((key: string) => { + if (payload[key] !== previous[key]) delete verifiedAttributes[key]; + }); + return profileOf({...previous, ...payload}, verifiedAttributes); +}; + +const t = (key: string, params?: Record): string => { + const translations = getDefaultI18nBundles()['en-US']?.translations as unknown as Record; + const value: string = translations?.[key] ?? key; + return params ? substituteTranslationParams(value, params) : value; +}; + +const verificationSlot = (props: BaseUserProfileVerificationSlotProps) => + h('div', {'data-testid': 'verification-panel'}, [ + h('span', `Verifying ${props.attributeDisplayName} (${props.attribute})`), + h('button', {onClick: props.onCancel, type: 'button'}, 'Not now'), + h('button', {onClick: props.onComplete, type: 'button'}, 'Recorded'), + ]); + +const settle = async (): Promise => { + await flushPromises(); + await new Promise((resolve: (value: unknown) => void) => { + setTimeout(resolve, 0); + }); + await flushPromises(); +}; + +// The row of a field: its label, value, status and actions. +const row = (wrapper: VueWrapper, label: string): DOMWrapper => + wrapper + .findAll('.thunderid-user-profile__field') + .find((r: DOMWrapper) => r.find('.thunderid-user-profile__field-label').text() === label)!; + +const buttonIn = (scope: DOMWrapper | VueWrapper, text: string): DOMWrapper | undefined => + scope.findAll('button').find((b: DOMWrapper) => b.text() === text); + +const status = (scope: DOMWrapper): DOMWrapper => + scope.find('.thunderid-user-profile__verification-status'); + +const mountBase = (props: Record = {}, withSlot = true): VueWrapper => + mount(BaseUserProfile, { + props: {flattenedProfile: storedProfile, profile: storedProfile, t, userSchema, ...props}, + slots: withSlot ? {verification: verificationSlot} : {}, + }) as VueWrapper; + +// Holds the profile and the save error as UserProfile does: a failed save sets `error` and keeps the profile. +const Harness = defineComponent({ + props: { + initialProfile: {required: true, type: Object as PropType}, + // A profile the next reload returns, as a revalidation from elsewhere would. + reloadedProfile: {default: undefined, type: Object as PropType}, + update: { + required: true, + type: Function as PropType<(profile: User, payload: Record) => Promise>, + }, + }, + setup(props) { + const profile: Ref = ref(props.initialProfile); + const error: Ref = ref(null); + const onUpdate = async (payload: Record): Promise => { + error.value = null; + try { + profile.value = await props.update(profile.value, payload); + } catch { + error.value = 'Could not update your profile.'; + } + }; + return () => [ + h( + BaseUserProfile, + { + editable: true, + error: error.value, + flattenedProfile: profile.value, + onUpdate, + profile: profile.value, + t, + userSchema, + }, + {verification: verificationSlot}, + ), + props.reloadedProfile + ? h( + 'button', + { + onClick: () => { + profile.value = props.reloadedProfile!; + }, + type: 'button', + }, + 'Reload profile', + ) + : null, + ]; + }, +}); + +const editAndSave = async (wrapper: VueWrapper, label: string, value: string): Promise => { + await row(wrapper, label).find('button[title="Edit"]').trigger('click'); + await row(wrapper, label).find('input').setValue(value); + await buttonIn(row(wrapper, label), 'Save')!.trigger('click'); +}; + +// A custom profile drawn through the default slot; `seen` keeps the last slot props. +const customProfile = + (seen: {props?: BaseUserProfileVerificationState}) => + (slotProps: BaseUserProfileVerificationState & {profile: User | null}): VNode => { + seen.props = slotProps; + return h( + 'ul', + ['email', 'mobile', 'given_name'] + .map((name: string) => + h('li', {'data-attribute': name}, [ + h('span', `${name}: ${slotProps.verificationStatus(name) ?? 'n/a'}`), + slotProps.canVerify(name) + ? h('button', {onClick: () => slotProps.verify(name), type: 'button'}, `Verify ${name}`) + : null, + ]), + ) + .concat([h('li', [slotProps.renderVerification()])]), + ); + }; + +describe('BaseUserProfile verification', () => { + describe('status', () => { + it('shows Verified or Unverified for each email and mobile field with a value, not for a first name', () => { + const wrapper: VueWrapper = mountBase(); + + expect(status(row(wrapper, 'Email')).text()).toContain('Verified'); + expect(status(row(wrapper, 'Mobile')).text()).toBe('Unverified'); + expect(status(row(wrapper, 'First name')).exists()).toBe(false); + }); + + it('gives the verification time of a verified field, also as text for keyboard and touch users', () => { + const wrapper: VueWrapper = mountBase(); + const verifiedOn = `Verified on ${new Date(VERIFIED_AT).toLocaleString('en-US')}`; + + expect(status(row(wrapper, 'Email')).attributes('title')).toBe(verifiedOn); + expect(status(row(wrapper, 'Email')).text()).toBe(`Verified (${verifiedOn})`); + expect(status(row(wrapper, 'Mobile')).attributes('title')).toBeUndefined(); + }); + + it('shows no status and offers no Verify when the profile does not report verification', () => { + const wrapper: VueWrapper = mountBase({flattenedProfile: legacyProfile, profile: legacyProfile}); + + expect(wrapper.find('.thunderid-user-profile__verification-status').exists()).toBe(false); + expect(buttonIn(wrapper, 'Verify')).toBeUndefined(); + }); + + it('does not list the verification record as a profile field', () => { + const wrapper: VueWrapper = mountBase({userSchema: null}); + + expect(wrapper.text()).toContain('alex@example.com'); + expect(wrapper.text()).not.toContain('Verified Attributes'); + expect(wrapper.text()).not.toContain('verifiedAt'); + }); + + it('hides the status while the field is being edited', async () => { + const wrapper: VueWrapper = mountBase({onUpdate: vi.fn()}); + + await row(wrapper, 'Email').find('button[title="Edit"]').trigger('click'); + + expect(status(row(wrapper, 'Email')).exists()).toBe(false); + }); + }); + + describe('Verify action', () => { + it('is offered for an unverified email and mobile, and not for a verified email', () => { + const wrapper: VueWrapper = mountBase(); + + expect(buttonIn(row(wrapper, 'Email'), 'Verify')).toBeUndefined(); + expect(buttonIn(row(wrapper, 'Mobile'), 'Verify')).toBeDefined(); + }); + + it('carries test identifiers on the status and the Verify action', () => { + const wrapper: VueWrapper = mountBase(); + + expect(buttonIn(row(wrapper, 'Mobile'), 'Verify')?.attributes('data-testid')).toBe('thunderid-verify-mobile'); + expect(status(row(wrapper, 'Email')).attributes('data-testid')).toBe('thunderid-verification-status-email'); + }); + + it('is not offered without a verification slot', () => { + const wrapper: VueWrapper = mountBase({flattenedProfile: unverifiedProfile, profile: unverifiedProfile}, false); + + expect(status(row(wrapper, 'Email')).text()).toBe('Unverified'); + expect(buttonIn(wrapper, 'Verify')).toBeUndefined(); + }); + + it('is not offered on a profile that cannot be edited', () => { + const wrapper: VueWrapper = mountBase({ + editable: false, + flattenedProfile: unverifiedProfile, + profile: unverifiedProfile, + }); + + expect(status(row(wrapper, 'Email')).text()).toBe('Unverified'); + expect(buttonIn(wrapper, 'Verify')).toBeUndefined(); + }); + + it('opens the panel for the field, hides its Verify, and closes it on dismiss', async () => { + const wrapper: VueWrapper = mountBase({flattenedProfile: unverifiedProfile, profile: unverifiedProfile}); + + await buttonIn(row(wrapper, 'Mobile'), 'Verify')!.trigger('click'); + + // A page profile opens the panel in a dialog. + const dialog: DOMWrapper = wrapper.find('[role="dialog"]'); + expect(dialog.attributes('aria-modal')).toBe('true'); + expect(dialog.text()).toContain('Verifying Mobile (mobile)'); + expect(buttonIn(row(wrapper, 'Mobile'), 'Verify')).toBeUndefined(); + + await buttonIn(wrapper, 'Not now')!.trigger('click'); + + expect(wrapper.find('[data-testid="verification-panel"]').exists()).toBe(false); + expect(buttonIn(row(wrapper, 'Mobile'), 'Verify')).toBeDefined(); + }); + + it('closes the dialog on Escape', async () => { + const wrapper: VueWrapper = mountBase({flattenedProfile: unverifiedProfile, profile: unverifiedProfile}); + + await buttonIn(row(wrapper, 'Mobile'), 'Verify')!.trigger('click'); + await wrapper.find('[role="dialog"]').trigger('keydown', {key: 'Escape'}); + + expect(wrapper.find('[role="dialog"]').exists()).toBe(false); + }); + + it('closes the dialog and confirms the attribute once the verification is recorded', async () => { + const wrapper: VueWrapper = mountBase({flattenedProfile: unverifiedProfile, profile: unverifiedProfile}); + + await buttonIn(row(wrapper, 'Mobile'), 'Verify')!.trigger('click'); + await buttonIn(wrapper, 'Recorded')!.trigger('click'); + + expect(wrapper.find('[role="dialog"]').exists()).toBe(false); + expect(wrapper.find('[data-testid="thunderid-verification-notice"]').text()).toContain('Mobile verified.'); + }); + + it('opens the panel under the row, without a dialog, in a compact profile', async () => { + const wrapper: VueWrapper = mountBase({ + compact: true, + flattenedProfile: unverifiedProfile, + profile: unverifiedProfile, + }); + + await buttonIn(row(wrapper, 'Email'), 'Verify')!.trigger('click'); + + expect(wrapper.find('[role="dialog"]').exists()).toBe(false); + const panel: DOMWrapper = wrapper.find('.thunderid-user-profile__verification--inline'); + expect(panel.text()).toContain('Verifying Email (email)'); + expect(buttonIn(row(wrapper, 'Mobile'), 'Verify')).toBeDefined(); + }); + + it('closes the panel when the field is edited', async () => { + const wrapper: VueWrapper = mountBase({ + flattenedProfile: unverifiedProfile, + onUpdate: vi.fn(), + profile: unverifiedProfile, + }); + + await buttonIn(row(wrapper, 'Email'), 'Verify')!.trigger('click'); + await row(wrapper, 'Email').find('button[title="Edit"]').trigger('click'); + + expect(wrapper.find('[data-testid="verification-panel"]').exists()).toBe(false); + }); + }); + + describe('after saving', () => { + it('opens the panel once the profile shows a changed email as unverified, never during the save', async () => { + let finishUpdate: (profile: User) => void = () => undefined; + const update: Mock = vi.fn( + () => + new Promise((resolve: (profile: User) => void) => { + finishUpdate = resolve; + }), + ); + const wrapper: VueWrapper = mount(Harness, {props: {initialProfile: storedProfile, update}}); + + await editAndSave(wrapper, 'Email', 'new@example.com'); + + expect(update).toHaveBeenCalledWith(storedProfile, {email: 'new@example.com'}); + expect(wrapper.find('[data-testid="verification-panel"]').exists()).toBe(false); + expect(status(row(wrapper, 'Email')).exists()).toBe(false); + + finishUpdate(serverUpdate(storedProfile, {email: 'new@example.com'})); + await settle(); + + expect(wrapper.text()).toContain('Verifying Email (email)'); + expect(status(row(wrapper, 'Email')).text()).toBe('Unverified'); + expect(row(wrapper, 'Email').text()).toContain('new@example.com'); + }); + + it('shows the stored value with its own status once the save fails, never the typed one', async () => { + const update: Mock = vi.fn(() => Promise.reject(new Error('rejected'))); + const wrapper: VueWrapper = mount(Harness, { + props: { + initialProfile: unverifiedProfile, + reloadedProfile: serverUpdate(unverifiedProfile, {email: 'other@example.com'}), + update, + }, + }); + + await editAndSave(wrapper, 'Email', 'new@example.com'); + await settle(); + + expect(wrapper.text()).toContain('Could not update your profile.'); + expect(row(wrapper, 'Email').text()).toContain('alex@example.com'); + expect(row(wrapper, 'Email').text()).not.toContain('new@example.com'); + expect(status(row(wrapper, 'Email')).text()).toBe('Unverified'); + expect(wrapper.find('[data-testid="verification-panel"]').exists()).toBe(false); + + // The failed save started nothing, so a later change from elsewhere opens nothing either. + await buttonIn(wrapper, 'Reload profile')!.trigger('click'); + await settle(); + + expect(row(wrapper, 'Email').text()).toContain('other@example.com'); + expect(wrapper.find('[data-testid="verification-panel"]').exists()).toBe(false); + }); + + it('opens no panel for a changed mobile, a changed first name, or an unchanged email', async () => { + const update: Mock = vi.fn((profile: User, payload: Record) => + Promise.resolve(serverUpdate(profile, payload)), + ); + const wrapper: VueWrapper = mount(Harness, {props: {initialProfile: unverifiedProfile, update}}); + + await editAndSave(wrapper, 'Mobile', '+94770000000'); + await settle(); + await editAndSave(wrapper, 'First name', 'Alexa'); + await settle(); + await editAndSave(wrapper, 'Email', 'alex@example.com'); + await settle(); + + expect(update).toHaveBeenCalledTimes(3); + expect(wrapper.find('[data-testid="verification-panel"]').exists()).toBe(false); + }); + + it('opens no panel when the profile shows the new email already verified', async () => { + const update: Mock = vi.fn((profile: User, payload: Record) => + Promise.resolve( + profileOf( + {...(profile['attributes'] as Record), ...payload}, + {email: {verifiedAt: VERIFIED_AT}}, + ), + ), + ); + const wrapper: VueWrapper = mount(Harness, {props: {initialProfile: unverifiedProfile, update}}); + + await editAndSave(wrapper, 'Email', 'new@example.com'); + await settle(); + + expect(status(row(wrapper, 'Email')).text()).toContain('Verified'); + expect(wrapper.find('[data-testid="verification-panel"]').exists()).toBe(false); + }); + + it('opens no panel for a changed email when the profile does not report verification', async () => { + const update: Mock = vi.fn((profile: User, payload: Record) => + Promise.resolve(profileOf({...(profile['attributes'] as Record), ...payload})), + ); + const wrapper: VueWrapper = mount(Harness, {props: {initialProfile: legacyProfile, update}}); + + await editAndSave(wrapper, 'Email', 'new@example.com'); + await settle(); + + expect(row(wrapper, 'Email').text()).toContain('new@example.com'); + expect(wrapper.find('[data-testid="verification-panel"]').exists()).toBe(false); + }); + }); + describe('custom render', () => { + const mountCustom = ( + props: Record = {}, + seen: {props?: BaseUserProfileVerificationState} = {}, + ): VueWrapper => + mount(BaseUserProfile, { + props: {editable: true, flattenedProfile: storedProfile, profile: storedProfile, t, userSchema, ...props}, + slots: {default: customProfile(seen), verification: verificationSlot}, + }) as VueWrapper; + + it("hands a custom profile each attribute's status", () => { + const seen: {props?: BaseUserProfileVerificationState} = {}; + const wrapper: VueWrapper = mountCustom({}, seen); + + expect(wrapper.text()).toContain('email: verified'); + expect(wrapper.text()).toContain('mobile: unverified'); + expect(wrapper.text()).toContain('given_name: n/a'); + expect(seen.props?.verifiedAttributes).toEqual({email: {verifiedAt: VERIFIED_AT}}); + expect(wrapper.find('.thunderid-user-profile__verification-status').exists()).toBe(false); + }); + + it('reports no status for an attribute without a stored value', () => { + const empty: User = profileOf({...attributes, mobile: ''}, {}); + const wrapper: VueWrapper = mountCustom({flattenedProfile: empty, profile: empty}); + + expect(wrapper.text()).toContain('email: unverified'); + expect(wrapper.text()).toContain('mobile: n/a'); + expect(buttonIn(wrapper, 'Verify mobile')).toBeUndefined(); + }); + + it('reports no status when the server does not report verification', () => { + const seen: {props?: BaseUserProfileVerificationState} = {}; + const wrapper: VueWrapper = mountCustom({flattenedProfile: legacyProfile, profile: legacyProfile}, seen); + + expect(wrapper.text()).toContain('email: n/a'); + expect(wrapper.text()).toContain('mobile: n/a'); + expect(seen.props?.verifiedAttributes).toBeUndefined(); + expect(buttonIn(wrapper, 'Verify mobile')).toBeUndefined(); + }); + + it('starts a verification only for an unverified attribute and renders the verification slot for it', async () => { + const seen: {props?: BaseUserProfileVerificationState} = {}; + const wrapper: VueWrapper = mountCustom({}, seen); + + expect(buttonIn(wrapper, 'Verify email')).toBeUndefined(); + seen.props?.verify('email'); + seen.props?.verify('given_name'); + await settle(); + expect(wrapper.find('[data-testid="verification-panel"]').exists()).toBe(false); + + await buttonIn(wrapper, 'Verify mobile')!.trigger('click'); + + expect(seen.props?.verifyingAttribute).toBe('mobile'); + expect(wrapper.find('[data-testid="verification-panel"]').text()).toContain('Verifying Mobile (mobile)'); + + await buttonIn(wrapper, 'Not now')!.trigger('click'); + expect(wrapper.find('[data-testid="verification-panel"]').exists()).toBe(false); + }); + + it('closes the open verification through cancelVerification', async () => { + const seen: {props?: BaseUserProfileVerificationState} = {}; + const wrapper: VueWrapper = mountCustom({}, seen); + await buttonIn(wrapper, 'Verify mobile')!.trigger('click'); + + seen.props?.cancelVerification(); + await settle(); + + expect(seen.props?.verifyingAttribute).toBeNull(); + expect(wrapper.find('[data-testid="verification-panel"]').exists()).toBe(false); + }); + + it('offers no verification without a verification slot', () => { + const seen: {props?: BaseUserProfileVerificationState} = {}; + const wrapper: VueWrapper = mount(BaseUserProfile, { + props: {editable: true, flattenedProfile: storedProfile, profile: storedProfile, t, userSchema}, + slots: {default: customProfile(seen)}, + }) as VueWrapper; + + expect(wrapper.text()).toContain('mobile: unverified'); + expect(seen.props?.canVerify('mobile')).toBe(false); + expect(buttonIn(wrapper, 'Verify mobile')).toBeUndefined(); + }); + + it('offers no verification when the profile is not editable', async () => { + const seen: {props?: BaseUserProfileVerificationState} = {}; + const wrapper: VueWrapper = mountCustom({editable: false}, seen); + + expect(wrapper.text()).toContain('mobile: unverified'); + expect(seen.props?.canVerify('mobile')).toBe(false); + seen.props?.verify('mobile'); + await settle(); + expect(wrapper.find('[data-testid="verification-panel"]').exists()).toBe(false); + }); + }); +}); + +describe('UserProfile verification', () => { + const mountUserProfile = (props: Record = {}, slots: Record = {}): VueWrapper => + mount(UserProfile, { + global: { + provide: { + [FLOW_META_KEY as symbol]: {meta: ref(null)}, + [I18N_KEY as symbol]: {currentLanguage: ref('en-US'), t} as unknown as I18nContextValue, + [THUNDERID_KEY as symbol]: { + baseUrl: 'https://localhost:8090', + instanceId: 0, + isInitialized: ref(true), + isLoading: ref(false), + vendor: 'thunderid', + } as unknown as ThunderIDContext, + [USER_KEY as symbol]: { + flattenedProfile: ref(unverifiedProfile), + onUpdateProfile: vi.fn(), + profile: ref({flattenedProfile: unverifiedProfile, profile: unverifiedProfile}), + revalidateProfile: vi.fn().mockResolvedValue(undefined), + updateProfile: vi.fn(), + userSchema: ref(userSchema), + } as unknown as UserContextValue, + }, + }, + props, + slots: slots as never, + }) as VueWrapper; + + beforeEach(() => { + mockExecute.mockReset().mockReturnValue(new Promise(() => undefined)); + }); + + it('verifies the email through the flow the app names by verificationFlowHandle', async () => { + const wrapper: VueWrapper = mountUserProfile({verificationFlowHandle: 'email-link-verification'}); + + expect(buttonIn(row(wrapper, 'First name'), 'Verify')).toBeUndefined(); + await buttonIn(row(wrapper, 'Email'), 'Verify')!.trigger('click'); + await settle(); + + expect(mockExecute).toHaveBeenCalledTimes(1); + expect(mockExecute).toHaveBeenCalledWith( + expect.objectContaining({ + payload: {flowType: 'SELF', handle: 'email-link-verification', inputs: {attribute: 'email'}}, + }), + ); + }); + + it('closes the panel when Done is selected after a link is sent', async () => { + mockExecute.mockResolvedValue({ + data: {additionalData: {maskedDestination: 'a•••@example.com'}, components: []}, + executionId: 'exec-1', + flowStatus: 'INCOMPLETE', + }); + const wrapper: VueWrapper = mountUserProfile(); + + await buttonIn(row(wrapper, 'Email'), 'Verify')!.trigger('click'); + await settle(); + await buttonIn(wrapper, 'Done')!.trigger('click'); + await settle(); + + expect(buttonIn(wrapper, 'Done')).toBeUndefined(); + expect(mockExecute).toHaveBeenCalledTimes(1); + }); + + it('offers no Verify when the profile is not editable', () => { + const wrapper: VueWrapper = mountUserProfile({editable: false}); + + expect(status(row(wrapper, 'Email')).text()).toBe('Unverified'); + expect(buttonIn(wrapper, 'Verify')).toBeUndefined(); + }); + + it('verifies from a custom profile through the connected verification flow', async () => { + const wrapper: VueWrapper = mountUserProfile({}, {default: customProfile({})}); + + expect(wrapper.text()).toContain('email: unverified'); + await buttonIn(wrapper, 'Verify email')!.trigger('click'); + await settle(); + + expect(mockExecute).toHaveBeenCalledTimes(1); + expect(mockExecute).toHaveBeenCalledWith( + expect.objectContaining({ + payload: { + flowType: 'SELF', + handle: 'default-self-attribute-verification-flow', + inputs: {attribute: 'email'}, + }, + }), + ); + }); +}); diff --git a/packages/vue/src/__tests__/components/account/verify-attribute.test.ts b/packages/vue/src/__tests__/components/account/verify-attribute.test.ts new file mode 100644 index 00000000..adb7a071 --- /dev/null +++ b/packages/vue/src/__tests__/components/account/verify-attribute.test.ts @@ -0,0 +1,795 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import { + EmbeddedFlowComponent, + EmbeddedSelfFlowResponse, + EmbeddedSelfFlowStatus, + getDefaultI18nBundles, + substituteTranslationParams, +} from '@thunderid/browser'; +import {DOMWrapper, VueWrapper, flushPromises, mount} from '@vue/test-utils'; +import {Mock, afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {type Ref, type VNode, h, ref} from 'vue'; +import BaseVerifyAttribute, { + type BaseVerifyAttributeRenderProps, +} from '../../../components/account/verify-attribute/BaseVerifyAttribute'; +import VerifyAttribute from '../../../components/account/verify-attribute/VerifyAttribute'; +import {FLOW_META_KEY, I18N_KEY, THUNDERID_KEY, USER_KEY} from '../../../keys'; +import type {I18nContextValue, ThunderIDContext, UserContextValue} from '../../../models/contexts'; + +const mockExecute = vi.fn() as Mock; + +vi.mock('../../../api/auth/executeEmbeddedSelfFlow', () => ({ + default: (...args: unknown[]): unknown => mockExecute(...args) as unknown, +})); + +// The shipped email flow's code prompt, with its text already resolved. +const codePrompt: EmbeddedFlowComponent[] = [ + {id: 'email_code_heading', label: 'Verify your email address', type: 'TEXT', variant: 'HEADING_1'}, + { + id: 'email_code_message', + label: 'Enter the verification code sent to your email address.', + type: 'TEXT', + variant: 'HEADING_6', + }, + { + components: [ + {id: 'input_email_otp', label: 'Verification code', ref: 'otp', required: true, type: 'OTP_INPUT'}, + {eventType: 'SUBMIT', id: 'action_email_verify', label: 'Verify', type: 'ACTION', variant: 'PRIMARY'}, + {eventType: 'SUBMIT', id: 'action_email_resend', label: 'Resend code', type: 'RESEND'}, + ], + id: 'block_email_otp', + type: 'BLOCK', + }, +] as unknown as EmbeddedFlowComponent[]; + +const codeSent = (destination = 'a•••@example.com', challengeToken = 'ct-1'): EmbeddedSelfFlowResponse => + ({ + challengeToken, + data: {additionalData: {maskedDestination: destination}, components: codePrompt}, + executionId: 'exec-1', + flowStatus: EmbeddedSelfFlowStatus.Incomplete, + }) as unknown as EmbeddedSelfFlowResponse; + +const linkSent: EmbeddedSelfFlowResponse = { + data: {additionalData: {maskedDestination: 'a•••@example.com'}, components: []}, + executionId: 'exec-1', + flowStatus: EmbeddedSelfFlowStatus.Incomplete, +} as unknown as EmbeddedSelfFlowResponse; + +const codeRejected: EmbeddedSelfFlowResponse = { + challengeToken: 'ct-2', + data: {components: codePrompt}, + error: {message: {defaultValue: 'The code is incorrect.', key: 'otp.invalid'}}, + executionId: 'exec-1', + flowStatus: EmbeddedSelfFlowStatus.Incomplete, +} as unknown as EmbeddedSelfFlowResponse; + +const verified: EmbeddedSelfFlowResponse = { + executionId: 'exec-1', + flowStatus: EmbeddedSelfFlowStatus.Complete, +}; + +const refused: EmbeddedSelfFlowResponse = { + error: {message: {defaultValue: 'Verification is not allowed.', key: 'verification.refused'}}, + executionId: 'exec-1', + flowStatus: EmbeddedSelfFlowStatus.Error, +} as unknown as EmbeddedSelfFlowResponse; + +/** + * Resolves keys through the real en-US bundle, so the tests assert on what a user sees. + */ +const createI18nContext = (): I18nContextValue => + ({ + bundles: ref({}), + currentLanguage: ref('en-US'), + fallbackLanguage: 'en-US', + injectBundles: vi.fn(), + setLanguage: vi.fn(), + t: (key: string, params?: Record): string => { + const translations = getDefaultI18nBundles()['en-US']?.translations as unknown as Record; + const value: string = translations?.[key] ?? key; + + return params ? substituteTranslationParams(value, params) : value; + }, + }) as unknown as I18nContextValue; + +const baseProviders = (): Record => ({ + [FLOW_META_KEY as symbol]: {meta: ref(null)}, + [I18N_KEY as symbol]: createI18nContext(), +}); + +const settle = async (): Promise => { + await flushPromises(); + await new Promise((resolve: (value: unknown) => void) => { + setTimeout(resolve, 0); + }); + await flushPromises(); +}; + +const button = (wrapper: VueWrapper, text: string): DOMWrapper | undefined => + wrapper.findAll('button').find((b: DOMWrapper) => b.text() === text); + +const enterCode = async (wrapper: VueWrapper, code: string): Promise => { + const digits: DOMWrapper[] = wrapper.findAll('input'); + for (let i = 0; i < code.length; i += 1) { + await digits[i].setValue(code[i]); + } +}; + +// A custom slot drawn with plain elements; `seen` keeps the last slot props for assertions. +const customSlot = + (seen: {props?: BaseVerifyAttributeRenderProps}) => + (slotProps: BaseVerifyAttributeRenderProps): VNode => { + seen.props = slotProps; + const verify: EmbeddedFlowComponent | undefined = slotProps.components[0]?.components?.find( + (component: EmbeddedFlowComponent) => component.type === 'ACTION', + ); + return h('form', {'aria-busy': String(slotProps.isLoading)}, [ + h('h2', slotProps.title), + h('p', slotProps.subtitle), + slotProps.destinationText ? h('p', slotProps.destinationText) : null, + slotProps.error ? h('p', {role: 'alert'}, slotProps.error) : null, + slotProps.isComplete ? h('p', slotProps.successMessage) : null, + h('input', { + 'aria-label': 'Code', + onInput: (event: Event) => slotProps.handleInputChange('otp', (event.target as HTMLInputElement).value), + value: slotProps.values['otp'] ?? '', + }), + verify + ? h('button', {onClick: () => void slotProps.handleSubmit(verify, slotProps.values), type: 'button'}, 'Check') + : null, + slotProps.handleResend + ? h('button', {onClick: () => void slotProps.handleResend?.(), type: 'button'}, 'Send again') + : null, + slotProps.onCancel ? h('button', {onClick: slotProps.onCancel, type: 'button'}, 'Dismiss') : null, + slotProps.onDone ? h('button', {onClick: slotProps.onDone, type: 'button'}, 'Opened it') : null, + ]); + }; + +describe('BaseVerifyAttribute', () => { + let onInitialize: Mock; + let onSubmit: Mock; + + const mountBase = (props: Record = {}, slots: Record = {}): VueWrapper => + mount(BaseVerifyAttribute, { + global: {provide: baseProviders()}, + props: {attributeDisplayName: 'Email', onInitialize, onSubmit, ...props}, + slots: slots as never, + }) as VueWrapper; + + beforeEach(() => { + onInitialize = vi.fn().mockResolvedValue(codeSent()); + onSubmit = vi.fn(); + }); + + it('shows a spinner and no prompt while the code is being sent', () => { + onInitialize.mockReturnValue(new Promise(() => undefined)); + + const wrapper: VueWrapper = mountBase(); + + expect(wrapper.find('[aria-busy="true"]').exists()).toBe(true); + expect(wrapper.find('input').exists()).toBe(false); + }); + + it('starts the verification once per mount, and only once the SDK is initialized', async () => { + const wrapper: VueWrapper = mountBase({isInitialized: false}); + await settle(); + expect(onInitialize).not.toHaveBeenCalled(); + + await wrapper.setProps({isInitialized: true}); + await settle(); + await wrapper.setProps({isInitialized: false}); + await wrapper.setProps({isInitialized: true}); + await settle(); + + expect(onInitialize).toHaveBeenCalledTimes(1); + }); + + it('does not retry a start that failed', async () => { + onInitialize.mockRejectedValue(new Error('network down')); + const wrapper: VueWrapper = mountBase({isInitialized: false}); + + await wrapper.setProps({isInitialized: true}); + await settle(); + await wrapper.setProps({isInitialized: false}); + await wrapper.setProps({isInitialized: true}); + await settle(); + + expect(onInitialize).toHaveBeenCalledTimes(1); + expect(wrapper.text()).toContain('Verification failed. Please try again.'); + }); + + it("prompts for the code with the flow's text and names where it was sent", async () => { + const wrapper: VueWrapper = mountBase(); + await settle(); + + expect(wrapper.text()).toContain('Verify your email address'); + expect(wrapper.text()).toContain('Enter the verification code sent to your email address.'); + expect(wrapper.text()).toContain('Sent to a•••@example.com.'); + expect(button(wrapper, 'Resend code')?.attributes('id')).toBe('action_email_resend'); + }); + + it('names the destination in its own words when the flow gives no prompt text', async () => { + onInitialize.mockResolvedValue({ + ...codeSent(), + data: { + ...codeSent().data, + components: codePrompt.filter((c: EmbeddedFlowComponent) => c.variant !== 'HEADING_6'), + }, + }); + const wrapper: VueWrapper = mountBase(); + await settle(); + + expect(wrapper.text()).toContain('Enter the code sent to a•••@example.com.'); + expect(wrapper.text()).not.toContain('Sent to a•••@example.com.'); + }); + + it('submits the code to the same execution with its challenge token', async () => { + onSubmit.mockResolvedValue(verified); + const wrapper: VueWrapper = mountBase(); + await settle(); + + await enterCode(wrapper, '123456'); + await button(wrapper, 'Verify')!.trigger('click'); + await settle(); + + expect(onSubmit).toHaveBeenCalledWith({ + action: 'action_email_verify', + challengeToken: 'ct-1', + executionId: 'exec-1', + inputs: {otp: '123456'}, + }); + }); + + it('does not submit without a code', async () => { + const wrapper: VueWrapper = mountBase(); + await settle(); + + await button(wrapper, 'Verify')!.trigger('click'); + await settle(); + + expect(onSubmit).not.toHaveBeenCalled(); + }); + + it('enables Verify again once a code is entered after an empty submit', async () => { + onSubmit.mockResolvedValue(verified); + const wrapper: VueWrapper = mountBase(); + await settle(); + + await button(wrapper, 'Verify')!.trigger('click'); + await settle(); + expect(button(wrapper, 'Verify')!.element.disabled).toBe(true); + + await enterCode(wrapper, '123456'); + expect(button(wrapper, 'Verify')!.element.disabled).toBe(false); + await button(wrapper, 'Verify')!.trigger('click'); + await settle(); + + expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({inputs: {otp: '123456'}})); + }); + + it('uses the translations in its preferences over the provider bundle', async () => { + onSubmit.mockResolvedValue(verified); + const wrapper: VueWrapper = mountBase({ + preferences: {i18n: {bundles: {'en-US': {translations: {'user.verification.success': '{attribute} confirmed'}}}}}, + }); + await settle(); + + await enterCode(wrapper, '123456'); + await button(wrapper, 'Verify')!.trigger('click'); + await settle(); + + expect(wrapper.text()).toContain('Email confirmed'); + }); + + it('confirms a recorded verification and reports it', async () => { + const onComplete: Mock = vi.fn(); + onSubmit.mockResolvedValue(verified); + const wrapper: VueWrapper = mountBase({onCancel: vi.fn(), onComplete}); + await settle(); + + await enterCode(wrapper, '123456'); + await button(wrapper, 'Verify')!.trigger('click'); + await settle(); + + expect(wrapper.text()).toContain('Email verified.'); + expect(onComplete).toHaveBeenCalledWith(verified); + expect(wrapper.find('input').exists()).toBe(false); + expect(button(wrapper, 'Close')).toBeDefined(); + }); + + it('keeps the prompt and the destination when a code is rejected, and sends the next attempt with its token', async () => { + onSubmit.mockResolvedValueOnce(codeRejected).mockResolvedValueOnce(verified); + const wrapper: VueWrapper = mountBase(); + await settle(); + + await enterCode(wrapper, '000000'); + await button(wrapper, 'Verify')!.trigger('click'); + await settle(); + + expect(wrapper.text()).toContain('The code is incorrect.'); + expect(wrapper.text()).toContain('Sent to a•••@example.com.'); + expect(wrapper.findAll('input')).toHaveLength(6); + + await enterCode(wrapper, '123456'); + await button(wrapper, 'Verify')!.trigger('click'); + await settle(); + + expect(onSubmit.mock.calls[1][0]).toMatchObject({challengeToken: 'ct-2', inputs: {otp: '123456'}}); + }); + + it('resends without the partly typed code or its validation and shows the new destination', async () => { + onSubmit.mockResolvedValue(codeSent('b•••@example.com', 'ct-3')); + const wrapper: VueWrapper = mountBase(); + await settle(); + + await enterCode(wrapper, '12'); + await button(wrapper, 'Resend code')!.trigger('click'); + await settle(); + + expect(onSubmit).toHaveBeenCalledWith({ + action: 'action_email_resend', + challengeToken: 'ct-1', + executionId: 'exec-1', + inputs: {}, + }); + expect(wrapper.text()).toContain('Sent to b•••@example.com.'); + }); + + it('shows a refusal, reports it and stops prompting', async () => { + const onError: Mock = vi.fn(); + onSubmit.mockResolvedValue(refused); + const wrapper: VueWrapper = mountBase({onCancel: vi.fn(), onError}); + await settle(); + + await enterCode(wrapper, '123456'); + await button(wrapper, 'Verify')!.trigger('click'); + await settle(); + + expect(wrapper.text()).toContain('Verification is not allowed.'); + expect(onError).toHaveBeenCalledWith(expect.objectContaining({message: 'Verification is not allowed.'})); + expect(wrapper.find('input').exists()).toBe(false); + expect(button(wrapper, 'Close')).toBeDefined(); + }); + + it('shows a generic message, not the response body, when the request fails', async () => { + const onError: Mock = vi.fn(); + const failure: Error = new Error('{"code":"FLW-1001","description":"internal detail"}'); + onInitialize.mockRejectedValue(failure); + const wrapper: VueWrapper = mountBase({onError}); + await settle(); + + expect(wrapper.text()).toContain('Verification failed. Please try again.'); + expect(wrapper.text()).not.toContain('internal detail'); + expect(onError).toHaveBeenCalledWith(failure); + }); + + it('tells the user where the link went and reports Done, without polling', async () => { + const onDone: Mock = vi.fn(); + onInitialize.mockResolvedValue(linkSent); + const wrapper: VueWrapper = mountBase({onDone}); + await settle(); + + expect(wrapper.text()).toContain('We sent a verification link to a•••@example.com. Open it, then select Done.'); + await button(wrapper, 'Done')!.trigger('click'); + + expect(onDone).toHaveBeenCalledTimes(1); + expect(onSubmit).not.toHaveBeenCalled(); + expect(onInitialize).toHaveBeenCalledTimes(1); + }); + + it('offers Done only for a link flow, and Cancel only when the panel can be dismissed', async () => { + const onCancel: Mock = vi.fn(); + const wrapper: VueWrapper = mountBase({onCancel, onDone: vi.fn()}); + await settle(); + + expect(button(wrapper, 'Done')).toBeUndefined(); + await button(wrapper, 'Cancel')!.trigger('click'); + expect(onCancel).toHaveBeenCalledTimes(1); + + const plain: VueWrapper = mountBase(); + await settle(); + expect(button(plain, 'Cancel')).toBeUndefined(); + }); + + it('tags the inputs and actions rendered from the flow, and its own actions, with flow identifiers', async () => { + const wrapper: VueWrapper = mountBase({onCancel: vi.fn()}); + await settle(); + + expect(button(wrapper, 'Verify')?.attributes('data-testid')).toBe('thunderid-action-action_email_verify'); + expect(button(wrapper, 'Resend code')?.attributes('data-testid')).toBe('thunderid-action-action_email_resend'); + expect(button(wrapper, 'Cancel')?.attributes('data-testid')).toBe('thunderid-action-verificationCancel'); + expect(wrapper.find('[data-testid="thunderid-field-otp"]').exists()).toBe(true); + expect(wrapper.find('[data-testid^="thunderid-signin-"]').exists()).toBe(false); + }); + + it('tags Done for a link flow', async () => { + onInitialize.mockResolvedValue(linkSent); + const wrapper: VueWrapper = mountBase({onDone: vi.fn()}); + await settle(); + + expect(button(wrapper, 'Done')?.attributes('data-testid')).toBe('thunderid-action-verificationDone'); + }); + + it('lets a custom renderer resend without validating the code', async () => { + onSubmit.mockResolvedValue(codeSent('a•••@example.com', 'ct-3')); + const wrapper: VueWrapper = mountBase( + {}, + { + default: ({components, destination, handleSubmit}: BaseVerifyAttributeRenderProps) => { + const resend: EmbeddedFlowComponent | undefined = components[0]?.components?.find( + (component: EmbeddedFlowComponent) => component.type === 'RESEND', + ); + return resend + ? h('button', {onClick: () => handleSubmit(resend, {}, true), type: 'button'}, `Resend to ${destination}`) + : null; + }, + }, + ); + await settle(); + + await button(wrapper, 'Resend to a•••@example.com')!.trigger('click'); + await settle(); + + expect(onSubmit).toHaveBeenCalledWith({ + action: 'action_email_resend', + challengeToken: 'ct-1', + executionId: 'exec-1', + inputs: {}, + }); + }); + describe('custom render', () => { + it('replaces the built-in steps and hands over the step, headings and destination', async () => { + const wrapper: VueWrapper = mountBase({}, {default: customSlot({})}); + await settle(); + + expect(wrapper.find('h2').text()).toBe('Verify your email address'); + expect(wrapper.text()).toContain('Enter the verification code sent to your email address.'); + expect(wrapper.text()).toContain('Sent to a•••@example.com.'); + expect(button(wrapper, 'Verify')).toBeUndefined(); + expect(wrapper.findAll('input')).toHaveLength(1); + }); + + it('submits the values the custom UI set, then shows the success message', async () => { + onSubmit.mockResolvedValue(verified); + const onComplete: Mock = vi.fn(); + const wrapper: VueWrapper = mountBase({onComplete}, {default: customSlot({})}); + await settle(); + + await wrapper.find('input').setValue('123456'); + await button(wrapper, 'Check')!.trigger('click'); + await settle(); + + expect(onSubmit).toHaveBeenCalledWith({ + action: 'action_email_verify', + challengeToken: 'ct-1', + executionId: 'exec-1', + inputs: {otp: '123456'}, + }); + expect(wrapper.text()).toContain('Email verified.'); + expect(onComplete).toHaveBeenCalledWith(verified); + }); + + it('keeps validation: an empty code is not sent', async () => { + const wrapper: VueWrapper = mountBase({}, {default: customSlot({})}); + await settle(); + + await button(wrapper, 'Check')!.trigger('click'); + await settle(); + + expect(onSubmit).not.toHaveBeenCalled(); + }); + + it('resends through handleResend without validating the code', async () => { + onSubmit.mockResolvedValue(codeSent('a•••@example.com', 'ct-3')); + const wrapper: VueWrapper = mountBase({}, {default: customSlot({})}); + await settle(); + + await button(wrapper, 'Send again')!.trigger('click'); + await settle(); + + expect(onSubmit).toHaveBeenCalledWith({ + action: 'action_email_resend', + challengeToken: 'ct-1', + executionId: 'exec-1', + inputs: {}, + }); + }); + + it('sends one resend when a custom UI calls handleResend twice at once', async () => { + onSubmit.mockResolvedValue(codeSent('a•••@example.com', 'ct-3')); + const seen: {props?: BaseVerifyAttributeRenderProps} = {}; + mountBase({}, {default: customSlot(seen)}); + await settle(); + + await Promise.all([seen.props?.handleResend?.(), seen.props?.handleResend?.()]); + + expect(onSubmit).toHaveBeenCalledTimes(1); + }); + + it('hands over cancel, and Done only while a link is awaited', async () => { + const seen: {props?: BaseVerifyAttributeRenderProps} = {}; + const onCancel: Mock = vi.fn(); + const wrapper: VueWrapper = mountBase({onCancel, onDone: vi.fn()}, {default: customSlot(seen)}); + await settle(); + + await button(wrapper, 'Dismiss')!.trigger('click'); + + expect(onCancel).toHaveBeenCalledTimes(1); + expect(seen.props?.onDone).toBeUndefined(); + expect(seen.props?.isAwaitingLink).toBe(false); + }); + + it('hands over Done and no resend for a link step', async () => { + onInitialize.mockResolvedValue(linkSent); + const seen: {props?: BaseVerifyAttributeRenderProps} = {}; + const onDone: Mock = vi.fn(); + const wrapper: VueWrapper = mountBase({onDone}, {default: customSlot(seen)}); + await settle(); + + await button(wrapper, 'Opened it')!.trigger('click'); + + expect(onDone).toHaveBeenCalledTimes(1); + expect(seen.props?.isAwaitingLink).toBe(true); + expect(seen.props?.handleResend).toBeUndefined(); + }); + + it('hands over a refusal as a translated error and the failed state', async () => { + onSubmit.mockResolvedValue(refused); + const seen: {props?: BaseVerifyAttributeRenderProps} = {}; + const wrapper: VueWrapper = mountBase({}, {default: customSlot(seen)}); + await settle(); + + await wrapper.find('input').setValue('123456'); + await button(wrapper, 'Check')!.trigger('click'); + await settle(); + + expect(wrapper.find('[role="alert"]').text()).toBe('Verification is not allowed.'); + expect(seen.props?.isFailed).toBe(true); + expect(seen.props?.handleResend).toBeUndefined(); + }); + + it('starts the verification once per mount', async () => { + const wrapper: VueWrapper = mountBase({isInitialized: false}, {default: customSlot({})}); + await wrapper.setProps({isInitialized: true}); + await settle(); + await wrapper.setProps({isInitialized: false}); + await wrapper.setProps({isInitialized: true}); + await settle(); + + expect(onInitialize).toHaveBeenCalledTimes(1); + }); + }); +}); + +describe('VerifyAttribute', () => { + let isLoading: Ref; + let revalidateProfile: Mock; + + const userSchema = { + given_name: {displayName: 'First name', type: 'string'}, + workEmail: {type: 'email'}, + }; + + const mountVerify = ( + props: Record, + schema: Record | null = userSchema, + thunderID: Record = {}, + slots: Record = {}, + schemaRef: Ref | null> = ref(schema), + ): VueWrapper => + mount(VerifyAttribute, { + global: { + provide: { + ...baseProviders(), + [THUNDERID_KEY as symbol]: { + baseUrl: 'https://localhost:8090', + endpoints: {flowExecute: 'https://auth.example.com/flow/execute'}, + instanceId: 3, + isInitialized: ref(true), + isLoading, + vendor: 'thunderid', + ...thunderID, + } as unknown as ThunderIDContext, + [USER_KEY as symbol]: { + flattenedProfile: ref(null), + onUpdateProfile: vi.fn(), + profile: ref(null), + revalidateProfile, + userSchema: schemaRef, + } as unknown as UserContextValue, + }, + }, + props, + slots: slots as never, + }) as VueWrapper; + + beforeEach(() => { + isLoading = ref(false); + revalidateProfile = vi.fn().mockResolvedValue(undefined); + mockExecute.mockReset().mockReturnValue(new Promise(() => undefined)); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('starts the default flow for the attribute through the configured endpoint with the authenticated transport', async () => { + mountVerify({attribute: 'workEmail'}); + await settle(); + + expect(mockExecute).toHaveBeenCalledTimes(1); + expect(mockExecute).toHaveBeenCalledWith({ + baseUrl: 'https://localhost:8090', + instanceId: 3, + payload: { + flowType: 'SELF', + handle: 'default-self-attribute-verification-flow', + inputs: {attribute: 'workEmail'}, + }, + url: 'https://auth.example.com/flow/execute', + }); + }); + + it('posts to /flow/execute on baseUrl when no flowExecute endpoint is configured', async () => { + mountVerify({attribute: 'workEmail'}, userSchema, {endpoints: undefined}); + await settle(); + + expect((mockExecute.mock.calls[0][0] as {url: string}).url).toBe('https://localhost:8090/flow/execute'); + }); + + it('starts the flow the app names by handle', async () => { + mountVerify({attribute: 'workEmail', flowHandle: 'email-link-verification'}); + await settle(); + + expect((mockExecute.mock.calls[0][0] as {payload: {handle: string}}).payload.handle).toBe( + 'email-link-verification', + ); + }); + + it('names the attribute with a readable name without a schema display name', async () => { + mockExecute.mockResolvedValue(linkSent); + const wrapper: VueWrapper = mountVerify({attribute: 'workEmail'}); + await settle(); + + expect(wrapper.text()).toContain('Verify Work Email'); + }); + + it('waits for the user schema to load before deciding', async () => { + isLoading.value = true; + const onError: Mock = vi.fn(); + mountVerify({attribute: 'workEmail', onError}); + await settle(); + + expect(mockExecute).not.toHaveBeenCalled(); + expect(onError).not.toHaveBeenCalled(); + + isLoading.value = false; + await settle(); + + expect(mockExecute).toHaveBeenCalledTimes(1); + }); + + it('waits, without refusing, while the schema is unknown after the session loads', async () => { + const schema: Ref | null> = ref(null); + const onError: Mock = vi.fn(); + mountVerify({attribute: 'workEmail', onError}, null, {}, {}, schema); + await settle(); + + expect(mockExecute).not.toHaveBeenCalled(); + expect(onError).not.toHaveBeenCalled(); + + schema.value = userSchema; + await settle(); + + expect(mockExecute).toHaveBeenCalledTimes(1); + expect(onError).not.toHaveBeenCalled(); + }); + + it.each([ + ['a string attribute such as a first name', 'given_name', userSchema], + ['a multi-valued attribute', 'emails', {emails: {type: 'array'}}], + ['an attribute the schema does not declare', 'email', userSchema], + ])('starts nothing for %s and reports the refusal', async (_label, attribute, schema) => { + const onError: Mock = vi.fn(); + const wrapper: VueWrapper = mountVerify({attribute, onError}, schema as Record | null); + await settle(); + + expect(mockExecute).not.toHaveBeenCalled(); + expect((onError.mock.calls[0][0] as {code: string}).code).toBe('VerifyAttribute-handleInitialize-RuntimeError-001'); + expect(wrapper.text()).toContain('Verification failed. Please try again.'); + }); + + it('re-reads the profile once the server records the verification, then reports it', async () => { + const onComplete: Mock = vi.fn(); + mockExecute.mockResolvedValue(verified); + mountVerify({attribute: 'workEmail', onComplete}); + await settle(); + + expect(revalidateProfile).toHaveBeenCalledTimes(1); + expect(onComplete).toHaveBeenCalledWith(verified); + }); + + it('still reports the verification when the re-read fails', async () => { + const onComplete: Mock = vi.fn(); + revalidateProfile.mockRejectedValue(new Error('offline')); + mockExecute.mockResolvedValue(verified); + const wrapper: VueWrapper = mountVerify({attribute: 'workEmail', onComplete}); + await settle(); + + expect(onComplete).toHaveBeenCalledTimes(1); + expect(wrapper.text()).toContain('Work Email verified.'); + }); + + it('offers Done for a link even when the app handles none, and re-reads the profile when it is selected', async () => { + mockExecute.mockResolvedValue(linkSent); + const wrapper: VueWrapper = mountVerify({attribute: 'workEmail'}); + await settle(); + + expect(revalidateProfile).not.toHaveBeenCalled(); + await button(wrapper, 'Done')!.trigger('click'); + await settle(); + + expect(revalidateProfile).toHaveBeenCalledTimes(1); + expect(mockExecute).toHaveBeenCalledTimes(1); + }); + + describe('custom render', () => { + it('passes the step the connected component started to its slot', async () => { + mockExecute.mockResolvedValue(codeSent()); + const wrapper: VueWrapper = mountVerify({attribute: 'workEmail'}, userSchema, {}, {default: customSlot({})}); + await settle(); + + expect(wrapper.find('h2').text()).toBe('Verify your email address'); + expect(button(wrapper, 'Verify')).toBeUndefined(); + expect(mockExecute).toHaveBeenCalledTimes(1); + expect(mockExecute.mock.calls[0][0]).toMatchObject({instanceId: 3, payload: {flowType: 'SELF'}}); + }); + + it('re-reads the profile and reports the verification a custom UI submitted', async () => { + const onComplete: Mock = vi.fn(); + mockExecute.mockResolvedValueOnce(codeSent()).mockResolvedValueOnce(verified); + const wrapper: VueWrapper = mountVerify( + {attribute: 'workEmail', onComplete}, + userSchema, + {}, + {default: customSlot({})}, + ); + await settle(); + + await wrapper.find('input').setValue('123456'); + await button(wrapper, 'Check')!.trigger('click'); + await settle(); + + expect((mockExecute.mock.calls[1][0] as {payload: unknown}).payload).toEqual({ + action: 'action_email_verify', + challengeToken: 'ct-1', + executionId: 'exec-1', + inputs: {otp: '123456'}, + }); + expect(wrapper.text()).toContain('Work Email verified.'); + expect(revalidateProfile).toHaveBeenCalledTimes(1); + expect(onComplete).toHaveBeenCalledWith(verified); + }); + + it('re-reads the profile when a custom UI confirms a link', async () => { + const onDone: Mock = vi.fn(); + mockExecute.mockResolvedValue(linkSent); + const wrapper: VueWrapper = mountVerify( + {attribute: 'workEmail', onDone}, + userSchema, + {}, + {default: customSlot({})}, + ); + await settle(); + + await button(wrapper, 'Opened it')!.trigger('click'); + await settle(); + + expect(revalidateProfile).toHaveBeenCalledTimes(1); + expect(onDone).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/packages/vue/src/__tests__/components/auth/sign-in.test.ts b/packages/vue/src/__tests__/components/auth/sign-in.test.ts index 745a7435..0edc5764 100644 --- a/packages/vue/src/__tests__/components/auth/sign-in.test.ts +++ b/packages/vue/src/__tests__/components/auth/sign-in.test.ts @@ -1,10 +1,11 @@ // Copyright 2026 The ThunderID Authors // SPDX-License-Identifier: Apache-2.0 -import {ThunderIDRuntimeError} from '@thunderid/browser'; +import {EmbeddedFlowComponent, ThunderIDRuntimeError} from '@thunderid/browser'; import {flushPromises, mount} from '@vue/test-utils'; import {describe, expect, it, vi, type Mock} from 'vitest'; -import {h, ref} from 'vue'; +import {defineComponent, h, ref, type VNode} from 'vue'; +import {renderSignInComponents, renderVerificationComponents} from '../../../components/auth/flow/AuthOptionFactory'; import BaseSignIn from '../../../components/auth/sign-in/BaseSignIn'; import SignIn from '../../../components/auth/sign-in/SignIn'; import {FLOW_KEY, FLOW_META_KEY, I18N_KEY, THUNDERID_KEY} from '../../../keys'; @@ -161,3 +162,49 @@ describe('SignIn', () => { expect(unhandled).not.toHaveBeenCalled(); }); }); + +describe('AuthOptionFactory RESEND', () => { + const resend: EmbeddedFlowComponent = { + eventType: 'SUBMIT', + id: 'action_resend', + label: 'Resend code', + type: 'RESEND', + } as EmbeddedFlowComponent; + + const codeBlock = (withResend: boolean): EmbeddedFlowComponent => + ({ + components: [ + {id: 'input_otp', label: 'Code', ref: 'otp', required: true, type: 'OTP_INPUT'}, + {eventType: 'SUBMIT', id: 'action_verify', label: 'Verify', type: 'ACTION', variant: 'PRIMARY'}, + ...(withResend ? [resend] : []), + ], + id: 'block_otp', + type: 'BLOCK', + }) as EmbeddedFlowComponent; + + const renderHtml = (render: typeof renderSignInComponents, withResend: boolean): string => { + const nodes: VNode[] = render( + [codeBlock(withResend)], + {}, + {}, + {}, + false, + false, + () => undefined, + () => undefined, + {onSubmit: vi.fn()}, + ); + return mount(defineComponent({render: (): VNode => h('div', nodes)})).html(); + }; + + it('is skipped by sign-in, which renders the step as if it had no resend', () => { + const html: string = renderHtml(renderSignInComponents, true); + + expect(html).not.toContain('Resend code'); + expect(html).toBe(renderHtml(renderSignInComponents, false)); + }); + + it('is rendered for attribute verification', () => { + expect(renderHtml(renderVerificationComponents, true)).toContain('Resend code'); + }); +}); diff --git a/packages/vue/src/api/auth/executeEmbeddedSelfFlow.ts b/packages/vue/src/api/auth/executeEmbeddedSelfFlow.ts new file mode 100644 index 00000000..9af34dd5 --- /dev/null +++ b/packages/vue/src/api/auth/executeEmbeddedSelfFlow.ts @@ -0,0 +1,40 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import { + EmbeddedSelfFlowResponse, + createHttpClientFetcher, + executeEmbeddedSelfFlow as baseExecuteEmbeddedSelfFlow, + ExecuteEmbeddedSelfFlowConfig as BaseExecuteEmbeddedSelfFlowConfig, +} from '@thunderid/browser'; + +export interface ExecuteEmbeddedSelfFlowConfig extends Omit { + /** + * Optional custom fetcher function. If not provided, the ThunderID SPA client's httpClient is used, + * which attaches the signed-in user's access token. + */ + fetcher?: (url: string, config: RequestInit) => Promise; + /** + * Optional instance ID for multi-instance support. Defaults to 0. + */ + instanceId?: number; +} + +/** + * Executes one step of a `SELF` flow for the signed-in user, through the authenticated + * transport that attaches the access token the flow identifies the user by. + * + * @param config - Configuration object with URL, payload and optional request config. + * @returns A promise that resolves with the flow step. + */ +const executeEmbeddedSelfFlow = async ({ + fetcher, + instanceId = 0, + ...requestConfig +}: ExecuteEmbeddedSelfFlowConfig): Promise => + baseExecuteEmbeddedSelfFlow({ + ...requestConfig, + fetcher: fetcher ?? createHttpClientFetcher(instanceId), + }); + +export default executeEmbeddedSelfFlow; diff --git a/packages/vue/src/components/account/user-profile/BaseUserProfile.ts b/packages/vue/src/components/account/user-profile/BaseUserProfile.ts index 34945f08..2737dabd 100644 --- a/packages/vue/src/components/account/user-profile/BaseUserProfile.ts +++ b/packages/vue/src/components/account/user-profile/BaseUserProfile.ts @@ -1,8 +1,36 @@ // Copyright 2025 The ThunderID Authors // SPDX-License-Identifier: Apache-2.0 -import {AttributeSchema, Preferences, type User, startCase, withVendorCSSClassPrefix} from '@thunderid/browser'; -import {type Component, type PropType, type Ref, type SetupContext, type VNode, defineComponent, h, ref} from 'vue'; +import { + AttributeSchema, + AttributeVerificationConstants, + Preferences, + type User, + type VerifiedAttribute, + getDefaultI18nBundles, + getVerifiedAttributes, + isVerifiableAttribute, + startCase, + substituteTranslationParams, + withVendorCSSClassPrefix, +} from '@thunderid/browser'; +import { + type Component, + Fragment, + type PropType, + type Ref, + type SetupContext, + Teleport, + type VNode, + defineComponent, + h, + inject, + nextTick, + ref, + watch, +} from 'vue'; +import {I18N_KEY} from '../../../keys'; +import type {I18nContextValue} from '../../../models/contexts'; import getDisplayName from '../../../utils/getDisplayName'; import getMappedUserProfileValue from '../../../utils/getMappedUserProfileValue'; import Alert from '../../primitives/Alert'; @@ -11,7 +39,7 @@ import Card from '../../primitives/Card'; import Checkbox from '../../primitives/Checkbox'; import DatePicker from '../../primitives/DatePicker'; import Divider from '../../primitives/Divider'; -import {PencilIcon} from '../../primitives/Icons'; +import {CircleAlertIcon, CircleCheckIcon, PencilIcon} from '../../primitives/Icons'; import Spinner from '../../primitives/Spinner'; import TextField from '../../primitives/TextField'; import Typography from '../../primitives/Typography'; @@ -55,6 +83,48 @@ export interface BaseUserProfileProps { userSchema?: Record | null; } +/** + * Slot props of the `verification` slot, which renders the verification panel for an attribute. + * Without the slot, or when the profile is not editable, statuses are shown without a Verify action. + */ +export interface BaseUserProfileVerificationSlotProps { + /** Name of the attribute to verify. */ + attribute: string; + /** Label of the attribute. */ + attributeDisplayName: string; + /** Closes the panel. */ + onCancel: () => void; + /** Closes the panel and confirms the recorded verification. */ + onComplete: () => void; +} + +/** + * Verification state and actions added to the default slot's props, so a custom profile can show + * each attribute's status and start a verification. + */ +export interface BaseUserProfileVerificationState { + /** + * Whether the user can verify the attribute now: the profile is editable, the server reports + * verification, and the attribute is an unverified `email` or `mobile` property with a stored value. + */ + canVerify: (attribute: string) => boolean; + /** Closes the open verification. */ + cancelVerification: () => void; + /** Renders the `verification` slot for the open attribute, or nothing when none is open. */ + renderVerification: () => VNode | null; + /** Opens the verification of an attribute that `canVerify` allows. */ + verify: (attribute: string) => void; + /** + * The attribute's status: `verified` or `unverified` for an `email` or `mobile` property with a stored + * value, else `null`, also when the server does not report verification. + */ + verificationStatus: (attribute: string) => 'verified' | 'unverified' | null; + /** Verified attributes as the server reports them, with `verifiedAt`; `undefined` when it does not. */ + verifiedAttributes: Record | undefined; + /** The attribute whose verification is open. */ + verifyingAttribute: string | null; +} + // ─── Constants ─────────────────────────────────────────────────────────────── const FIELDS_TO_SKIP: string[] = [ @@ -78,6 +148,7 @@ const FIELDS_TO_SKIP: string[] = [ 'attributes', 'isReadOnly', 'isReadonly', + AttributeVerificationConstants.VERIFIED_ATTRIBUTES_KEY, ]; const READONLY_FIELDS: string[] = [ @@ -113,6 +184,18 @@ const AVATAR_GRADIENTS: string[] = [ // ─── Helpers ───────────────────────────────────────────────────────────────── +const isVerificationType = (type?: string): boolean => + !!type && AttributeVerificationConstants.VERIFIABLE_ATTRIBUTE_TYPES.includes(type.toLowerCase()); + +const isVerifiedAfterSave = (type?: string): boolean => + !!type && AttributeVerificationConstants.VERIFY_AFTER_SAVE_ATTRIBUTE_TYPES.includes(type.toLowerCase()); + +// The stored value of an attribute: `/users/me` nests attributes and may also spread them to the top level. +const storedValueOf = (user: User | null | undefined, name: string): unknown => { + const attributes: Record | undefined = user?.['attributes'] as Record | undefined; + return attributes?.[name] ?? user?.[name]; +}; + function getAvatarGradient(seed: string): string { if (!seed) return AVATAR_GRADIENTS[0]; let hash = 0; @@ -200,6 +283,77 @@ const BaseUserProfile: Component = defineComponent({ return fallback ?? key; }; + // Translates with parameters. UserProfile passes the i18n `t`, which takes them; without it, or for + // a key it lacks, the default bundle answers. + const tp = (key: string, params: Record = {}): string => { + const translate = props.t as ((key: string, params?: Record) => string) | undefined; + const res: string | undefined = translate?.(key, params); + if (res && res !== key) return res; + const fallback: string | undefined = ( + getDefaultI18nBundles()['en-US']?.translations as unknown as Record | undefined + )?.[key]; + return fallback ? substituteTranslationParams(fallback, params) : key; + }; + + // ── Verification ────────────────────────────────────────────────────────── + + const i18n: I18nContextValue | null = inject(I18N_KEY, null); + + // Attribute whose verification panel is open. + const verifyingField: Ref = ref(null); + // Label of the attribute whose verification was just recorded. + const verifiedNotice: Ref = ref(null); + const verificationDialogRef: Ref = ref(null); + // Element focused before the dialog opened, focused again once it closes. + let focusBeforeDialog: HTMLElement | null = null; + + const openVerification = (name: string): void => { + verifiedNotice.value = null; + verifyingField.value = name; + }; + + // A compact profile already sits in a modal, so its panel opens under the row instead of in a dialog. + watch(verifyingField, (name: string | null, previous: string | null): void => { + if (props.compact) return; + if (name && !previous) { + focusBeforeDialog = document.activeElement as HTMLElement | null; + void nextTick((): void => verificationDialogRef.value?.focus()); + } else if (!name && previous) { + focusBeforeDialog?.focus(); + focusBeforeDialog = null; + } + }); + // A saved attribute to verify once the profile reflects the change. + const pendingVerification: Ref<{name: string; previousValue: unknown} | null> = ref(null); + + const storedUser = (): User | null => props.flattenedProfile ?? props.profile ?? null; + // A server without verification support omits the field, so no status is known. + const reportedVerifiedAttributes = (): Record | undefined => + getVerifiedAttributes(props.profile ?? props.flattenedProfile); + + watch( + () => [props.profile, props.flattenedProfile, props.error] as const, + () => { + const pending: {name: string; previousValue: unknown} | null = pendingVerification.value; + if (!pending) return; + + if (props.error) { + pendingVerification.value = null; + return; + } + + const storedValue: unknown = storedValueOf(storedUser(), pending.name); + if (storedValue === pending.previousValue) return; + + const verified: Record | undefined = reportedVerifiedAttributes(); + // An absent verification record leaves the status unknown, so nothing opens on that basis. + if (verified && typeof storedValue === 'string' && storedValue.trim() !== '' && !verified[pending.name]) { + openVerification(pending.name); + } + pendingVerification.value = null; + }, + ); + // ── Visibility ──────────────────────────────────────────────────────────── function shouldShowField(fieldName: string, isSchemaBased = false): boolean { @@ -217,6 +371,13 @@ const BaseUserProfile: Component = defineComponent({ fieldErrors.value = {...fieldErrors.value, [fieldName]: ''}; } + // Drops a field's edited value, so the row shows the stored one again. + function clearEditedValue(fieldName: string): void { + const next: Record = {...editedValues.value}; + delete next[fieldName]; + editedValues.value = next; + } + function cancelEditing(fieldName: string): void { const data: User | null = props.flattenedProfile ?? props.profile ?? null; const originalValue: any = @@ -273,14 +434,182 @@ const BaseUserProfile: Component = defineComponent({ schema.schemaId, schema.multiValued, ); + // Offer verification once a changed, verifiable email is saved. + const previousValue: unknown = storedValueOf(storedUser(), fieldName); + pendingVerification.value = + slots['verification'] && + isVerifiedAfterSave(schema.type) && + isVerifiableAttribute(props.userSchema, fieldName) && + typeof submitVal === 'string' && + submitVal !== '' && + submitVal !== previousValue + ? {name: fieldName, previousValue} + : null; + try { await props.onUpdate(payload); editingFields.value = {...editingFields.value, [fieldName]: false}; + // The row shows the stored value from here on, never the typed one, even if the save failed. + clearEditedValue(fieldName); } catch { // Keep field in editing mode on error so user input is preserved + pendingVerification.value = null; } } + function renderVerificationStatus(name: string): VNode { + const verification: VerifiedAttribute | undefined = reportedVerifiedAttributes()?.[name]; + let verifiedAtText: string | undefined; + + if (verification?.verifiedAt) { + const verifiedAt: Date = new Date(verification.verifiedAt); + const date: string = Number.isNaN(verifiedAt.getTime()) + ? verification.verifiedAt + : verifiedAt.toLocaleString(i18n?.currentLanguage?.value ?? props.preferences?.i18n?.language); + verifiedAtText = tp('user.verification.status.verified_at', {date}); + } + + const modifier: string = verification ? 'verified' : 'unverified'; + + return h( + 'span', + { + class: [px('user-profile__verification-status'), px(`user-profile__verification-status--${modifier}`)].join( + ' ', + ), + 'data-testid': `thunderid-verification-status-${name}`, + title: verifiedAtText, + }, + [ + h(verification ? CircleCheckIcon : CircleAlertIcon), + tp(`user.verification.status.${modifier}`), + // A title is not reachable by keyboard or touch, so the time is also given as text. + verifiedAtText ? h('span', {class: px('user-profile__visually-hidden')}, ` (${verifiedAtText})`) : null, + ], + ); + } + + // Status of a stored `email` or `mobile` value, for a custom render. + const verificationStatusOf = (name: string): 'verified' | 'unverified' | null => { + const verified: Record | undefined = reportedVerifiedAttributes(); + const stored: unknown = storedValueOf(storedUser(), name); + if (!verified || !isVerifiableAttribute(props.userSchema, name)) return null; + if (typeof stored !== 'string' || stored.trim() === '') return null; + return verified[name] ? 'verified' : 'unverified'; + }; + + const verificationRenderProps = (): BaseUserProfileVerificationState => { + const canVerify = (name: string): boolean => + Boolean(props.editable) && !!slots['verification'] && verificationStatusOf(name) === 'unverified'; + + return { + canVerify, + cancelVerification: (): void => { + verifyingField.value = null; + }, + renderVerification: (): VNode | null => { + const name: string | null = verifyingField.value; + if (!name) return null; + return buildVerificationPanel({displayName: props.userSchema?.[name]?.displayName, name}); + }, + verificationStatus: verificationStatusOf, + verifiedAttributes: reportedVerifiedAttributes(), + verify: (name: string): void => { + if (canVerify(name)) openVerification(name); + }, + verifyingAttribute: verifyingField.value, + }; + }; + + function buildVerificationPanel(schema: ExtendedSchema): VNode | null { + const name: string | undefined = schema.name; + if (!slots['verification'] || !name || verifyingField.value !== name) return null; + + const attributeDisplayName: string = schema.displayName ?? startCase(name); + + return h( + 'div', + { + class: [px('user-profile__verification'), props.compact ? px('user-profile__verification--inline') : ''] + .filter(Boolean) + .join(' '), + }, + slots['verification']({ + attribute: name, + attributeDisplayName, + onCancel: (): void => { + verifyingField.value = null; + }, + onComplete: (): void => { + verifyingField.value = null; + verifiedNotice.value = attributeDisplayName; + }, + } as BaseUserProfileVerificationSlotProps), + ); + } + + // Keeps Tab inside the dialog and closes it on Escape. + function handleDialogKeydown(event: KeyboardEvent): void { + if (event.key === 'Escape') { + event.stopPropagation(); + verifyingField.value = null; + return; + } + if (event.key !== 'Tab' || !verificationDialogRef.value) return; + const focusables: HTMLElement[] = Array.from( + verificationDialogRef.value.querySelectorAll( + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])', + ), + ); + if (focusables.length === 0) return; + const first: HTMLElement = focusables[0]; + const last: HTMLElement = focusables[focusables.length - 1]; + if ( + event.shiftKey && + (document.activeElement === first || document.activeElement === verificationDialogRef.value) + ) { + event.preventDefault(); + last.focus(); + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault(); + first.focus(); + } + } + + function renderVerificationPanel(schema: ExtendedSchema): VNode | null { + const panel: VNode | null = buildVerificationPanel(schema); + if (!panel || props.compact) return panel; + + return h(Teleport, {to: 'body'}, [ + h( + 'div', + { + class: px('user-profile__verification-overlay'), + onClick: (event: MouseEvent): void => { + if (event.target === event.currentTarget) verifyingField.value = null; + }, + }, + [ + h( + 'div', + { + 'aria-label': tp('user.verification.heading', { + attribute: schema.displayName ?? startCase(schema.name!), + }), + 'aria-modal': 'true', + class: px('user-profile__verification-dialog'), + onKeydown: handleDialogKeydown, + ref: verificationDialogRef, + role: 'dialog', + tabindex: -1, + }, + [panel], + ), + ], + ), + ]); + } + // ── Input rendering per schema type ─────────────────────────────────────── function renderInput(schema: ExtendedSchema): VNode { @@ -333,6 +662,21 @@ const BaseUserProfile: Component = defineComponent({ if (!hasValue && !isEditing && !(isEditable && mutability === 'READ_WRITE')) return null; + // Status describes the stored value, which the row shows whenever it is not being edited: a save + // keeps the field in edit mode until it settles, then drops the typed value. + const verified: Record | undefined = reportedVerifiedAttributes(); + const showsVerificationStatus: boolean = + verified !== undefined && hasValue && !isEditing && isVerificationType(schema.type); + // A read-only profile shows the status only. + const canVerify: boolean = + Boolean(props.editable) && + !!slots['verification'] && + showsVerificationStatus && + !verified?.[name] && + verifyingField.value !== name && + isVerifiableAttribute(props.userSchema, name) && + typeof value === 'string'; + const editablePlaceholder: VNode | null = isEditable ? h( 'span', @@ -355,7 +699,24 @@ const BaseUserProfile: Component = defineComponent({ : null, ]) : displayValueNode, + showsVerificationStatus ? renderVerificationStatus(name) : null, ]), + canVerify + ? h('div', {class: px('user-profile__field-actions')}, [ + h( + Button, + { + class: px('user-profile__verify-button'), + color: 'primary' as const, + 'data-testid': `thunderid-verify-${name}`, + onClick: () => openVerification(name), + size: 'small' as const, + variant: 'outline' as const, + }, + () => tp('user.verification.buttons.verify.text'), + ), + ]) + : null, isEditable && !isReadonly ? h('div', {class: px('user-profile__field-actions')}, [ isEditing @@ -385,7 +746,11 @@ const BaseUserProfile: Component = defineComponent({ ? h(Button, { class: px('user-profile__field-edit-btn'), color: 'secondary' as const, - onClick: () => startEditing(name, value), + onClick: () => { + if (verifyingField.value === name) verifyingField.value = null; + verifiedNotice.value = null; + startEditing(name, value); + }, size: 'small' as const, startIcon: h(PencilIcon), title: t('userProfile.actions.edit', 'Edit'), @@ -476,12 +841,22 @@ const BaseUserProfile: Component = defineComponent({ if (!data && !props.isLoading) { return slots['default'] - ? slots['default']({error: props.error, isLoading: props.isLoading, profile: null}) + ? slots['default']({ + error: props.error, + isLoading: props.isLoading, + profile: null, + ...verificationRenderProps(), + }) : null; } if (slots['default']) { - return slots['default']({error: props.error, isLoading: props.isLoading, profile: data}); + return slots['default']({ + error: props.error, + isLoading: props.isLoading, + profile: data, + ...verificationRenderProps(), + }); } const currentUser: Record = data as Record; @@ -508,6 +883,21 @@ const BaseUserProfile: Component = defineComponent({ children.push(h(Alert, {class: px('user-profile__error'), severity: 'error' as const}, () => props.error)); } + if (verifiedNotice.value) { + const attribute: string = verifiedNotice.value; + children.push( + h( + Alert, + { + class: px('user-profile__verification-notice'), + 'data-testid': 'thunderid-verification-notice', + severity: 'success' as const, + }, + () => tp('user.verification.success', {attribute}), + ), + ); + } + // Fields if (props.isLoading) { children.push(h('div', {class: px('user-profile__loading')}, [h(Spinner)])); @@ -535,7 +925,11 @@ const BaseUserProfile: Component = defineComponent({ }); const fieldRows: VNode[] = metaSchemas - .map((schema: ExtendedSchema) => renderSchemaFieldRow(schema)) + .map((schema: ExtendedSchema) => { + const row: VNode | null = renderSchemaFieldRow(schema); + const panel: VNode | null = row ? renderVerificationPanel(schema) : null; + return panel ? h(Fragment, {key: schema.name}, [row, panel]) : row; + }) .filter((node: VNode | null): node is VNode => node !== null); children.push(h('div', {class: px('user-profile__fields')}, fieldRows)); diff --git a/packages/vue/src/components/account/user-profile/UserProfile.css.ts b/packages/vue/src/components/account/user-profile/UserProfile.css.ts index 872a9127..9e7d8c0d 100644 --- a/packages/vue/src/components/account/user-profile/UserProfile.css.ts +++ b/packages/vue/src/components/account/user-profile/UserProfile.css.ts @@ -229,6 +229,87 @@ img.thunderid-user-profile__avatar { gap: calc(var(--thunderid-spacing-unit) * 0.5); } +.thunderid-user-profile__verification-status { + display: inline-flex; + flex-shrink: 0; + gap: calc(var(--thunderid-spacing-unit) * 0.5); + align-items: center; + padding: 0 var(--thunderid-spacing-unit); + border-radius: 999px; + font-size: 0.75rem; + font-weight: 500; + line-height: 22px; + white-space: nowrap; +} + +.thunderid-user-profile__verification-status svg { + width: 12px; + height: 12px; +} + +.thunderid-user-profile__verification-status--verified { + color: var(--thunderid-color-success-dark, var(--thunderid-color-success-main)); + background: color-mix(in srgb, var(--thunderid-color-success-main) 12%, transparent); +} + +/* Amber marks an action the user can take. */ +.thunderid-user-profile__verification-status--unverified { + color: var(--thunderid-color-warning-contrastText); + background: color-mix(in srgb, var(--thunderid-color-warning-main) 14%, transparent); +} + +.thunderid-user-profile__verification { + padding: var(--thunderid-spacing-unit) 0; +} + +/* Starts where the value column starts: the label's width plus the row gap. */ +.thunderid-user-profile__verification--inline { + margin-inline-start: calc(120px + var(--thunderid-spacing-unit)); +} + +.thunderid-user-profile__verification-overlay { + position: fixed; + inset: 0; + z-index: 9999; + display: flex; + align-items: center; + justify-content: center; + background-color: rgba(0, 0, 0, 0.45); +} + +.thunderid-user-profile__verification-dialog { + box-sizing: border-box; + width: min(448px, calc(100vw - calc(var(--thunderid-spacing-unit) * 4))); + max-height: 90vh; + overflow-y: auto; + padding: calc(var(--thunderid-spacing-unit) * 2); + background: var(--thunderid-color-background-surface); + border-radius: var(--thunderid-border-radius-large); + box-shadow: var(--thunderid-shadow-large); + font-family: var(--thunderid-typography-fontFamily); + outline: none; +} + +.thunderid-user-profile__verification-dialog > .thunderid-user-profile__verification { + padding: 0; +} + +.thunderid-user-profile__verification-notice { + margin-bottom: calc(var(--thunderid-spacing-unit) * 2); +} + +.thunderid-user-profile__visually-hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + .thunderid-user-profile__field-error { color: var(--thunderid-color-error, #d32f2f); font-size: var(--thunderid-typography-fontSize-xs, 0.8rem); diff --git a/packages/vue/src/components/account/user-profile/UserProfile.ts b/packages/vue/src/components/account/user-profile/UserProfile.ts index 9dfb114f..9539b8a7 100644 --- a/packages/vue/src/components/account/user-profile/UserProfile.ts +++ b/packages/vue/src/components/account/user-profile/UserProfile.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { + AttributeVerificationConstants, Preferences, ThunderIDError, User, @@ -20,11 +21,12 @@ import { ref, type Ref, } from 'vue'; -import BaseUserProfile from './BaseUserProfile'; +import BaseUserProfile, {type BaseUserProfileVerificationSlotProps} from './BaseUserProfile'; import updateMeProfile from '../../../api/account/updateMeProfile'; import useI18n from '../../../composables/useI18n'; import useThunderID from '../../../composables/useThunderID'; import useUser from '../../../composables/useUser'; +import VerifyAttribute from '../verify-attribute/VerifyAttribute'; type UserProfileProps = Readonly<{ avatarSize: 'sm' | 'md' | 'lg'; @@ -38,6 +40,7 @@ type UserProfileProps = Readonly<{ showAvatar: boolean; showFields: string[]; title: string; + verificationFlowHandle: string; }>; const UserProfile: Component = defineComponent({ @@ -74,6 +77,8 @@ const UserProfile: Component = defineComponent({ showFields: {default: () => [], type: Array as PropType}, /** Card header title. */ title: {default: 'Profile', type: String}, + /** Handle of the `SELF` flow that verifies an attribute. */ + verificationFlowHandle: {default: AttributeVerificationConstants.DEFAULT_FLOW_HANDLE, type: String}, }, setup(props: UserProfileProps, {slots}: SetupContext): () => VNode { const {baseUrl, endpoints, instanceId, preferences: contextPreferences} = useThunderID(); @@ -139,6 +144,25 @@ const UserProfile: Component = defineComponent({ } } + const renderVerification = ({ + attribute, + attributeDisplayName, + onCancel, + onComplete, + }: BaseUserProfileVerificationSlotProps): VNode => + h(VerifyAttribute, { + attribute, + attributeDisplayName, + flowHandle: props.verificationFlowHandle, + key: attribute, + onCancel, + onComplete, + onDone: onCancel, + preferences: resolvedPreferences.value, + // A compact profile shows the panel under the row as a card; otherwise it opens in a dialog. + variant: props.compact ? 'outlined' : 'flat', + }); + return (): VNode => h( BaseUserProfile, @@ -162,7 +186,7 @@ const UserProfile: Component = defineComponent({ title: props.title, userSchema: userSchema?.value, }, - slots, + isEditableProfile.value ? {verification: renderVerification, ...slots} : slots, ); }, }); diff --git a/packages/vue/src/components/account/verify-attribute/BaseVerifyAttribute.ts b/packages/vue/src/components/account/verify-attribute/BaseVerifyAttribute.ts new file mode 100644 index 00000000..5eb2d258 --- /dev/null +++ b/packages/vue/src/components/account/verify-attribute/BaseVerifyAttribute.ts @@ -0,0 +1,533 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import { + AttributeVerificationConstants, + EmbeddedFlowComponent, + EmbeddedSelfFlowRequest, + EmbeddedSelfFlowResponse, + EmbeddedSelfFlowStatus, + FieldError, + FlowMetadataResponse, + FlowStepComponents, + Preferences, + buildValidatorFromRules, + getFlowStepComponents, + resolveFlowTemplateLiterals, + withVendorCSSClassPrefix, +} from '@thunderid/browser'; +import { + type Component, + type PropType, + type Ref, + type SetupContext, + type VNode, + type VNodeArrayChildren, + cloneVNode, + defineComponent, + h, + isVNode, + ref, + watch, +} from 'vue'; +import useFlowMeta from '../../../composables/useFlowMeta'; +import {extractErrorMessage, normalizeFlowResponse} from '../../../utils/flowTransformer'; +import getAuthComponentHeadings, {type AuthComponentHeadingsResult} from '../../../utils/getAuthComponentHeadings'; +import useAdminTranslation from '../../admin/shared/useAdminTranslation'; +import {renderVerificationComponents} from '../../auth/flow/AuthOptionFactory'; +import Alert from '../../primitives/Alert'; +import Button from '../../primitives/Button'; +import Spinner from '../../primitives/Spinner'; +import Typography from '../../primitives/Typography'; + +type VerificationState = 'loading' | 'active' | 'complete' | 'failed'; + +// Flow identifiers of the step's inputs (`ref`, else `id`) and actions (`id`). +const collectFlowIds = ({actions, inputs}: FlowStepComponents): {actions: Set; fields: Set} => ({ + actions: new Set(actions.map((component: EmbeddedFlowComponent) => component.id)), + fields: new Set(inputs.map((component: EmbeddedFlowComponent) => component.ref ?? component.id)), +}); + +// Tags the nodes rendered from the flow with `thunderid-field-` and `thunderid-action-`. +// The shared factory keys a node by its component id and names an input by its ref. +const withFlowTestIds = (node: VNode, ids: {actions: Set; fields: Set}): VNode => { + const name: unknown = node.props?.['name']; + let testId: string | undefined; + if (typeof name === 'string' && ids.fields.has(name)) { + testId = `thunderid-field-${name}`; + } else if (node.key !== null && ids.actions.has(String(node.key))) { + testId = `thunderid-action-${String(node.key)}`; + } + + const children: VNode['children'] = Array.isArray(node.children) + ? (node.children.map((child: unknown) => + isVNode(child) ? withFlowTestIds(child, ids) : child, + ) as VNodeArrayChildren) + : node.children; + if (!testId && children === node.children) return node; + + const tagged: VNode = cloneVNode(node, testId ? {'data-testid': testId} : null); + tagged.children = children; + return tagged; +}; + +/** + * Slot props of the default scoped slot, so the steps can be drawn with any UI. + */ +export interface BaseVerifyAttributeRenderProps { + additionalData: Record; + /** The step's components, without its headings. */ + components: EmbeddedFlowComponent[]; + /** + * Masked destination the code or link was sent to, when the flow reports it. + */ + destination: string | null; + /** Translated "Sent to …" line, set when the flow has its own prompt in `subtitle`. */ + destinationText: string | null; + /** Translated error of the last request, or the refusal. */ + error: string | null; + fieldErrors: Record; + handleInputChange: (name: string, value: string) => void; + /** Sends a new code. Set only while the step has a resend action. */ + handleResend?: () => Promise; + /** + * Submits a step. Pass `skipValidation` for an action, such as a resend, that sends no inputs. + */ + handleSubmit: ( + component: EmbeddedFlowComponent, + data?: Record, + skipValidation?: boolean, + ) => Promise; + isAwaitingLink: boolean; + isComplete: boolean; + /** Whether the server refused the verification or the request failed; the flow cannot continue. */ + isFailed: boolean; + isLoading: boolean; + isValid: boolean; + /** Closes the panel. Set only when the app passes `onCancel`. */ + onCancel?: () => void; + /** + * Confirms a link the user opened outside the app. Set only while the step waits for a link and the + * app passes `onDone`. + */ + onDone?: () => void; + subtitle: string; + /** Translated message for a recorded verification. */ + successMessage: string; + title: string; + touched: Record; + values: Record; +} + +export interface BaseVerifyAttributeProps { + /** Label of the attribute being verified, used in the default heading and success message. */ + attributeDisplayName?: string; + buttonClassName?: string; + className?: string; + inputClassName?: string; + isInitialized?: boolean; + /** Called when the user closes the panel. The close action is shown only when this is set. */ + onCancel?: () => void; + /** Called once the server has recorded the verification. */ + onComplete?: (response: EmbeddedSelfFlowResponse) => void; + /** + * Called when the user selects Done after the flow sent a link, which they confirm outside the app. + * The Done action is shown only when this is set. + */ + onDone?: () => void; + /** Called when the server refuses the verification or the request fails. */ + onError?: (error: Error) => void; + onFlowChange?: (response: EmbeddedSelfFlowResponse) => void; + onInitialize?: () => Promise; + onSubmit?: (payload: EmbeddedSelfFlowRequest) => Promise; + /** Component-level preferences to override global i18n and theme settings. */ + preferences?: Preferences; + showSubtitle?: boolean; + showTitle?: boolean; + size?: 'small' | 'medium' | 'large'; + variant?: 'outlined' | 'flat'; +} + +/** + * BaseVerifyAttribute renders the server-described steps of a self attribute verification flow. + * It takes the flow calls as props, so it stays independent of how the request reaches the server. + * + * It starts the flow once per mount, since starting one sends a message, and never retries a start + * that failed. + */ +const BaseVerifyAttribute: Component = defineComponent({ + name: 'BaseVerifyAttribute', + props: { + attributeDisplayName: {default: '', type: String}, + buttonClassName: {default: '', type: String}, + className: {default: '', type: String}, + inputClassName: {default: '', type: String}, + isInitialized: {default: true, type: Boolean}, + onCancel: {default: undefined, type: Function as PropType<() => void>}, + onComplete: {default: undefined, type: Function as PropType<(response: EmbeddedSelfFlowResponse) => void>}, + onDone: {default: undefined, type: Function as PropType<() => void>}, + onError: {default: undefined, type: Function as PropType<(error: Error) => void>}, + onFlowChange: { + default: undefined, + type: Function as PropType<(response: EmbeddedSelfFlowResponse) => void>, + }, + onInitialize: {default: undefined, type: Function as PropType<() => Promise>}, + onSubmit: { + default: undefined, + type: Function as PropType<(payload: EmbeddedSelfFlowRequest) => Promise>, + }, + preferences: {default: undefined, type: Object as PropType}, + showSubtitle: {default: true, type: Boolean}, + showTitle: {default: true, type: Boolean}, + size: {default: 'medium', type: String as PropType<'small' | 'medium' | 'large'>}, + variant: {default: 'outlined', type: String as PropType<'outlined' | 'flat'>}, + }, + setup(props: BaseVerifyAttributeProps, {slots}: SetupContext): () => VNode { + const {meta: flowMetaRef} = useFlowMeta(); + const t: ReturnType = useAdminTranslation(() => props.preferences); + + const state: Ref = ref('loading'); + const isSubmitting: Ref = ref(false); + const currentFlow: Ref = ref(null); + const error: Ref = ref(null); + + const formValues: Ref> = ref({}); + const touchedFields: Ref> = ref({}); + const formErrors: Ref> = ref({}); + const isFormValid: Ref = ref(true); + + // Guards against a second initiation, which would send a second code. + let initializationAttempted = false; + let challengeToken: string | null = null; + + const meta = (): FlowMetadataResponse | null => (flowMetaRef as Ref)?.value ?? null; + const labelParams = (): Record => ({attribute: props.attributeDisplayName ?? ''}); + + const normalize = (response: EmbeddedSelfFlowResponse): EmbeddedSelfFlowResponse => { + if (!response?.data || Array.isArray(response.data.components)) { + return response; + } + + const {components} = normalizeFlowResponse( + response, + t, + {defaultErrorKey: 'user.verification.error', resolveTranslations: false, throwOnError: false}, + meta(), + ); + + return {...response, data: {...response.data, components}}; + }; + + const inputs = (): EmbeddedFlowComponent[] => + getFlowStepComponents(currentFlow.value?.data?.components ?? []).inputs; + + const validate = (): boolean => { + const errors: Record = {}; + const touched: Record = {}; + + inputs().forEach((component: EmbeddedFlowComponent) => { + const name: string = component.ref ?? component.id; + const value: string = formValues.value[name] ?? ''; + const ruleValidator: ((value: string) => string | null) | null = buildValidatorFromRules(component.validation); + touched[name] = true; + + if (component.required && value.trim() === '') { + errors[name] = t('validations.required.field.error'); + } else if (ruleValidator && value) { + const ruleMessage: string | null = ruleValidator(value); + if (ruleMessage) errors[name] = t(ruleMessage); + } + }); + + touchedFields.value = touched; + formErrors.value = errors; + isFormValid.value = Object.keys(errors).length === 0; + + return isFormValid.value; + }; + + const resetForm = (): void => { + formValues.value = {}; + touchedFields.value = {}; + isFormValid.value = true; + + // Project server-side field errors into form state. + const errors: Record = {}; + const touched: Record = {}; + (currentFlow.value?.data?.fieldErrors ?? []).forEach((fieldError: FieldError) => { + if (!(fieldError.identifier in errors)) { + errors[fieldError.identifier] = fieldError.message; + touched[fieldError.identifier] = true; + } + }); + formErrors.value = errors; + touchedFields.value = touched; + }; + + const handleResponse = (rawResponse: EmbeddedSelfFlowResponse): void => { + const response: EmbeddedSelfFlowResponse = normalize(rawResponse); + props.onFlowChange?.(response); + + if (response.challengeToken !== undefined) { + challengeToken = response.challengeToken ?? null; + } + + if (response.flowStatus === EmbeddedSelfFlowStatus.Error) { + const message: string = extractErrorMessage(response, t, 'user.verification.error'); + error.value = message; + state.value = 'failed'; + props.onError?.(new Error(message)); + return; + } + + if (response.flowStatus === EmbeddedSelfFlowStatus.Complete) { + error.value = null; + state.value = 'complete'; + props.onComplete?.(response); + return; + } + + // INCOMPLETE: render the step; a rejected code comes back with an error and without + // additionalData, so keep the destination and code length of the step that sent the code. + const previous: EmbeddedSelfFlowResponse | null = currentFlow.value; + currentFlow.value = + response.data?.additionalData || !previous?.data?.additionalData + ? response + : {...response, data: {...response.data, additionalData: previous.data.additionalData}}; + resetForm(); + error.value = response.error ? extractErrorMessage(response, t, 'user.verification.error') : null; + state.value = 'active'; + }; + + const handleFailure = (caughtError: unknown): void => { + // Show a generic message; an API error body is not meant for the user. + error.value = t('user.verification.error'); + state.value = 'failed'; + props.onError?.(caughtError instanceof Error ? caughtError : new Error(String(caughtError))); + }; + + watch( + () => props.isInitialized, + (initialized: boolean | undefined) => { + if (!initialized || initializationAttempted) return; + initializationAttempted = true; + + void (async (): Promise => { + try { + const response: EmbeddedSelfFlowResponse | undefined = await props.onInitialize?.(); + if (response) handleResponse(response); + } catch (caughtError: unknown) { + handleFailure(caughtError); + } + })(); + }, + {immediate: true}, + ); + + const handleInputChange = (name: string, value: string): void => { + formValues.value = {...formValues.value, [name]: value}; + // Revalidate after a failed submit, so a corrected value enables the action again. + if (!isFormValid.value) validate(); + }; + + const handleInputBlur = (name: string): void => { + touchedFields.value = {...touchedFields.value, [name]: true}; + }; + + const handleSubmit = async ( + component: EmbeddedFlowComponent, + data?: Record, + skipValidation?: boolean, + ): Promise => { + // A second call from a custom render while one is in flight would send a second code. + if (!currentFlow.value || isSubmitting.value) return; + if (!skipValidation && !validate()) return; + + isSubmitting.value = true; + error.value = null; + + try { + const stepInputs: Record = Object.fromEntries( + Object.entries(data ?? {}).filter( + ([, value]: [string, string]) => value !== null && value !== undefined && value !== '', + ), + ); + + const response: EmbeddedSelfFlowResponse | undefined = await props.onSubmit?.({ + executionId: currentFlow.value.executionId, + ...(component.id && {action: component.id}), + ...(challengeToken ? {challengeToken} : {}), + inputs: stepInputs, + }); + + if (response) handleResponse(response); + } catch (caughtError: unknown) { + handleFailure(caughtError); + } finally { + isSubmitting.value = false; + } + }; + + return (): VNode => { + const additionalData: Record = currentFlow.value?.data?.additionalData ?? {}; + const maskedDestination: unknown = additionalData[AttributeVerificationConstants.MASKED_DESTINATION_KEY]; + const destination: string | null = + typeof maskedDestination === 'string' && maskedDestination ? maskedDestination : null; + const headings: AuthComponentHeadingsResult = getAuthComponentHeadings( + currentFlow.value?.data?.components ?? [], + undefined, + undefined, + t('user.verification.heading', labelParams()), + t('user.verification.subheading.generic'), + ); + const {componentsWithoutHeadings} = headings; + const step: FlowStepComponents = getFlowStepComponents(componentsWithoutHeadings); + const flowIds: ReturnType = collectFlowIds(step); + // A step with nothing to enter or submit waits for the user to open a link sent to them. + const isAwaitingLink: boolean = state.value === 'active' && inputs().length === 0 && step.actions.length === 0; + const title: string = resolveFlowTemplateLiterals(headings.title, {meta: meta(), t}); + // The flow's own prompt wins; the destination is added beside it, or fills in when the flow has none. + let subtitle: string = resolveFlowTemplateLiterals(headings.subtitle, {meta: meta(), t}); + let destinationText: string | null = null; + if (destination && headings.headingComponents.subheading) { + destinationText = t('user.verification.destination', {destination}); + } else if (destination) { + subtitle = t(isAwaitingLink ? 'user.verification.subheading.link' : 'user.verification.subheading', { + destination, + }); + } + + const isLoading: boolean = state.value === 'loading' || isSubmitting.value; + const px: typeof withVendorCSSClassPrefix = withVendorCSSClassPrefix; + const containerClass: string = [ + px('verify-attribute'), + px(`verify-attribute--${props.size}`), + px(`verify-attribute--${props.variant}`), + props.className, + ] + .filter(Boolean) + .join(' '); + + if (slots['default']) { + const resend: EmbeddedFlowComponent | undefined = state.value === 'active' ? step.resend : undefined; + const renderProps: BaseVerifyAttributeRenderProps = { + additionalData, + components: componentsWithoutHeadings, + destination, + destinationText, + error: error.value, + fieldErrors: formErrors.value, + handleInputChange, + handleResend: resend ? (): Promise => handleSubmit(resend, undefined, true) : undefined, + handleSubmit, + isAwaitingLink, + isComplete: state.value === 'complete', + isFailed: state.value === 'failed', + isLoading, + isValid: isFormValid.value, + onCancel: props.onCancel, + onDone: isAwaitingLink ? props.onDone : undefined, + subtitle, + successMessage: t('user.verification.success', labelParams()), + title, + touched: touchedFields.value, + values: formValues.value, + }; + + return h('div', {class: containerClass}, slots['default'](renderProps)); + } + + const showDone: boolean = isAwaitingLink && !!props.onDone; + + return h('div', {'aria-busy': isLoading, class: containerClass}, [ + state.value === 'active' && (props.showTitle || props.showSubtitle) + ? h('div', {class: px('verify-attribute__header')}, [ + props.showTitle + ? h(Typography, {class: px('verify-attribute__heading'), variant: 'h5'}, () => title) + : null, + props.showSubtitle + ? h(Typography, {class: px('verify-attribute__subheading'), variant: 'body2'}, () => [ + subtitle, + destinationText + ? h('span', {class: px('verify-attribute__destination')}, ` ${destinationText}`) + : null, + ]) + : null, + ]) + : null, + state.value === 'loading' ? h('div', {class: px('verify-attribute__loading')}, [h(Spinner)]) : null, + error.value ? h(Alert, {class: px('verify-attribute__alert'), severity: 'error'}, () => error.value) : null, + state.value === 'complete' + ? h(Alert, {class: px('verify-attribute__alert'), severity: 'success'}, () => + t('user.verification.success', labelParams()), + ) + : null, + state.value === 'active' + ? h( + 'div', + {class: px('verify-attribute__content')}, + renderVerificationComponents( + componentsWithoutHeadings, + formValues.value, + touchedFields.value, + formErrors.value, + isLoading, + isFormValid.value, + resetForm, + handleInputChange, + { + additionalData, + buttonClassName: [px('verify-attribute__button'), props.buttonClassName].filter(Boolean).join(' '), + inputClassName: [px('verify-attribute__input'), props.inputClassName].filter(Boolean).join(' '), + meta: meta(), + onInputBlur: handleInputBlur, + onSubmit: (component: EmbeddedFlowComponent, data?: Record, skip?: boolean): void => { + void handleSubmit(component, data, skip); + }, + size: props.size, + t, + variant: props.variant, + }, + ).map((node: VNode) => withFlowTestIds(node, flowIds)), + ) + : null, + props.onCancel || showDone + ? h('div', {class: px('verify-attribute__actions')}, [ + showDone + ? h( + Button, + { + color: 'primary', + 'data-testid': 'thunderid-action-verificationDone', + onClick: () => props.onDone?.(), + size: 'small', + variant: 'solid', + }, + () => t('user.verification.buttons.done.text'), + ) + : null, + props.onCancel + ? h( + Button, + { + color: 'secondary', + 'data-testid': 'thunderid-action-verificationCancel', + disabled: isSubmitting.value, + onClick: () => props.onCancel?.(), + size: 'small', + variant: 'outline', + }, + () => + state.value === 'active' || state.value === 'loading' + ? t('user.verification.buttons.cancel.text') + : t('user.verification.buttons.close.text'), + ) + : null, + ]) + : null, + ]); + }; + }, +}); + +export default BaseVerifyAttribute; diff --git a/packages/vue/src/components/account/verify-attribute/VerifyAttribute.css.ts b/packages/vue/src/components/account/verify-attribute/VerifyAttribute.css.ts new file mode 100644 index 00000000..926bd70e --- /dev/null +++ b/packages/vue/src/components/account/verify-attribute/VerifyAttribute.css.ts @@ -0,0 +1,78 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +/** + * Styles for the VerifyAttribute presentation component. + * Parity target: `@thunderid/react` BaseVerifyAttribute.styles.ts + */ +const VERIFY_ATTRIBUTE_CSS = ` +/* ============================================================ + VerifyAttribute (React Parity) + ============================================================ */ + +.thunderid-verify-attribute { + display: flex; + flex-direction: column; + gap: calc(var(--thunderid-spacing-unit) * 2); + width: 100%; + box-sizing: border-box; + font-family: var(--thunderid-typography-fontFamily); +} + +.thunderid-verify-attribute--outlined { + padding: calc(var(--thunderid-spacing-unit) * 2); + border: 1px solid var(--thunderid-color-border); + border-radius: var(--thunderid-border-radius-medium, 6px); +} + +.thunderid-verify-attribute__header { + display: flex; + flex-direction: column; + gap: calc(var(--thunderid-spacing-unit) / 2); +} + +.thunderid-verify-attribute__heading { + margin: 0; +} + +.thunderid-verify-attribute__subheading, +.thunderid-verify-attribute__destination { + margin: 0; + color: var(--thunderid-color-text-secondary); +} + +.thunderid-verify-attribute__content { + display: flex; + flex-direction: column; + gap: calc(var(--thunderid-spacing-unit) * 1.5); +} + +.thunderid-verify-attribute__content .thunderid-otp-field__inputs { + justify-content: flex-start; +} + +.thunderid-verify-attribute__content .thunderid-otp-field__digit { + flex: 1 1 calc(var(--thunderid-spacing-unit) * 4); + max-width: calc(var(--thunderid-spacing-unit) * 6); + min-width: 0; +} + +.thunderid-verify-attribute__alert { + width: 100%; +} + +.thunderid-verify-attribute__loading { + display: flex; + justify-content: center; + padding: calc(var(--thunderid-spacing-unit) * 2); +} + +.thunderid-verify-attribute__actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: var(--thunderid-spacing-unit); +} +`; + +export default VERIFY_ATTRIBUTE_CSS; diff --git a/packages/vue/src/components/account/verify-attribute/VerifyAttribute.ts b/packages/vue/src/components/account/verify-attribute/VerifyAttribute.ts new file mode 100644 index 00000000..b91b0e8c --- /dev/null +++ b/packages/vue/src/components/account/verify-attribute/VerifyAttribute.ts @@ -0,0 +1,163 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import { + AttributeVerificationConstants, + EmbeddedFlowType, + EmbeddedSelfFlowRequest, + EmbeddedSelfFlowResponse, + Preferences, + ThunderIDRuntimeError, + isVerifiableAttribute, + resolveResourceEndpoint, + startCase, + withVendorCSSClassPrefix, +} from '@thunderid/browser'; +import {type Component, type PropType, type SetupContext, type VNode, defineComponent, h} from 'vue'; +import BaseVerifyAttribute from './BaseVerifyAttribute'; +import executeEmbeddedSelfFlow from '../../../api/auth/executeEmbeddedSelfFlow'; +import useThunderID from '../../../composables/useThunderID'; +import useUser from '../../../composables/useUser'; +import {createVueLogger} from '../../../utils/logger'; + +const logger: ReturnType = createVueLogger('VerifyAttribute'); + +type VerifyAttributeProps = Readonly<{ + attribute: string; + attributeDisplayName?: string; + className: string; + flowHandle: string; + onCancel?: () => void; + onComplete?: (response: EmbeddedSelfFlowResponse) => void; + onDone?: () => void; + onError?: (error: Error) => void; + preferences?: Preferences; + showSubtitle: boolean; + showTitle: boolean; + size: 'small' | 'medium' | 'large'; + variant: 'outlined' | 'flat'; +}>; + +/** + * VerifyAttribute verifies one of the signed-in user's attributes, such as their email address, by + * running a self attribute verification flow named by `flowHandle`. The flow picks the email or mobile + * path from the attribute's type, sends a code or a link to the attribute's current value, and the + * component renders the steps it describes; for a link it shows a Done action. Every request carries + * the user's access token. Once the server records the verification, and when the user selects Done, + * it re-reads the user's profile so `useUser()` shows the new status. + * + * It waits for the user schema to load, and refuses an attribute the schema does not declare as an + * `email` or `mobile` property, since starting a verification sends a message. + * + * Mount it with a `key` per attribute: each mount starts a new verification and sends a new code or + * link. + * + * @example + * ```vue + * + * ``` + */ +const VerifyAttribute: Component = defineComponent({ + name: 'VerifyAttribute', + props: { + /** + * Name of the signed-in user's attribute to verify, such as `email`. Only a top-level, + * single-valued `email` or `mobile` property of the user's schema can be verified; any other is + * refused without a request, through `onError`, with `VerifyAttribute-handleInitialize-RuntimeError-001`. + */ + attribute: {required: true, type: String}, + /** Label of the attribute; defaults to the schema's display name. */ + attributeDisplayName: {default: undefined, type: String}, + /** Extra CSS class added to the root element. */ + className: {default: '', type: String}, + /** Handle of the `SELF` flow to run. */ + flowHandle: {default: AttributeVerificationConstants.DEFAULT_FLOW_HANDLE, type: String}, + onCancel: {default: undefined, type: Function as PropType<() => void>}, + onComplete: {default: undefined, type: Function as PropType<(response: EmbeddedSelfFlowResponse) => void>}, + onDone: {default: undefined, type: Function as PropType<() => void>}, + onError: {default: undefined, type: Function as PropType<(error: Error) => void>}, + /** Component-level preferences to override global preferences. */ + preferences: {default: undefined, type: Object as PropType}, + showSubtitle: {default: true, type: Boolean}, + showTitle: {default: true, type: Boolean}, + size: {default: 'medium', type: String as PropType<'small' | 'medium' | 'large'>}, + variant: {default: 'outlined', type: String as PropType<'outlined' | 'flat'>}, + }, + setup(props: VerifyAttributeProps, {slots}: SetupContext): () => VNode { + const {baseUrl, endpoints, instanceId, isInitialized, isLoading} = useThunderID(); + const {revalidateProfile, userSchema} = useUser(); + + const flowExecuteUrl: string = resolveResourceEndpoint('flowExecute', {endpoints}) ?? `${baseUrl}/flow/execute`; + + const execute = (payload: EmbeddedSelfFlowRequest): Promise => + executeEmbeddedSelfFlow({ + baseUrl, + instanceId, + payload, + url: flowExecuteUrl, + }); + + const handleInitialize = (): Promise => { + if (!isVerifiableAttribute(userSchema?.value, props.attribute)) { + return Promise.reject( + new ThunderIDRuntimeError( + `Attribute "${props.attribute}" is not a verifiable email or mobile attribute.`, + 'VerifyAttribute-handleInitialize-RuntimeError-001', + 'vue', + 'Only a top-level, single-valued email or mobile attribute of the user schema can be verified.', + ), + ); + } + + return execute({ + flowType: EmbeddedFlowType.Self, + handle: props.flowHandle, + inputs: {[AttributeVerificationConstants.ATTRIBUTE_INPUT]: props.attribute}, + }); + }; + + // Re-reads the profile so the recorded verification shows; a failure leaves it stale, not broken. + const refreshProfile = async (): Promise => { + try { + await revalidateProfile?.(); + } catch (caughtError: unknown) { + logger.warn('Failed to re-read the user profile after verification:', caughtError); + } + }; + + return (): VNode => + h( + BaseVerifyAttribute, + { + attributeDisplayName: + props.attributeDisplayName ?? + userSchema?.value?.[props.attribute]?.displayName ?? + startCase(props.attribute), + class: withVendorCSSClassPrefix('verify-attribute--styled'), + className: props.className, + // The schema decides whether the attribute can be verified, so wait for it to load. + isInitialized: isInitialized.value && !isLoading.value && !!userSchema?.value, + onCancel: props.onCancel, + onComplete: (response: EmbeddedSelfFlowResponse): void => { + void refreshProfile(); + props.onComplete?.(response); + }, + onDone: (): void => { + void refreshProfile(); + props.onDone?.(); + }, + onError: props.onError, + onInitialize: handleInitialize, + onSubmit: execute, + preferences: props.preferences, + showSubtitle: props.showSubtitle, + showTitle: props.showTitle, + size: props.size, + variant: props.variant, + }, + slots, + ); + }, +}); + +export default VerifyAttribute; diff --git a/packages/vue/src/components/auth/flow/AuthOptionFactory.ts b/packages/vue/src/components/auth/flow/AuthOptionFactory.ts index 8271654f..fd9c04fa 100644 --- a/packages/vue/src/components/auth/flow/AuthOptionFactory.ts +++ b/packages/vue/src/components/auth/flow/AuthOptionFactory.ts @@ -1,4 +1,9 @@ // Copyright 2025 The ThunderID Authors // SPDX-License-Identifier: Apache-2.0 -export {renderInviteUserComponents, renderSignInComponents, renderSignUpComponents} from './AuthOptionFactoryCore'; +export { + renderInviteUserComponents, + renderSignInComponents, + renderSignUpComponents, + renderVerificationComponents, +} from './AuthOptionFactoryCore'; diff --git a/packages/vue/src/components/auth/flow/AuthOptionFactoryCore.ts b/packages/vue/src/components/auth/flow/AuthOptionFactoryCore.ts index f5dfe65e..fb95b8b5 100644 --- a/packages/vue/src/components/auth/flow/AuthOptionFactoryCore.ts +++ b/packages/vue/src/components/auth/flow/AuthOptionFactoryCore.ts @@ -179,6 +179,8 @@ const createAuthComponentFromFlow = ( /** Called with the error of a failed picker load. The form itself shows no error for it. */ onPickerError?: (error: Error) => void; onSubmit?: (component: EmbeddedFlowComponent, data?: Record, skipValidation?: boolean) => void; + /** Renders RESEND actions; only attribute verification sets it, so other flows skip them as before. */ + renderResend?: boolean; size?: 'small' | 'medium' | 'large'; t?: TranslationFn; variant?: any; @@ -222,6 +224,25 @@ const createAuthComponentFromFlow = ( return null; }; + // A resend asks for a new code, so it submits its own action without the step's values or validation. + if (component.type === EmbeddedFlowComponentType.Resend && options.renderResend) { + return h( + Button, + { + class: options.buttonClassName, + color: 'primary', + disabled: isLoading || options.isTimeoutDisabled, + fullWidth: true, + id: component.id, + key, + onClick: (): void => options.onSubmit?.(component, {}, true), + type: 'button', + variant: 'text', + }, + {default: () => resolve(component.label)}, + ); + } + switch (component.type) { case EmbeddedFlowComponentType.TextInput: case EmbeddedFlowComponentType.PasswordInput: @@ -750,6 +771,41 @@ export const renderSignUpComponents = ( ) .filter((v): v is VNode => v !== null); +/** + * Processes an array of components and renders them as VNodes for self attribute verification. + * Identical to renderSignUpComponents, except that it renders RESEND actions. + */ +export const renderVerificationComponents = ( + components: EmbeddedFlowComponent[], + formValues: Record, + touchedFields: Record, + formErrors: Record, + isLoading: boolean, + isFormValid: boolean, + resetForm: () => void, + onInputChange: (name: string, value: string) => void, + options?: Parameters[8], +): VNode[] => + components + .map((component: EmbeddedFlowComponent, index: number) => + createAuthComponentFromFlow( + component, + formValues, + touchedFields, + formErrors, + isLoading, + isFormValid, + resetForm, + onInputChange, + { + ...options, + key: component.id ?? index, + renderResend: true, + }, + ), + ) + .filter((v): v is VNode => v !== null); + /** * Processes an array of components and renders them as VNodes for invite-user flows. * Identical to renderSignInComponents — separated for semantic clarity. diff --git a/packages/vue/src/index.ts b/packages/vue/src/index.ts index b009245a..3ba092e6 100644 --- a/packages/vue/src/index.ts +++ b/packages/vue/src/index.ts @@ -141,6 +141,16 @@ export {default as Loading} from './components/auth/guards/Loading'; export {default as User} from './components/account/user/User'; export {default as UserProfile} from './components/account/user-profile/UserProfile'; export {default as BaseUserProfile} from './components/account/user-profile/BaseUserProfile'; +export type { + BaseUserProfileVerificationSlotProps, + BaseUserProfileVerificationState, +} from './components/account/user-profile/BaseUserProfile'; +export {default as VerifyAttribute} from './components/account/verify-attribute/VerifyAttribute'; +export {default as BaseVerifyAttribute} from './components/account/verify-attribute/BaseVerifyAttribute'; +export type { + BaseVerifyAttributeProps, + BaseVerifyAttributeRenderProps, +} from './components/account/verify-attribute/BaseVerifyAttribute'; export {default as Attribution} from './components/common/attribution/Attribution'; export {default as ChangeCredential} from './components/account/change-credential/ChangeCredential'; export {default as BaseChangeCredential} from './components/account/change-credential/BaseChangeCredential'; @@ -262,6 +272,8 @@ export type {FlowErrorResponse, FlowTransformOptions} from './utils/flowTransfor export {handlePasskeyAuthentication, handlePasskeyRegistration} from './utils/passkey'; export {default as getUsersMeMeta} from './api/account/getUsersMeMeta'; export * from './api/account/getUsersMeMeta'; +export {default as executeEmbeddedSelfFlow} from './api/auth/executeEmbeddedSelfFlow'; +export type {ExecuteEmbeddedSelfFlowConfig} from './api/auth/executeEmbeddedSelfFlow'; // ── Phase 4 — Router Helpers ── export {createThunderIDGuard} from './router/guard'; @@ -302,6 +314,13 @@ export { type EmbeddedSignUpFlowInitiateRequest, type EmbeddedSignUpFlowRequest, type EmbeddedSignUpFlowErrorResponse, + EmbeddedSelfFlowStatus, + type EmbeddedSelfFlowResponse, + type EmbeddedSelfFlowRequest, + AttributeVerificationConstants, + getVerifiedAttributes, + isVerifiableAttribute, + type VerifiedAttribute, type ComponentRenderContext, type ComponentsExtensions, type ComponentRenderer, diff --git a/packages/vue/src/styles/injectStyles.ts b/packages/vue/src/styles/injectStyles.ts index fefb2fbf..aedf4198 100644 --- a/packages/vue/src/styles/injectStyles.ts +++ b/packages/vue/src/styles/injectStyles.ts @@ -27,6 +27,7 @@ import DEFAULTS_CSS from './defaults.css'; import CHANGE_CREDENTIAL_CSS from '../components/account/change-credential/ChangeCredential.css'; import USER_DROPDOWN_CSS from '../components/account/user-dropdown/UserDropdown.css'; import USER_PROFILE_CSS from '../components/account/user-profile/UserProfile.css'; +import VERIFY_ATTRIBUTE_CSS from '../components/account/verify-attribute/VerifyAttribute.css'; import ADMIN_CSS from '../components/admin/shared/Admin.css'; import BASE_DEVICE_GRANT_CSS from '../components/auth/device-grant/BaseDeviceGrant.css'; import CONSENT_CSS from '../components/auth/flow/Consent.css'; @@ -89,6 +90,7 @@ const STYLES: string = [ USER_PROFILE_CSS, ADMIN_CSS, ATTRIBUTION_CSS, + VERIFY_ATTRIBUTE_CSS, ].join('\n'); /** diff --git a/playgrounds/nextjs/src/explorer/CustomRenderParts.tsx b/playgrounds/nextjs/src/explorer/CustomRenderParts.tsx index b18f970e..463124b1 100644 --- a/playgrounds/nextjs/src/explorer/CustomRenderParts.tsx +++ b/playgrounds/nextjs/src/explorer/CustomRenderParts.tsx @@ -22,7 +22,13 @@ import { BaseUserList, type BaseUserListRenderProps, } from '@thunderid/nextjs'; -import {type DataTableColumn, type ManagedUser, useTheme} from '@thunderid/react'; +import { + type BaseVerifyAttributeRenderProps, + type DataTableColumn, + type EmbeddedFlowComponent, + type ManagedUser, + useTheme, +} from '@thunderid/react'; import {ReactElement, ReactNode, useMemo, useState} from 'react'; import {SAMPLE_USERS} from './customRenderSamples'; import {bool, num, str} from './helpers'; @@ -206,3 +212,71 @@ export const SampleUserGrid = ({action, args}: {action: Action; args: Args}): Re ); }; + +// The step's components of the given types, at any depth. +const flowComponents = (components: EmbeddedFlowComponent[], types: string[]): EmbeddedFlowComponent[] => + components.flatMap((component: EmbeddedFlowComponent) => [ + ...(types.includes(component.type) ? [component] : []), + ...flowComponents(component.components ?? [], types), + ]); + +const INPUT_TYPES: string[] = ['TEXT_INPUT', 'EMAIL_INPUT', 'PHONE_INPUT', 'OTP_INPUT', 'PASSWORD_INPUT']; + +/** A verification step's render props drawn with MUI: the step's inputs and actions, resend and cancel. */ +export const VerifyForm = (step: BaseVerifyAttributeRenderProps): ReactElement => { + const isActive: boolean = !step.isComplete && !step.isFailed; + + return ( + + + + + {step.error && {step.error}} + {step.isComplete && {step.successMessage}} + {isActive && + flowComponents(step.components, INPUT_TYPES).map((input: EmbeddedFlowComponent) => { + const name: string = input.ref ?? input.id; + + return ( + step.handleInputChange(name, event.target.value)} + /> + ); + })} + + + + {step.onCancel && } + {step.handleResend && ( + + )} + {step.onDone && ( + + )} + {isActive && + flowComponents(step.components, ['ACTION']).map((action: EmbeddedFlowComponent) => ( + + ))} + + + ); +}; diff --git a/playgrounds/nextjs/src/explorer/customRenderSamples.ts b/playgrounds/nextjs/src/explorer/customRenderSamples.ts index 250a1bd3..03a2e819 100644 --- a/playgrounds/nextjs/src/explorer/customRenderSamples.ts +++ b/playgrounds/nextjs/src/explorer/customRenderSamples.ts @@ -1,7 +1,13 @@ // Copyright 2026 The ThunderID Authors // SPDX-License-Identifier: Apache-2.0 -import type {ManagedUser, MembershipGroup} from '@thunderid/react'; +import { + type EmbeddedSelfFlowRequest, + type EmbeddedSelfFlowResponse, + EmbeddedSelfFlowStatus, + type ManagedUser, + type MembershipGroup, +} from '@thunderid/react'; // Sample data for the Base components. Nothing here reaches a server. @@ -26,3 +32,47 @@ export const SAMPLE_MEMBERSHIPS: MembershipGroup[] = [ {id: 'grp_admins', name: 'Administrators', ouId: OU}, {id: 'grp_support', name: 'Support', ouId: OU}, ]; + +// A self attribute verification flow that accepts the code 123456, for the Base verification stories. +export const SAMPLE_VERIFICATION_STEP: EmbeddedSelfFlowResponse = { + challengeToken: 'sample', + data: { + additionalData: {maskedDestination: 'l•••@acme.io'}, + components: [ + {id: 'heading', label: 'Verify your email address', type: 'TEXT', variant: 'HEADING_1'}, + { + id: 'message', + label: 'Enter the verification code sent to your email address.', + type: 'TEXT', + variant: 'HEADING_6', + }, + { + components: [ + {id: 'input_otp', label: 'Verification code', ref: 'otp', required: true, type: 'OTP_INPUT'}, + {eventType: 'SUBMIT', id: 'action_verify', label: 'Verify', type: 'ACTION', variant: 'PRIMARY'}, + {eventType: 'SUBMIT', id: 'action_resend', label: 'Resend code', type: 'RESEND'}, + ], + id: 'block_otp', + type: 'BLOCK', + }, + ], + }, + executionId: 'sample', + flowStatus: EmbeddedSelfFlowStatus.Incomplete, +} as unknown as EmbeddedSelfFlowResponse; + +export const submitSampleVerification = (payload: EmbeddedSelfFlowRequest): Promise => { + if (payload.action === 'action_resend' || payload.inputs?.['otp'] === '123456') { + return Promise.resolve( + payload.action === 'action_resend' + ? SAMPLE_VERIFICATION_STEP + : {executionId: 'sample', flowStatus: EmbeddedSelfFlowStatus.Complete}, + ); + } + + return Promise.resolve({ + ...SAMPLE_VERIFICATION_STEP, + data: {components: SAMPLE_VERIFICATION_STEP.data?.components}, + error: {message: {defaultValue: 'The code is incorrect.', key: 'otp.invalid'}}, + } as unknown as EmbeddedSelfFlowResponse); +}; diff --git a/playgrounds/nextjs/src/explorer/customRenderStories.tsx b/playgrounds/nextjs/src/explorer/customRenderStories.tsx index 760da807..ac232f7b 100644 --- a/playgrounds/nextjs/src/explorer/customRenderStories.tsx +++ b/playgrounds/nextjs/src/explorer/customRenderStories.tsx @@ -12,10 +12,17 @@ import { GroupCreate, UserDetails, UserList, + VerifyAttribute, } from '@thunderid/nextjs'; +import {BaseVerifyAttribute, type BaseVerifyAttributeRenderProps, type EmbeddedSelfFlowRequest} from '@thunderid/react'; import {NeedsId} from './AdminParts'; -import {GroupForm, Mui, SampleUserGrid, UserCard, UserDataGrid} from './CustomRenderParts'; -import {SAMPLE_MEMBERSHIPS, SAMPLE_USERS} from './customRenderSamples'; +import {GroupForm, Mui, SampleUserGrid, UserCard, UserDataGrid, VerifyForm} from './CustomRenderParts'; +import { + SAMPLE_MEMBERSHIPS, + SAMPLE_USERS, + SAMPLE_VERIFICATION_STEP, + submitSampleVerification, +} from './customRenderSamples'; import {bool, isLive, num, str} from './helpers'; import Narrow from './Narrow'; import type {Action, Args, ComponentDef, PropControl} from './types'; @@ -78,6 +85,29 @@ const MUI_CARD_SNIPPET = ` )} `; +const MUI_VERIFY_SNIPPET = ` + {({title, subtitle, destinationText, components, values, handleInputChange, handleSubmit, handleResend, onCancel, isLoading, isValid, isComplete, successMessage, error}) => ( + + + + {error && {error}} + {isComplete ? ( + {successMessage} + ) : ( + handleInputChange('otp', e.target.value)} /> + )} + + + {onCancel && } + {handleResend && } + + + + )} +`; + /** * Stories that replace an admin component's default UI through its `children` render function, * here with MUI. The same render props work with any component library. @@ -217,4 +247,61 @@ export const CUSTOM_RENDER_COMPONENTS: ComponentDef[] = [ {args: {source: 'live'}, id: 'live', name: 'Live (needs userId)'}, ], }, + { + code: () => ({ + imports: ['VerifyAttribute'], + jsx: MUI_VERIFY_SNIPPET, + otherImports: [ + "import {Alert, Button, Card, CardActions, CardContent, CardHeader, TextField} from '@mui/material';", + ], + }), + controls: [ + { + ...SOURCE_CONTROL, + description: + 'base renders the Base component with a sample flow; live renders the connected component, whose steps go through the SDK server action.', + }, + {control: 'text', description: 'Live only: attribute to verify.', name: 'attribute', type: 'string'}, + ], + defaults: {attribute: 'email', source: 'base'}, + group: 'Custom rendering', + id: 'custom-verifyattribute-form', + live: true, + name: 'VerifyAttribute → MUI form', + render: (args: Args, action: Action) => ( + + + {isLive(args) ? ( + action('onCancel')} + onComplete={() => action('onComplete')} + onError={(error: Error) => action('onError', error.message)} + > + {(props: BaseVerifyAttributeRenderProps) => } + + ) : ( + action('onCancel')} + onComplete={() => action('onComplete')} + onInitialize={() => Promise.resolve(SAMPLE_VERIFICATION_STEP)} + onSubmit={(payload: EmbeddedSelfFlowRequest) => { + action('onSubmit', JSON.stringify(payload)); + + return submitSampleVerification(payload); + }} + > + {(props: BaseVerifyAttributeRenderProps) => } + + )} + + + ), + stories: [ + {id: 'base', name: 'Sample flow (code 123456)'}, + {args: {source: 'live'}, id: 'live', name: 'Live (needs sign-in, sends a code)'}, + ], + }, ]; diff --git a/playgrounds/nextjs/src/explorer/stories.tsx b/playgrounds/nextjs/src/explorer/stories.tsx index 07646a90..66ccc72c 100644 --- a/playgrounds/nextjs/src/explorer/stories.tsx +++ b/playgrounds/nextjs/src/explorer/stories.tsx @@ -20,6 +20,7 @@ import { UserProfile, UserSelect, UserTypeSelect, + VerifyAttribute, } from '@thunderid/nextjs'; import { BaseChangeCredential, @@ -29,11 +30,14 @@ import { BaseUserAvatar, BaseUserDropdown, BaseUserProfile, + BaseVerifyAttribute, ChangePasswordValues, + EmbeddedSelfFlowRequest, Typography, } from '@thunderid/react'; import {ChangeEvent, FC} from 'react'; import {ADMIN_COMPONENTS} from './adminStories'; +import {SAMPLE_VERIFICATION_STEP, submitSampleVerification} from './customRenderSamples'; import {CUSTOM_RENDER_COMPONENTS} from './customRenderStories'; import {bool, fromReact, isLive, jsx, num, omit, str} from './helpers'; import Narrow from './Narrow'; @@ -442,6 +446,57 @@ export const COMPONENTS: ComponentDef[] = [ {args: {source: 'live'}, id: 'live', name: 'Signed-in user (editable)'}, ], }, + { + code: (args: Args) => + str(args, 'source') === 'live' + ? { + imports: ['VerifyAttribute'], + jsx: jsx('VerifyAttribute', omit(args, 'source'), {extra: ['onComplete={handleComplete}']}), + } + : fromReact({ + imports: ['BaseVerifyAttribute'], + jsx: jsx('BaseVerifyAttribute', {}, {extra: ['onInitialize={startFlow}', 'onSubmit={submitStep}']}), + }), + controls: [ + SOURCE_CONTROL, + {control: 'text', description: 'Live only: attribute to verify.', name: 'attribute', type: 'string'}, + {control: 'text', description: 'Live only: handle of the SELF flow.', name: 'flowHandle', type: 'string'}, + ], + defaults: {attribute: 'email', flowHandle: '', source: 'base'}, + group: 'User', + id: 'verifyattribute', + live: true, + name: 'VerifyAttribute', + render: (args: Args, action: Action) => + str(args, 'source') === 'live' ? ( + + action('onComplete')} + onError={(error: Error) => action('onError', error.message)} + /> + + ) : ( + + Promise.resolve(SAMPLE_VERIFICATION_STEP)} + onSubmit={(payload: EmbeddedSelfFlowRequest) => { + action('onSubmit', JSON.stringify(payload)); + + return submitSampleVerification(payload); + }} + onComplete={() => action('onComplete')} + /> + + ), + stories: [ + {id: 'base', name: 'Sample flow (code 123456)'}, + {args: {source: 'live'}, id: 'live', name: 'Signed-in user (sends a code)'}, + ], + }, { code: (args: Args) => isLive(args) diff --git a/playgrounds/node/README.md b/playgrounds/node/README.md index ff1c1528..fbb7ec63 100644 --- a/playgrounds/node/README.md +++ b/playgrounds/node/README.md @@ -12,7 +12,7 @@ that holds a `ThunderIDNodeClient`, and serves an operations explorer for runnin | Session | `isSignedIn()`, `getUser()`, `getIdToken()`, `getDecodedIdToken()`, `getAccessToken()`, `decodeJwtToken()`, `clearSession()` | | Tokens | `refreshAccessToken()`, `revokeAccessToken()`, `exchangeToken()`, client credentials (`getAccessToken()` on a `client_credentials` client) | | Sign-out | `signOut()`, `handleBackchannelLogout()` | -| Management API | `getUsers()`, `getFlows()`, `getUserTypes()`, `updateUserCredentials()` | +| Management API | `getUsers()`, `getFlows()`, `getUserTypes()`, `updateUserCredentials()`, `executeEmbeddedSelfFlow()` | | Management client | `client.management(sessionId)`: `groups.list/get/listMembers`, `roles.list/get/listAssignments`, `agentTypes.list`, `agents.listGroups/listRoles`, `users.listGroups`, and a permission-gated `groups.list()` behind `hasPermission(getTokenPermissions(token), permission)` | `signInSilently()`, `signUp()`, `recover()`, `getUserProfile()` and `updateUserProfile()` are listed as not available: diff --git a/playgrounds/node/src/catalog.ts b/playgrounds/node/src/catalog.ts index e4b86333..4440a69e 100644 --- a/playgrounds/node/src/catalog.ts +++ b/playgrounds/node/src/catalog.ts @@ -435,6 +435,24 @@ const operations: OperationSpec[] = [ session: true, summary: 'List user types.', }, + { + args: json({ + flowType: 'SELF', + handle: 'default-self-attribute-verification-flow', + inputs: {attribute: 'email'}, + }), + call: 'client.executeEmbeddedSelfFlow(payload, sessionId)', + description: + "Runs one step of a SELF flow for the signed-in user, with the session access token. The starting step shown verifies the user's email and sends a real code; continue with the returned executionId, challengeToken and the action and inputs of the next step.", + group: 'Management API', + id: 'executeEmbeddedSelfFlow', + network: true, + params: [], + regions: ['executeEmbeddedSelfFlow'], + returns: 'Promise', + session: true, + summary: 'Run a SELF flow step.', + }, { args: json({password: ''}), call: 'client.updateUserCredentials(payload, sessionId)', diff --git a/playgrounds/node/src/operations.ts b/playgrounds/node/src/operations.ts index 3a87fc97..e94f5b52 100644 --- a/playgrounds/node/src/operations.ts +++ b/playgrounds/node/src/operations.ts @@ -9,6 +9,7 @@ import { hasPermission, type AgentConfig, type CIBAInitiateOptions, + type EmbeddedSelfFlowRequest, type ExtendedAuthorizeRequestUrlParams, type ManagementListOptions, type RoleAssigneeType, @@ -192,6 +193,11 @@ const getUserTypes: Runner = ({client, params, sessionId}) => client.getUserTypes({limit: num(params['limit']), offset: num(params['offset'])}, sessionId); // #endregion +// #region executeEmbeddedSelfFlow +const executeEmbeddedSelfFlow: Runner = ({args, client, sessionId}) => + client.executeEmbeddedSelfFlow(args as EmbeddedSelfFlowRequest, sessionId); +// #endregion + // #region updateUserCredentials const updateUserCredentials: Runner = async ({args, client, sessionId}) => { await client.updateUserCredentials(args as Record, sessionId); @@ -277,6 +283,7 @@ const runners: Record = { clientCredentials, decodeJwtToken, exchangeToken, + executeEmbeddedSelfFlow, getAccessToken, getConfiguration, getDecodedIdToken, diff --git a/playgrounds/node/src/trace.ts b/playgrounds/node/src/trace.ts index 01584210..0589d780 100644 --- a/playgrounds/node/src/trace.ts +++ b/playgrounds/node/src/trace.ts @@ -146,6 +146,7 @@ const CLIENT_METHODS: Record = { clearSessionAsync: 0, decodeJwtToken: -1, exchangeToken: 1, + executeEmbeddedSelfFlow: 1, getAccessToken: 0, getConfiguration: -1, getDecodedIdToken: 0, diff --git a/playgrounds/nuxt/lib/customRenderStories.ts b/playgrounds/nuxt/lib/customRenderStories.ts index f1b5ffa4..94369e83 100644 --- a/playgrounds/nuxt/lib/customRenderStories.ts +++ b/playgrounds/nuxt/lib/customRenderStories.ts @@ -1,17 +1,34 @@ // Copyright 2026 The ThunderID Authors // SPDX-License-Identifier: Apache-2.0 -import type {Application, ManagedUser, MembershipGroup} from '@thunderid/browser'; -import type { - AdminListRenderProps, - ApplicationCreateSubmission, - BaseApplicationCreateRenderProps, - BaseApplicationDetailsRenderProps, - BaseGroupCreateRenderProps, - BaseUserDetailsRenderProps, +import { + EmbeddedSelfFlowStatus, + type Application, + type EmbeddedSelfFlowRequest, + type EmbeddedSelfFlowResponse, + type ManagedUser, + type MembershipGroup, +} from '@thunderid/browser'; +import { + BaseVerifyAttribute, + type AdminListRenderProps, + type ApplicationCreateSubmission, + type BaseApplicationCreateRenderProps, + type BaseApplicationDetailsRenderProps, + type BaseGroupCreateRenderProps, + type BaseUserDetailsRenderProps, + type BaseVerifyAttributeRenderProps, } from '@thunderid/vue'; import {defineComponent, h, ref, type Component, type PropType, type Ref, type VNode} from 'vue'; -import {applicationCard, applicationForm, groupForm, PrimeTheme, userCard, userTable} from './primeRenderers'; +import { + applicationCard, + applicationForm, + groupForm, + PrimeTheme, + userCard, + userTable, + verifyForm, +} from './primeRenderers'; import type {Action, Args, ComponentDef, PropControl} from './storyTypes'; import {narrow} from './storyWidgets'; import { @@ -25,6 +42,7 @@ import { GroupCreate, UserDetails, UserList, + VerifyAttribute, } from '#components'; // The admin components are auto-imported by @thunderid/nuxt when thunderid.management is on. @@ -87,6 +105,50 @@ const SAMPLE_APPLICATION: Application = { /** A secret shown in the "secret shown once" stories. Sample only, never a real credential. */ const SAMPLE_SECRET = 'sample-secret-5f1c9e2a'; +// A self attribute verification flow that accepts the code 123456, for the Base verification stories. +export const SAMPLE_VERIFICATION_STEP: EmbeddedSelfFlowResponse = { + challengeToken: 'sample', + data: { + additionalData: {maskedDestination: 'l•••@acme.io'}, + components: [ + {id: 'heading', label: 'Verify your email address', type: 'TEXT', variant: 'HEADING_1'}, + { + id: 'message', + label: 'Enter the verification code sent to your email address.', + type: 'TEXT', + variant: 'HEADING_6', + }, + { + components: [ + {id: 'input_otp', label: 'Verification code', ref: 'otp', required: true, type: 'OTP_INPUT'}, + {eventType: 'SUBMIT', id: 'action_verify', label: 'Verify', type: 'ACTION', variant: 'PRIMARY'}, + {eventType: 'SUBMIT', id: 'action_resend', label: 'Resend code', type: 'RESEND'}, + ], + id: 'block_otp', + type: 'BLOCK', + }, + ], + }, + executionId: 'sample', + flowStatus: EmbeddedSelfFlowStatus.Incomplete, +} as unknown as EmbeddedSelfFlowResponse; + +export const submitSampleVerification = (payload: EmbeddedSelfFlowRequest): Promise => { + if (payload.action === 'action_resend' || payload.inputs?.otp === '123456') { + return Promise.resolve( + payload.action === 'action_resend' + ? SAMPLE_VERIFICATION_STEP + : {executionId: 'sample', flowStatus: EmbeddedSelfFlowStatus.Complete}, + ); + } + + return Promise.resolve({ + ...SAMPLE_VERIFICATION_STEP, + data: {components: SAMPLE_VERIFICATION_STEP.data?.components}, + error: {message: {defaultValue: 'The code is incorrect.', key: 'otp.invalid'}}, + } as unknown as EmbeddedSelfFlowResponse); +}; + const isLive = (args: Args): boolean => str(args, 'source') === 'live'; const themed = (width: number, child: VNode): VNode => narrow(width, h(PrimeTheme, null, {default: () => child})); @@ -219,6 +281,25 @@ const APPLICATION_CARD_TEMPLATE = ` + +`; + /** * Stories that replace an admin component's default UI through its default scoped slot, here with * PrimeVue. The same slot props work with any component library. @@ -318,6 +399,65 @@ export const CUSTOM_RENDER_COMPONENTS: ComponentDef[] = [ {args: {source: 'live'}, id: 'live', name: 'Live (needs sign-in)'}, ], }, + { + code: () => ({ + imports: [], + otherImports: [ + "import Button from 'primevue/button';", + "import Card from 'primevue/card';", + "import InputText from 'primevue/inputtext';", + "import Message from 'primevue/message';", + ], + template: VERIFY_TEMPLATE, + }), + controls: [ + { + ...SOURCE_CONTROL, + description: + 'base renders BaseVerifyAttribute with a sample flow; live renders the container, which runs each step through POST /api/auth/user/self-flow.', + }, + {control: 'text', description: 'Live only: attribute to verify.', name: 'attribute', type: 'string'}, + ], + defaults: {attribute: 'email', source: 'base'}, + group: 'Custom rendering', + id: 'custom-verifyattribute-form', + live: true, + name: 'VerifyAttribute → PrimeVue form', + render: (args: Args, action: Action): VNode => + themed( + 440, + isLive(args) + ? h( + VerifyAttribute, + { + attribute: str(args, 'attribute') || 'email', + key: `live-${str(args, 'attribute')}`, + onCancel: (): void => action('cancel'), + onComplete: (): void => action('complete'), + onError: (error: Error): void => action('error', quoted(error.message)), + }, + {default: (state: BaseVerifyAttributeRenderProps) => verifyForm(state)}, + ) + : h( + BaseVerifyAttribute, + { + attributeDisplayName: 'Email', + onCancel: (): void => action('cancel'), + onComplete: (): void => action('complete'), + onInitialize: (): Promise => Promise.resolve(SAMPLE_VERIFICATION_STEP), + onSubmit: (payload: EmbeddedSelfFlowRequest): Promise => { + action('submit', JSON.stringify(payload)); + return submitSampleVerification(payload); + }, + }, + {default: (state: BaseVerifyAttributeRenderProps) => verifyForm(state)}, + ), + ), + stories: [ + {id: 'base', name: 'Sample flow (code 123456)'}, + {args: {source: 'live'}, id: 'live', name: 'Live (needs sign-in, sends a code)'}, + ], + }, { code: () => ({ imports: [], diff --git a/playgrounds/nuxt/lib/primeRenderers.ts b/playgrounds/nuxt/lib/primeRenderers.ts index 5ee883cb..9f2704a8 100644 --- a/playgrounds/nuxt/lib/primeRenderers.ts +++ b/playgrounds/nuxt/lib/primeRenderers.ts @@ -1,13 +1,14 @@ // Copyright 2026 The ThunderID Authors // SPDX-License-Identifier: Apache-2.0 -import type {ManagedUser, MembershipGroup} from '@thunderid/browser'; +import type {EmbeddedFlowComponent, ManagedUser, MembershipGroup} from '@thunderid/browser'; import { type AdminListRenderProps, type BaseApplicationCreateRenderProps, type BaseApplicationDetailsRenderProps, type BaseGroupCreateRenderProps, type BaseUserDetailsRenderProps, + type BaseVerifyAttributeRenderProps, type DataTableColumn, useTheme, } from '@thunderid/vue'; @@ -300,3 +301,83 @@ export const applicationCard = (state: BaseApplicationDetailsRenderProps): VNode title: () => (state.isLoading && !state.application ? 'Loading…' : (state.application?.name ?? '')), }, ); + +// The step's components of one kind, at any depth. +const flowComponents = (components: EmbeddedFlowComponent[], types: string[]): EmbeddedFlowComponent[] => + components.flatMap((component: EmbeddedFlowComponent) => [ + ...(types.includes(component.type) ? [component] : []), + ...flowComponents(component.components ?? [], types), + ]); + +const INPUT_TYPES: string[] = ['TEXT_INPUT', 'EMAIL_INPUT', 'PHONE_INPUT', 'OTP_INPUT', 'PASSWORD_INPUT']; + +/** The verification's default scoped slot drawn as a PrimeVue card: inputs, actions, resend and cancel. */ +export const verifyForm = (state: BaseVerifyAttributeRenderProps): VNode => { + const isActive: boolean = !state.isComplete && !state.isFailed; + + return h( + Card, + {}, + { + content: () => + h('div', {class: 'pg-prime-stack'}, [ + state.error ? h(Message, {severity: 'error'}, {default: () => state.error}) : null, + state.isComplete ? h(Message, {severity: 'success'}, {default: () => state.successMessage}) : null, + state.isLoading && !state.components.length ? h('p', 'Sending…') : null, + ...(isActive + ? flowComponents(state.components, INPUT_TYPES).map((input: EmbeddedFlowComponent) => { + const name: string = input.ref ?? input.id; + return h('label', {class: 'pg-prime-field', key: name}, [ + h('span', String(input.label ?? name)), + h(InputText, { + autocomplete: 'one-time-code', + modelValue: state.values[name], + 'onUpdate:modelValue': (value: string | undefined) => state.handleInputChange(name, value ?? ''), + }), + state.touched[name] && state.fieldErrors[name] + ? h( + Message, + {severity: 'error', size: 'small', variant: 'simple'}, + {default: () => state.fieldErrors[name]}, + ) + : null, + ]); + }) + : []), + ]), + footer: () => + h('div', {class: 'pg-prime-actions'}, [ + state.onCancel + ? h(Button, { + label: isActive ? 'Cancel' : 'Close', + onClick: state.onCancel, + severity: 'secondary', + text: true, + }) + : null, + state.handleResend + ? h(Button, { + disabled: state.isLoading, + label: 'Send a new code', + onClick: () => void state.handleResend?.(), + severity: 'secondary', + }) + : null, + state.onDone ? h(Button, {label: 'Done', onClick: state.onDone}) : null, + ...(isActive + ? flowComponents(state.components, ['ACTION']).map((action: EmbeddedFlowComponent) => + h(Button, { + disabled: !state.isValid || state.isLoading, + key: action.id, + label: String(action.label ?? action.id), + loading: state.isLoading, + onClick: () => void state.handleSubmit(action, state.values), + }), + ) + : []), + ]), + subtitle: () => [state.subtitle, state.destinationText].filter(Boolean).join(' '), + title: () => state.title, + }, + ); +}; diff --git a/playgrounds/nuxt/lib/stories.ts b/playgrounds/nuxt/lib/stories.ts index e7583cf9..ae407589 100644 --- a/playgrounds/nuxt/lib/stories.ts +++ b/playgrounds/nuxt/lib/stories.ts @@ -8,6 +8,7 @@ import { BaseSignUpButton, BaseUserDropdown, BaseUserProfile, + BaseVerifyAttribute, Button, Card, Divider, @@ -18,10 +19,11 @@ import { MicrosoftButton, Spinner, Typography, + type EmbeddedSelfFlowRequest, } from '@thunderid/vue'; import {defineComponent, h, ref, type Component, type PropType, type Ref, type VNode} from 'vue'; import {ADMIN_COMPONENTS} from './adminStories'; -import {CUSTOM_RENDER_COMPONENTS} from './customRenderStories'; +import {CUSTOM_RENDER_COMPONENTS, SAMPLE_VERIFICATION_STEP, submitSampleVerification} from './customRenderStories'; import {tag} from './storyCode'; import type {Action, Args, ArgValue, ComponentDef, PropControl} from './storyTypes'; import {CheckboxStory, narrow, OtpFieldStory, PasswordFieldStory, SelectStory, TextFieldStory} from './storyWidgets'; @@ -43,6 +45,7 @@ import { UserProfile, UserSelect, UserTypeSelect, + VerifyAttribute, } from '#components'; const str = (args: Args, key: string): string => String(args[key] ?? ''); @@ -768,6 +771,58 @@ export const COMPONENTS: ComponentDef[] = [ {args: {source: 'live'}, id: 'live', name: 'Signed-in user (editable)'}, ], }, + { + code: (args: Args) => + str(args, 'source') === 'live' + ? { + imports: [], + template: tag('VerifyAttribute', omit(args, 'source'), {extra: ['@complete="handleComplete"']}), + } + : { + imports: ['BaseVerifyAttribute'], + template: tag( + 'BaseVerifyAttribute', + {}, + {extra: [':on-initialize="startFlow"', ':on-submit="submitStep"']}, + ), + }, + controls: [ + SOURCE_CONTROL, + {control: 'text', description: 'Live only: attribute to verify.', name: 'attribute', type: 'string'}, + {control: 'text', description: 'Live only: handle of the SELF flow.', name: 'flowHandle', type: 'string'}, + ], + defaults: {attribute: 'email', flowHandle: '', source: 'base'}, + group: 'User', + id: 'verifyattribute', + live: true, + name: 'VerifyAttribute', + render: (args: Args, action: Action) => + narrow( + 440, + str(args, 'source') === 'live' + ? h(VerifyAttribute, { + attribute: str(args, 'attribute') || 'email', + flowHandle: str(args, 'flowHandle') || undefined, + key: `${str(args, 'attribute')}:${str(args, 'flowHandle')}`, + onComplete: () => action('complete'), + onError: (error: Error) => action('error', error.message), + }) + : h(BaseVerifyAttribute, { + attributeDisplayName: 'Email', + onComplete: () => action('complete'), + onInitialize: () => Promise.resolve(SAMPLE_VERIFICATION_STEP), + onSubmit: (payload: EmbeddedSelfFlowRequest) => { + action('submit', JSON.stringify(payload)); + + return submitSampleVerification(payload); + }, + }), + ), + stories: [ + {id: 'base', name: 'Sample flow (code 123456)'}, + {args: {source: 'live'}, id: 'live', name: 'Signed-in user (sends a code)'}, + ], + }, { code: (args: Args) => ({ imports: [], diff --git a/playgrounds/react/src/pages/explorer/CustomRenderWidgets.tsx b/playgrounds/react/src/pages/explorer/CustomRenderWidgets.tsx index 7ef0f0a8..c342d5d1 100644 --- a/playgrounds/react/src/pages/explorer/CustomRenderWidgets.tsx +++ b/playgrounds/react/src/pages/explorer/CustomRenderWidgets.tsx @@ -30,7 +30,9 @@ import { type BaseRolePermissionsRenderProps, type BaseUserCreateRenderProps, type BaseUserDetailsRenderProps, + type BaseVerifyAttributeRenderProps, type DataTableColumn, + type EmbeddedFlowComponent, getAgentName, useTheme, } from '@thunderid/react'; @@ -677,3 +679,71 @@ export const IdentityGrid = ({ ); }; + +// The step's components of the given types, at any depth. +const flowComponents = (components: EmbeddedFlowComponent[], types: string[]): EmbeddedFlowComponent[] => + components.flatMap((component: EmbeddedFlowComponent) => [ + ...(types.includes(component.type) ? [component] : []), + ...flowComponents(component.components ?? [], types), + ]); + +const INPUT_TYPES: string[] = ['TEXT_INPUT', 'EMAIL_INPUT', 'PHONE_INPUT', 'OTP_INPUT', 'PASSWORD_INPUT']; + +/** A verification step's render props drawn with MUI: the step's inputs and actions, resend and cancel. */ +export const VerifyForm = (step: BaseVerifyAttributeRenderProps): ReactElement => { + const isActive: boolean = !step.isComplete && !step.isFailed; + + return ( + + + + + {step.error && {step.error}} + {step.isComplete && {step.successMessage}} + {isActive && + flowComponents(step.components, INPUT_TYPES).map((input: EmbeddedFlowComponent) => { + const name: string = input.ref ?? input.id; + + return ( + step.handleInputChange(name, event.target.value)} + /> + ); + })} + + + + {step.onCancel && } + {step.handleResend && ( + + )} + {step.onDone && ( + + )} + {isActive && + flowComponents(step.components, ['ACTION']).map((action: EmbeddedFlowComponent) => ( + + ))} + + + ); +}; diff --git a/playgrounds/react/src/pages/explorer/customRenderStories.tsx b/playgrounds/react/src/pages/explorer/customRenderStories.tsx index 2688abfa..c04ac844 100644 --- a/playgrounds/react/src/pages/explorer/customRenderStories.tsx +++ b/playgrounds/react/src/pages/explorer/customRenderStories.tsx @@ -20,7 +20,11 @@ import { BaseUserCreate, BaseUserDetails, BaseUserList, + BaseVerifyAttribute, type BasicAgent, + type EmbeddedSelfFlowRequest, + type EmbeddedSelfFlowResponse, + EmbeddedSelfFlowStatus, type BasicApplication, type Group, type ManagedUser, @@ -54,11 +58,56 @@ import { PermissionCards, UserCard, UserForm, + VerifyForm, } from './CustomRenderWidgets'; import {num, str} from './storyHelpers'; import {Narrow} from './StoryWidgets'; import type {Action, Args, ComponentDef, PropControl, Snippet, StoryDef} from './types'; +// A self attribute verification flow that accepts the code 123456, for the Base verification stories. +export const SAMPLE_VERIFICATION_STEP: EmbeddedSelfFlowResponse = { + challengeToken: 'sample', + data: { + additionalData: {maskedDestination: 'l•••@acme.io'}, + components: [ + {id: 'heading', label: 'Verify your email address', type: 'TEXT', variant: 'HEADING_1'}, + { + id: 'message', + label: 'Enter the verification code sent to your email address.', + type: 'TEXT', + variant: 'HEADING_6', + }, + { + components: [ + {id: 'input_otp', label: 'Verification code', ref: 'otp', required: true, type: 'OTP_INPUT'}, + {eventType: 'SUBMIT', id: 'action_verify', label: 'Verify', type: 'ACTION', variant: 'PRIMARY'}, + {eventType: 'SUBMIT', id: 'action_resend', label: 'Resend code', type: 'RESEND'}, + ], + id: 'block_otp', + type: 'BLOCK', + }, + ], + }, + executionId: 'sample', + flowStatus: EmbeddedSelfFlowStatus.Incomplete, +} as unknown as EmbeddedSelfFlowResponse; + +export const submitSampleVerification = (payload: EmbeddedSelfFlowRequest): Promise => { + if (payload.action === 'action_resend' || payload.inputs?.['otp'] === '123456') { + return Promise.resolve( + payload.action === 'action_resend' + ? SAMPLE_VERIFICATION_STEP + : {executionId: 'sample', flowStatus: EmbeddedSelfFlowStatus.Complete}, + ); + } + + return Promise.resolve({ + ...SAMPLE_VERIFICATION_STEP, + data: {components: SAMPLE_VERIFICATION_STEP.data?.components}, + error: {message: {defaultValue: 'The code is incorrect.', key: 'otp.invalid'}}, + } as unknown as EmbeddedSelfFlowResponse); +}; + /** The `source` control, with the custom renderer as a third option. */ const SOURCE_WITH_CUSTOM: PropControl = { control: ['base', 'live', 'custom'], @@ -287,6 +336,34 @@ const CODE: Record Snippet> = { otherImports: [MUI_CARD], }), userlist: () => listSnippet('UserList', 'users'), + verifyattribute: () => ({ + imports: ['VerifyAttribute'], + jsx: ` + {({title, subtitle, destinationText, components, values, handleInputChange, handleSubmit, handleResend, onCancel, isLoading, isValid, isComplete, successMessage, error}) => ( + + + + {error && {error}} + {isComplete ? ( + {successMessage} + ) : ( + handleInputChange('otp', e.target.value)} /> + )} + + + {onCancel && } + {handleResend && } + + + + )} +`, + otherImports: [ + "import {Alert, Button, Card, CardActions, CardContent, CardHeader, TextField} from '@mui/material';", + ], + }), }; interface PagedListState { @@ -577,10 +654,27 @@ const RENDER: Record ReactNode> = { valueOf: (user: ManagedUser, attribute: string) => user.attributes?.[attribute], }, ), + verifyattribute: (_args: Args, action: Action) => ( + + action('onCancel')} + onComplete={() => action('onComplete')} + onInitialize={() => Promise.resolve(SAMPLE_VERIFICATION_STEP)} + onSubmit={(payload: EmbeddedSelfFlowRequest) => { + action('onSubmit', JSON.stringify(payload)); + + return submitSampleVerification(payload); + }} + > + {VerifyForm} + + + ), }; /** - * Replaces an admin component's live stories with a "Custom (MUI)" story. It renders the Base component + * Replaces a component's live stories with a "Custom (MUI)" story. It renders the Base component * with sample data through its `children` render function, drawn with MUI. */ export const withCustomStory = (def: ComponentDef): ComponentDef => { diff --git a/playgrounds/react/src/pages/explorer/stories.tsx b/playgrounds/react/src/pages/explorer/stories.tsx index 92ec9740..b177d3ac 100644 --- a/playgrounds/react/src/pages/explorer/stories.tsx +++ b/playgrounds/react/src/pages/explorer/stories.tsx @@ -9,6 +9,7 @@ import { BaseUserAvatar, BaseUserDropdown, BaseUserProfile, + BaseVerifyAttribute, Button, Card, Checkbox, @@ -38,9 +39,12 @@ import { UserAvatar, UserDropdown, UserProfile, + VerifyAttribute, + EmbeddedSelfFlowRequest, } from '@thunderid/react'; import {ChangeEvent, FC, ReactNode} from 'react'; import {ADMIN_COMPONENTS} from './adminStories'; +import {SAMPLE_VERIFICATION_STEP, submitSampleVerification, withCustomStory} from './customRenderStories'; import {bool, jsx, num, omit, SOURCE_CONTROL, str} from './storyHelpers'; import {Narrow, OtpFieldStory, PasswordFieldStory, SelectStory} from './StoryWidgets'; import type {Action, Args, ComponentDef} from './types'; @@ -939,6 +943,57 @@ export const COMPONENTS: ComponentDef[] = [ {args: {source: 'live'}, id: 'live', name: 'Signed-in user (editable)'}, ], }, + withCustomStory({ + code: (args: Args) => + str(args, 'source') === 'live' + ? { + imports: ['VerifyAttribute'], + jsx: jsx('VerifyAttribute', omit(args, 'source'), {extra: ['onComplete={handleComplete}']}), + } + : { + imports: ['BaseVerifyAttribute'], + jsx: jsx('BaseVerifyAttribute', {}, {extra: ['onInitialize={startFlow}', 'onSubmit={submitStep}']}), + }, + controls: [ + SOURCE_CONTROL, + {control: 'text', description: 'Live only: attribute to verify.', name: 'attribute', type: 'string'}, + {control: 'text', description: 'Live only: handle of the SELF flow.', name: 'flowHandle', type: 'string'}, + ], + defaults: {attribute: 'email', flowHandle: '', source: 'base'}, + group: 'User', + id: 'verifyattribute', + live: true, + name: 'VerifyAttribute', + render: (args: Args, action: Action) => + str(args, 'source') === 'live' ? ( + + action('onComplete')} + onError={(error: Error) => action('onError', error.message)} + /> + + ) : ( + + Promise.resolve(SAMPLE_VERIFICATION_STEP)} + onSubmit={(payload: EmbeddedSelfFlowRequest) => { + action('onSubmit', JSON.stringify(payload)); + + return submitSampleVerification(payload); + }} + onComplete={() => action('onComplete')} + /> + + ), + stories: [ + {id: 'base', name: 'Sample flow (code 123456)'}, + {args: {source: 'live'}, id: 'live', name: 'Signed-in user (sends a code)'}, + ], + }), { code: () => ({ imports: ['User'], diff --git a/playgrounds/vue/src/pages/explorer/PrimeRenderers.ts b/playgrounds/vue/src/pages/explorer/PrimeRenderers.ts index 29f29962..9748599d 100644 --- a/playgrounds/vue/src/pages/explorer/PrimeRenderers.ts +++ b/playgrounds/vue/src/pages/explorer/PrimeRenderers.ts @@ -1,13 +1,14 @@ // Copyright 2026 The ThunderID Authors // SPDX-License-Identifier: Apache-2.0 -import type {ManagedUser, MembershipGroup} from '@thunderid/browser'; +import type {EmbeddedFlowComponent, ManagedUser, MembershipGroup} from '@thunderid/browser'; import { type AdminListRenderProps, type BaseApplicationCreateRenderProps, type BaseApplicationDetailsRenderProps, type BaseGroupCreateRenderProps, type BaseUserDetailsRenderProps, + type BaseVerifyAttributeRenderProps, type DataTableColumn, useTheme, } from '@thunderid/vue'; @@ -300,3 +301,83 @@ export const applicationCard = (state: BaseApplicationDetailsRenderProps): VNode title: () => (state.isLoading && !state.application ? 'Loading…' : (state.application?.name ?? '')), }, ); + +// The step's components of one kind, at any depth. +const flowComponents = (components: EmbeddedFlowComponent[], types: string[]): EmbeddedFlowComponent[] => + components.flatMap((component: EmbeddedFlowComponent) => [ + ...(types.includes(component.type) ? [component] : []), + ...flowComponents(component.components ?? [], types), + ]); + +const INPUT_TYPES: string[] = ['TEXT_INPUT', 'EMAIL_INPUT', 'PHONE_INPUT', 'OTP_INPUT', 'PASSWORD_INPUT']; + +/** The verification's default scoped slot drawn as a PrimeVue card: inputs, actions, resend and cancel. */ +export const verifyForm = (state: BaseVerifyAttributeRenderProps): VNode => { + const isActive: boolean = !state.isComplete && !state.isFailed; + + return h( + Card, + {}, + { + content: () => + h('div', {class: 'pg-prime-stack'}, [ + state.error ? h(Message, {severity: 'error'}, {default: () => state.error}) : null, + state.isComplete ? h(Message, {severity: 'success'}, {default: () => state.successMessage}) : null, + state.isLoading && !state.components.length ? h('p', 'Sending…') : null, + ...(isActive + ? flowComponents(state.components, INPUT_TYPES).map((input: EmbeddedFlowComponent) => { + const name: string = input.ref ?? input.id; + return h('label', {class: 'pg-prime-field', key: name}, [ + h('span', String(input.label ?? name)), + h(InputText, { + autocomplete: 'one-time-code', + modelValue: state.values[name], + 'onUpdate:modelValue': (value: string | undefined) => state.handleInputChange(name, value ?? ''), + }), + state.touched[name] && state.fieldErrors[name] + ? h( + Message, + {severity: 'error', size: 'small', variant: 'simple'}, + {default: () => state.fieldErrors[name]}, + ) + : null, + ]); + }) + : []), + ]), + footer: () => + h('div', {class: 'pg-prime-actions'}, [ + state.onCancel + ? h(Button, { + label: isActive ? 'Cancel' : 'Close', + onClick: state.onCancel, + severity: 'secondary', + text: true, + }) + : null, + state.handleResend + ? h(Button, { + disabled: state.isLoading, + label: 'Send a new code', + onClick: () => void state.handleResend?.(), + severity: 'secondary', + }) + : null, + state.onDone ? h(Button, {label: 'Done', onClick: state.onDone}) : null, + ...(isActive + ? flowComponents(state.components, ['ACTION']).map((action: EmbeddedFlowComponent) => + h(Button, { + disabled: !state.isValid || state.isLoading, + key: action.id, + label: String(action.label ?? action.id), + loading: state.isLoading, + onClick: () => void state.handleSubmit(action, state.values), + }), + ) + : []), + ]), + subtitle: () => [state.subtitle, state.destinationText].filter(Boolean).join(' '), + title: () => state.title, + }, + ); +}; diff --git a/playgrounds/vue/src/pages/explorer/customRenderStories.ts b/playgrounds/vue/src/pages/explorer/customRenderStories.ts index 9cfbe0e7..4953e4cd 100644 --- a/playgrounds/vue/src/pages/explorer/customRenderStories.ts +++ b/playgrounds/vue/src/pages/explorer/customRenderStories.ts @@ -1,7 +1,14 @@ // Copyright 2026 The ThunderID Authors // SPDX-License-Identifier: Apache-2.0 -import type {Application, ManagedUser, MembershipGroup} from '@thunderid/browser'; +import { + type Application, + type EmbeddedSelfFlowRequest, + type EmbeddedSelfFlowResponse, + EmbeddedSelfFlowStatus, + type ManagedUser, + type MembershipGroup, +} from '@thunderid/browser'; import { type AdminListRenderProps, ApplicationCreate, @@ -16,12 +23,23 @@ import { BaseUserDetails, type BaseUserDetailsRenderProps, BaseUserList, + BaseVerifyAttribute, + type BaseVerifyAttributeRenderProps, GroupCreate, UserDetails, UserList, + VerifyAttribute, } from '@thunderid/vue'; import {Component, defineComponent, h, PropType, Ref, ref, VNode} from 'vue'; -import {applicationCard, applicationForm, groupForm, PrimeTheme, userCard, userTable} from './PrimeRenderers'; +import { + applicationCard, + applicationForm, + groupForm, + PrimeTheme, + userCard, + userTable, + verifyForm, +} from './PrimeRenderers'; import {bool, num, quoted, SOURCE_CONTROL, str} from './storyKit'; import type {Action, Args, ComponentDef} from './types'; import {narrow} from './widgets'; @@ -71,6 +89,50 @@ const SAMPLE_APPLICATION: Application = { /** A secret shown in the "secret shown once" stories. Sample only, never a real credential. */ const SAMPLE_SECRET = 'sample-secret-5f1c9e2a'; +// A self attribute verification flow that accepts the code 123456, for the Base verification stories. +export const SAMPLE_VERIFICATION_STEP: EmbeddedSelfFlowResponse = { + challengeToken: 'sample', + data: { + additionalData: {maskedDestination: 'l•••@acme.io'}, + components: [ + {id: 'heading', label: 'Verify your email address', type: 'TEXT', variant: 'HEADING_1'}, + { + id: 'message', + label: 'Enter the verification code sent to your email address.', + type: 'TEXT', + variant: 'HEADING_6', + }, + { + components: [ + {id: 'input_otp', label: 'Verification code', ref: 'otp', required: true, type: 'OTP_INPUT'}, + {eventType: 'SUBMIT', id: 'action_verify', label: 'Verify', type: 'ACTION', variant: 'PRIMARY'}, + {eventType: 'SUBMIT', id: 'action_resend', label: 'Resend code', type: 'RESEND'}, + ], + id: 'block_otp', + type: 'BLOCK', + }, + ], + }, + executionId: 'sample', + flowStatus: EmbeddedSelfFlowStatus.Incomplete, +} as unknown as EmbeddedSelfFlowResponse; + +export const submitSampleVerification = (payload: EmbeddedSelfFlowRequest): Promise => { + if (payload.action === 'action_resend' || payload.inputs?.['otp'] === '123456') { + return Promise.resolve( + payload.action === 'action_resend' + ? SAMPLE_VERIFICATION_STEP + : {executionId: 'sample', flowStatus: EmbeddedSelfFlowStatus.Complete}, + ); + } + + return Promise.resolve({ + ...SAMPLE_VERIFICATION_STEP, + data: {components: SAMPLE_VERIFICATION_STEP.data?.components}, + error: {message: {defaultValue: 'The code is incorrect.', key: 'otp.invalid'}}, + } as unknown as EmbeddedSelfFlowResponse); +}; + const isLive = (args: Args): boolean => str(args, 'source') === 'live'; const themed = (width: number, child: VNode): VNode => narrow(width, h(PrimeTheme, null, {default: () => child})); @@ -203,6 +265,25 @@ const APPLICATION_CARD_TEMPLATE = ` + +`; + /** * Stories that replace an admin component's default UI through its default scoped slot, here with * PrimeVue. The same slot props work with any component library. @@ -361,6 +442,61 @@ export const CUSTOM_RENDER_COMPONENTS: ComponentDef[] = [ {args: {source: 'live'}, id: 'live', name: 'Live (needs userId)'}, ], }, + { + code: () => ({ + imports: ['VerifyAttribute'], + otherImports: [ + "import Button from 'primevue/button';", + "import Card from 'primevue/card';", + "import InputText from 'primevue/inputtext';", + "import Message from 'primevue/message';", + ], + template: VERIFY_TEMPLATE, + }), + controls: [ + SOURCE_CONTROL, + {control: 'text', description: 'Live only: attribute to verify.', name: 'attribute', type: 'string'}, + ], + defaults: {attribute: 'email', source: 'base'}, + group: 'Custom rendering', + id: 'custom-verifyattribute-form', + live: true, + name: 'VerifyAttribute → PrimeVue form', + render: (args: Args, action: Action): VNode => + themed( + 440, + isLive(args) + ? h( + VerifyAttribute, + { + attribute: str(args, 'attribute') || 'email', + key: `live-${str(args, 'attribute')}`, + onCancel: (): void => action('cancel'), + onComplete: (): void => action('complete'), + onError: (error: Error): void => action('error', quoted(error.message)), + }, + {default: (state: BaseVerifyAttributeRenderProps) => verifyForm(state)}, + ) + : h( + BaseVerifyAttribute, + { + attributeDisplayName: 'Email', + onCancel: (): void => action('cancel'), + onComplete: (): void => action('complete'), + onInitialize: (): Promise => Promise.resolve(SAMPLE_VERIFICATION_STEP), + onSubmit: (payload: EmbeddedSelfFlowRequest): Promise => { + action('submit', JSON.stringify(payload)); + return submitSampleVerification(payload); + }, + }, + {default: (state: BaseVerifyAttributeRenderProps) => verifyForm(state)}, + ), + ), + stories: [ + {id: 'base', name: 'Sample flow (code 123456)'}, + {args: {source: 'live'}, id: 'live', name: 'Live (needs sign-in, sends a code)'}, + ], + }, { code: () => ({ imports: ['ApplicationCreate'], diff --git a/playgrounds/vue/src/pages/explorer/stories.ts b/playgrounds/vue/src/pages/explorer/stories.ts index 3b3f49ef..c226dced 100644 --- a/playgrounds/vue/src/pages/explorer/stories.ts +++ b/playgrounds/vue/src/pages/explorer/stories.ts @@ -10,6 +10,7 @@ import { BaseSignUpButton, BaseUserDropdown, BaseUserProfile, + BaseVerifyAttribute, Button, Card, ChangeCredential, @@ -42,10 +43,12 @@ import { User, UserDropdown, UserProfile, + VerifyAttribute, + type EmbeddedSelfFlowRequest, } from '@thunderid/vue'; import {Component, h} from 'vue'; import {ADMIN_COMPONENTS} from './adminStories'; -import {CUSTOM_RENDER_COMPONENTS} from './customRenderStories'; +import {CUSTOM_RENDER_COMPONENTS, SAMPLE_VERIFICATION_STEP, submitSampleVerification} from './customRenderStories'; import {bool, CARD_VARIANT, handler, num, omit, quoted, SIZE, slot, SOURCE_CONTROL, str, tpl} from './storyKit'; import type {Action, Args, ComponentDef} from './types'; import {narrow, VModel} from './widgets'; @@ -1098,6 +1101,59 @@ export const COMPONENTS: ComponentDef[] = [ {args: {source: 'live'}, id: 'live', name: 'Signed-in user (editable)'}, ], }, + { + code: (args: Args) => + str(args, 'source') === 'live' + ? { + imports: ['VerifyAttribute'], + script: handler('handleComplete'), + template: tpl('VerifyAttribute', omit(args, 'source'), {extra: ['@complete="handleComplete"']}), + } + : { + imports: ['BaseVerifyAttribute'], + template: tpl( + 'BaseVerifyAttribute', + {}, + {extra: [':on-initialize="startFlow"', ':on-submit="submitStep"']}, + ), + }, + controls: [ + SOURCE_CONTROL, + {control: 'text', description: 'Live only: attribute to verify.', name: 'attribute', type: 'string'}, + {control: 'text', description: 'Live only: handle of the SELF flow.', name: 'flowHandle', type: 'string'}, + ], + defaults: {attribute: 'email', flowHandle: '', source: 'base'}, + group: 'User', + id: 'verifyattribute', + live: true, + name: 'VerifyAttribute', + render: (args: Args, action: Action) => + narrow( + 440, + str(args, 'source') === 'live' + ? h(VerifyAttribute, { + attribute: str(args, 'attribute') || 'email', + flowHandle: str(args, 'flowHandle') || undefined, + key: `${str(args, 'attribute')}:${str(args, 'flowHandle')}`, + onComplete: () => action('complete'), + onError: (error: Error) => action('error', error.message), + }) + : h(BaseVerifyAttribute, { + attributeDisplayName: 'Email', + onComplete: () => action('complete'), + onInitialize: () => Promise.resolve(SAMPLE_VERIFICATION_STEP), + onSubmit: (payload: EmbeddedSelfFlowRequest) => { + action('submit', JSON.stringify(payload)); + + return submitSampleVerification(payload); + }, + }), + ), + stories: [ + {id: 'base', name: 'Sample flow (code 123456)'}, + {args: {source: 'live'}, id: 'live', name: 'Signed-in user (sends a code)'}, + ], + }, { code: () => ({ imports: ['User'],