From 0285eb4621b90a48cf7c8db5be1970010c18ccdf Mon Sep 17 00:00:00 2001 From: Pasindu Yeshan Date: Thu, 29 Oct 2026 18:44:47 +0530 Subject: [PATCH] Let users verify their email address and mobile number from their account Adds a VerifyAttribute component and its Base variant to React, Vue, Next.js and Nuxt. It runs the server's SELF_SERVICE attribute verification flow, started by flow handle with the user's token, and renders its steps from the flow response. UserProfile shows each contact's verification status and offers Verify for an unverified one. The core SDK adds executeEmbeddedSelfServiceFlow, the request parser the Next.js action and the Nuxt route use to forward a step, and helpers that read verifiedAttributes from the profile. Signed-off-by: Pasindu Yeshan --- .../__tests__/executeEmbeddedSelfFlow.test.ts | 216 +++++ .../src/api/auth/executeEmbeddedSelfFlow.ts | 148 ++++ .../AttributeVerificationConstants.ts | 42 + packages/javascript/src/i18n/models/i18n.ts | 19 + .../javascript/src/i18n/translations/en-US.ts | 19 + .../javascript/src/i18n/translations/fr-FR.ts | 20 + .../javascript/src/i18n/translations/hi-IN.ts | 19 + .../javascript/src/i18n/translations/ja-JP.ts | 20 + .../javascript/src/i18n/translations/pt-BR.ts | 20 + .../javascript/src/i18n/translations/pt-PT.ts | 20 + .../javascript/src/i18n/translations/si-LK.ts | 20 + .../javascript/src/i18n/translations/ta-IN.ts | 20 + .../javascript/src/i18n/translations/te-IN.ts | 20 + packages/javascript/src/index.ts | 13 +- .../javascript/src/models/embedded-flow.ts | 4 + .../src/models/embedded-self-flow.ts | 91 ++ .../src/models/extensions/components.ts | 2 +- packages/javascript/src/models/user.ts | 10 + .../__tests__/getFlowStepComponents.test.ts | 33 + .../__tests__/getVerifiedAttributes.test.ts | 37 + .../__tests__/isVerifiableAttribute.test.ts | 41 + .../__tests__/parseSelfFlowRequest.test.ts | 71 ++ .../src/utils/getFlowStepComponents.ts | 56 ++ .../src/utils/getVerifiedAttributes.ts | 25 + .../src/utils/isVerifiableAttribute.ts | 40 + .../src/utils/parseSelfFlowRequest.ts | 64 ++ packages/nextjs/src/ThunderIDNextClient.ts | 17 + .../account/UserProfile/UserProfile.tsx | 45 +- .../VerifyAttribute/VerifyAttribute.tsx | 136 +++ .../__tests__/VerifyAttribute.test.ts | 65 ++ .../contexts/ThunderID/ThunderIDProvider.tsx | 19 + packages/nextjs/src/client/index.ts | 3 + .../__tests__/executeSelfFlowAction.test.ts | 91 ++ .../actions/auth/executeSelfFlowAction.ts | 52 ++ packages/node/src/ThunderIDNodeClient.ts | 24 + .../src/__tests__/ThunderIDNodeClient.test.ts | 26 + packages/nuxt/src/module.ts | 6 + .../runtime/components/account/UserProfile.ts | 41 +- .../components/account/VerifyAttribute.ts | 154 ++++ .../src/runtime/constants/NuxtAPIRoutes.ts | 3 + .../server/routes/auth/user/self-flow.post.ts | 60 ++ .../tests/unit/account-custom-render.test.ts | 127 +++ .../nuxt/tests/unit/self-flow-post.test.ts | 120 +++ .../__tests__/executeEmbeddedSelfFlow.test.ts | 71 ++ .../src/api/auth/executeEmbeddedSelfFlow.ts | 54 ++ .../UserProfile/BaseUserProfile.styles.ts | 42 + .../account/UserProfile/BaseUserProfile.tsx | 229 ++++- .../account/UserProfile/UserProfile.tsx | 47 +- .../__tests__/BaseUserProfile.test.tsx | 549 ++++++++++++ .../__tests__/UserProfile.test.tsx | 140 +++ .../BaseVerifyAttribute.styles.ts | 80 ++ .../VerifyAttribute/BaseVerifyAttribute.tsx | 660 +++++++++++++++ .../VerifyAttribute/VerifyAttribute.tsx | 142 ++++ .../__tests__/BaseVerifyAttribute.test.tsx | 627 ++++++++++++++ .../__tests__/VerifyAttribute.test.tsx | 410 +++++++++ ...ow-when-type-REGISTRATION-is-present-1.png | Bin 2082 -> 0 bytes ...-when-type-AUTHENTICATION-is-present-1.png | Bin 2082 -> 0 bytes .../auth/flow/AuthOptionFactory.tsx | 58 +- .../flow/__tests__/AuthOptionFactory.test.tsx | 109 ++- ...oji--spec-as-its-glyph--not-an-image-1.png | Bin 6923 -> 0 bytes ...angs-a-ribbon-off-the-edge-of-a-card-1.png | Bin 2370 -> 0 bytes .../ComponentRendererContext.ts | 2 +- .../contexts/ThunderID/ThunderIDProvider.tsx | 24 +- .../__tests__/ThunderIDProvider.test.tsx | 56 +- packages/react/src/index.ts | 15 + .../auth/execute-embedded-self-flow.test.ts | 82 ++ .../account/user-profile-verification.test.ts | 558 ++++++++++++ .../account/verify-attribute.test.ts | 795 ++++++++++++++++++ .../__tests__/components/auth/sign-in.test.ts | 51 +- .../src/api/auth/executeEmbeddedSelfFlow.ts | 40 + .../account/user-profile/BaseUserProfile.ts | 293 ++++++- .../account/user-profile/UserProfile.css.ts | 34 + .../account/user-profile/UserProfile.ts | 24 +- .../verify-attribute/BaseVerifyAttribute.ts | 531 ++++++++++++ .../verify-attribute/VerifyAttribute.css.ts | 68 ++ .../verify-attribute/VerifyAttribute.ts | 163 ++++ .../components/auth/flow/AuthOptionFactory.ts | 7 +- .../auth/flow/AuthOptionFactoryCore.ts | 56 ++ packages/vue/src/index.ts | 19 + packages/vue/src/styles/injectStyles.ts | 2 + .../nextjs/src/explorer/CustomRenderParts.tsx | 76 +- .../src/explorer/customRenderSamples.ts | 52 +- .../src/explorer/customRenderStories.tsx | 91 +- playgrounds/nextjs/src/explorer/stories.tsx | 55 ++ playgrounds/node/README.md | 2 +- playgrounds/node/src/catalog.ts | 18 + playgrounds/node/src/operations.ts | 7 + playgrounds/node/src/trace.ts | 1 + playgrounds/nuxt/lib/customRenderStories.ts | 158 +++- playgrounds/nuxt/lib/primeRenderers.ts | 83 +- playgrounds/nuxt/lib/stories.ts | 57 +- .../pages/explorer/CustomRenderWidgets.tsx | 70 ++ .../pages/explorer/customRenderStories.tsx | 96 ++- .../react/src/pages/explorer/stories.tsx | 55 ++ .../vue/src/pages/explorer/PrimeRenderers.ts | 83 +- .../src/pages/explorer/customRenderStories.ts | 140 ++- playgrounds/vue/src/pages/explorer/stories.ts | 58 +- 97 files changed, 9157 insertions(+), 62 deletions(-) create mode 100644 packages/javascript/src/api/auth/__tests__/executeEmbeddedSelfFlow.test.ts create mode 100644 packages/javascript/src/api/auth/executeEmbeddedSelfFlow.ts create mode 100644 packages/javascript/src/constants/AttributeVerificationConstants.ts create mode 100644 packages/javascript/src/models/embedded-self-flow.ts create mode 100644 packages/javascript/src/utils/__tests__/getFlowStepComponents.test.ts create mode 100644 packages/javascript/src/utils/__tests__/getVerifiedAttributes.test.ts create mode 100644 packages/javascript/src/utils/__tests__/isVerifiableAttribute.test.ts create mode 100644 packages/javascript/src/utils/__tests__/parseSelfFlowRequest.test.ts create mode 100644 packages/javascript/src/utils/getFlowStepComponents.ts create mode 100644 packages/javascript/src/utils/getVerifiedAttributes.ts create mode 100644 packages/javascript/src/utils/isVerifiableAttribute.ts create mode 100644 packages/javascript/src/utils/parseSelfFlowRequest.ts create mode 100644 packages/nextjs/src/client/components/account/VerifyAttribute/VerifyAttribute.tsx create mode 100644 packages/nextjs/src/client/components/account/VerifyAttribute/__tests__/VerifyAttribute.test.ts create mode 100644 packages/nextjs/src/server/actions/auth/__tests__/executeSelfFlowAction.test.ts create mode 100644 packages/nextjs/src/server/actions/auth/executeSelfFlowAction.ts create mode 100644 packages/nuxt/src/runtime/components/account/VerifyAttribute.ts create mode 100644 packages/nuxt/src/runtime/server/routes/auth/user/self-flow.post.ts create mode 100644 packages/nuxt/tests/unit/account-custom-render.test.ts create mode 100644 packages/nuxt/tests/unit/self-flow-post.test.ts create mode 100644 packages/react/src/api/auth/__tests__/executeEmbeddedSelfFlow.test.ts create mode 100644 packages/react/src/api/auth/executeEmbeddedSelfFlow.ts create mode 100644 packages/react/src/components/account/UserProfile/__tests__/BaseUserProfile.test.tsx create mode 100644 packages/react/src/components/account/UserProfile/__tests__/UserProfile.test.tsx create mode 100644 packages/react/src/components/account/VerifyAttribute/BaseVerifyAttribute.styles.ts create mode 100644 packages/react/src/components/account/VerifyAttribute/BaseVerifyAttribute.tsx create mode 100644 packages/react/src/components/account/VerifyAttribute/VerifyAttribute.tsx create mode 100644 packages/react/src/components/account/VerifyAttribute/__tests__/BaseVerifyAttribute.test.tsx create mode 100644 packages/react/src/components/account/VerifyAttribute/__tests__/VerifyAttribute.test.tsx delete mode 100644 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 delete mode 100644 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 delete mode 100644 packages/react/src/components/auth/flow/__tests__/__screenshots__/ImageComponent.test.tsx/ImageComponent-renders-an-emoji--spec-as-its-glyph--not-an-image-1.png delete mode 100644 packages/react/src/components/common/Attribution/__tests__/__screenshots__/Attribution.test.tsx/Attribution-hangs-a-ribbon-off-the-edge-of-a-card-1.png create mode 100644 packages/vue/src/__tests__/api/auth/execute-embedded-self-flow.test.ts create mode 100644 packages/vue/src/__tests__/components/account/user-profile-verification.test.ts create mode 100644 packages/vue/src/__tests__/components/account/verify-attribute.test.ts create mode 100644 packages/vue/src/api/auth/executeEmbeddedSelfFlow.ts create mode 100644 packages/vue/src/components/account/verify-attribute/BaseVerifyAttribute.ts create mode 100644 packages/vue/src/components/account/verify-attribute/VerifyAttribute.css.ts create mode 100644 packages/vue/src/components/account/verify-attribute/VerifyAttribute.ts 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 6e941c57..19389495 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'; @@ -929,6 +938,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..c3ca8c1b 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,30 @@ 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, + }: BaseUserProfileVerificationSlotProps): VNode => + h(VerifyAttribute, { + attribute, + attributeDisplayName, + flowHandle: props.verificationFlowHandle, + key: attribute, + onCancel, + onDone: onCancel, + }); + return (): VNode => h( BaseUserProfile, @@ -76,15 +105,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..b7b0d2e6 100644 --- a/packages/react/src/components/account/UserProfile/BaseUserProfile.styles.ts +++ b/packages/react/src/components/account/UserProfile/BaseUserProfile.styles.ts @@ -197,6 +197,41 @@ const useStyles = (theme: Theme, colorScheme: string): Record => margin-bottom: calc(${theme.vars.spacing.unit} * 3); `; + const verificationStatus: string = css` + flex-shrink: 0; + padding: 0 ${theme.vars.spacing.unit}; + border: 1px solid currentColor; + border-radius: ${theme.vars.borderRadius.small}; + font-size: 0.75rem; + line-height: 20px; + white-space: nowrap; + `; + + const verified: string = css` + color: ${theme.vars.colors.success.main}; + `; + + const unverified: string = css` + color: ${theme.vars.colors.text.secondary}; + `; + + const verification: string = css` + padding: ${theme.vars.spacing.unit} 0; + `; + + // 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 +255,22 @@ const useStyles = (theme: Theme, colorScheme: string): Record => sectionLabel, sectionRow, sectionValue, + unverified, value, valuePlaceholder, + verification, + 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.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..38285689 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'; @@ -71,12 +82,35 @@ 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; +} + // Fields to skip based on schema.name const fieldsToSkip: string[] = [ 'roles.default', @@ -101,11 +135,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 +167,59 @@ 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); + // 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] + ) { + setVerifyingField(name); + } + setPendingVerification(null); + }, [pendingVerification, flattenedProfile, profile, error, verifiedAttributes, supportsVerification]); useEffect(() => { const nextUser = flattenedProfile ?? profile; @@ -224,6 +312,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 +413,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 +700,59 @@ 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 ? t('user.verification.status.verified') : t('user.verification.status.unverified')} + {/* A title is not reachable by keyboard or touch, so the time is also given as text. */} + {verifiedAtTitle && {` (${verifiedAtTitle})`}} + + ); + }; + + const renderVerificationPanel = (schema: Schema): ReactNode => { + if (!renderVerification || !schema.name || verifyingField !== schema.name) { + return null; + } + + return ( +
+ {renderVerification({ + attribute: schema.name, + attributeDisplayName: schema.displayName ?? startCase(schema.name), + onCancel: (): void => setVerifyingField(null), + })} +
+ ); + }; + const renderUserInfo = (schema: Schema): any => { if (!schema?.name) return null; @@ -594,6 +766,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 +803,27 @@ 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 +846,10 @@ const BaseUserProfile: FC = ({ size="small" color="tertiary" variant="icon" - onClick={(): any => toggleFieldEdit(schema.name)} + onClick={(): any => { + if (verifyingField === schema.name) setVerifyingField(null); + toggleFieldEdit(schema.name); + }} title="Edit" className={styles.editButton} > @@ -733,6 +947,7 @@ const BaseUserProfile: FC = ({ {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..ad42f6d6 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,25 @@ const UserProfile: FC = ({preferences, editable = true, ...res } }; + const renderVerification = ({ + attribute, + attributeDisplayName, + onCancel, + }: 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')); + + expect(within(row('Email')).getByText('Verifying Email (email)')).toBeTruthy(); + 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(row('Mobile')).getByText('Verifying Mobile (mobile)')).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..361ac1dc --- /dev/null +++ b/packages/react/src/components/account/VerifyAttribute/BaseVerifyAttribute.styles.ts @@ -0,0 +1,80 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {Theme} 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); + `; + + 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..28400557 --- /dev/null +++ b/packages/react/src/components/account/VerifyAttribute/BaseVerifyAttribute.tsx @@ -0,0 +1,660 @@ +// 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} + + )} + {showSubtitle && 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 47767d2f3db3e57c7b5cb7d5e6f5d4e0ed04e8ff..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2082 zcmeAS@N?(olHy`uVBq!ia0y~yVDx2RV7kD;1QeOwv~@BA1N(JP7srr_Id3jA@-i53 z95DEFXMgI4ja%e?m6tbcF?X2z{K9j~FO__?^PK08GDpKv_u@O4hD6# qC=0CM|NmbQa_}pV!3L`0nHgejI2P=w?qmguFnGH9xvXv_u@O4hD6# qC=0CM|NmbQa_}pV!3L`0nHgejI2P=w?qmguFnGH9xvX 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: @@ -1143,6 +1164,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 539d54cdacbe86d7fb29c1941c4e962734c45fd2..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6923 zcmeHM=~L6$w#7yp6$4&vL8T$uB3x|{6$K>>wy3l%pciEfgUkX6VUhqLL>Ze_plJj| z42ZNKjSxdfLI_C|WHyX4Wco1^fec_EVM=o8_vO{AdhgZ!1FSlq_O3doYM;GU?X`C1 zEk`@h?!&uPR8&CMf4<_RqO#3FMdh2mo!@Llij#?XDk^(bu3!1-PTX?=Z!X$v1>L?x zMGVl`wOV0#SUg$v%t8Cp-8zAA{ORZ@d~^8wYp@1|O;4-51EVo?lx`a=-t$pjvHyW3zzp z&*4zwWtBg#URL?kfAK|5&o*?7#^>_rOJJ<(`seTa?D!9?E!39(N6N=2^+EN>Le@1x z|7-H)vWN@cXI*(|prxLnFnQ*Hs!FE?5Qg@kOb%wY3R?Z7T}QWTVbz|Vk4Hz%*N~Hx z>vN`Jn#`NxdEFqdT*q9*h#o89QPsZvcHh#fa&yQ?{K{#LhcuxltuIre5xpOKu9fEk ziDF}N;v@-*;L*`g3#`V`r_Ef9-Yv7ojQdi-*rxDyp$WsM`bjzdwN6iq+4J^Ed?H=B zy5z6P#k|JhZ1jAro^g8F*6($su1eEs&4Jh^H*fDGiGT&Rk#ThxDE~l8fOr>CpuB8W zm(iSCk|;iAm!s$eEB_T79K3Ve75>8jUHaN=F=lB6chkY)K5`}@A)%2!m?K&?#{2pTOI8Qor>LEst9KCbiscQo(gx&(UlXFEew25q6_&06nn_EOCJV$+ z*We7=or)Uc!x|&=M8C+$poF0Cq%x0co26BMpS3TA95(xDi<$;jZGadyqKDeVKhic4 zaq(yv>`gggu%Hjf8PF+*>xUoE);3{a;_0`V0>$(U@I`t^%!kWFb7&Z+G##1@tu;G< zb3SRZIhp1|Tkdv>4DX)B(;q4Ve&`RSXiMnM)lOBOw1kz-HOb(h=~&Wy)l|EUwsOo! zZBe-mt9Fyj^8#6gg_CB(+h`dTc@hBMSutZa&!A(Br7f~{a9G} zTRBc+gaW%>e<}Eb<=9w|uD1zYF20Xsg)MYonBu zmHA#dD zEU{Q&SKY>OG)i@MUf!vZ;_i}PmlM!a{sbcQtNpNg6+-mq1DyZeTump4tYn1 zO?_@71Y?xV>L!-UeZCf&T7SRPiM5PxW{bpP@?W;$c1-JEyg)oi}7WOxc)nFRjn8 zt%kt7r-0JeS$v0$`gG6+@hx*;f#P{;-ywW&5f&?Dbm?B0l)gLw8PxK-uXj2|@3f1c zudlDGtIj!e==?KK_0UOyNJJ!rY5D!2MVx75R`c9M=S85oi4JzX)$+uW<)j>*&sjP) z8C~jDLm~&RuYX%mP$2l!2=BPc`}ofKVj(mA*w(dMr+@>{$nN=RX*6PJIRmlJL#L0{9ke+toN^!^2QQ7ekY^zlQF6KJqercw!~iu7 z&hLb(4?S3j@B?(`!)CP6HvcjW4N!J=xMI9ZjRXqEviAUvF|P)zu2>fQVlZV!jpals zIG}##T%t1-RcZ5pifKq|k{vAu>F4c25m-NAFr`as8*@c-t*xyn(Zvn}&+)$a#Q+YW zRI#k;it{Xb`qW)q6Wt80%mI5C1M)!JAB#b1nRm{f zu{_5eViBEA`7|tsSJ2SCB{xU$M^Z19NyhP;*}67*nQg#2nLsiC^DaEIZ)=+1_Pn(7 z4?B*__TlL;W!pKGRx7=ye;tsAU;N3_HpGo)~ynbmo;LP!Q%uP=_LKir#~ z-jmY)4Ka^js@yCs`0bHxiY>*{&NBSQ>=Uv~%N<$NI7(cmit&yPQ!rl z%9E_QmKIwnkcbeE(AUrRyx#E-^_oj{_gC|7Dk#i1@C~K`VAi~GL$rtyGUACji51jp zCTCPLeuP1qWTs-{sSC9?(eolze}8}9Q}Li97Ml8xYK-&ZKQB)F32&;wUZql;5tI`9 z#$~Lm_-$AV&Wn2syj6v#H<*I zbGR7ak|AX9nv!!8P*IsA5~+1^QXnugx8CuOsrW^=kf{-zYuAiD6v}0emWQ|LMY#tJ zgglZiSKY4ceU9%jEm{_NQN{X5!NmL@?@C&FD#~yyH>w>?v%E&UR7BXOafHxamvX zVuq5(P>On!q=e4S>q zCOs9ti0C#hO%$m6#EqMl;?Hm~mB>49DVGREj<4pk+E!ODR4>2VVuO%|fqk7<&gOek zzZ{<&EBDLgWYFcygEniOHY}>}(q52Vv9UN|m0yDDQ7C5385Hz}bYSu&^Bcka60{Ut zs-RX1Q{DOOW=_RAu4&!Gwbm2gskGI!n#Mqs8)iK0=c+4y2?=a7<>rR(KfdcnBu?oZ z-JY6NE0IV>YP|@Z1nhN5?D%9PW*0oy#KBi@|4z-}p=V{0yK z+sG*-eBo=4;Ig?$#yczQGv%tY4s|U07IXyHmqL^N*-kFku3@&~D_cW(JQK)<2U!Q|O~` z#=CWp_=lP4HMN=msuccL{7odn%nxv0h}2m$X^MXD&7U8t);`tb-=39<+~xJ*C2?u3 zqrW`G);2bj>HVofb*llbks{9zV;_;O%rz{^yz*MJ87g?n>e6S+(@log4*W*4&&K88 zMbfi~C_O^wCW{a}7@||^pOrvxsYb+vl3Kj0+nY{Y;z|c*`Ft2SJSZs0x9qOZ0py_K zl~X54gcNW?40~N%Tm(9S9D=$;LJAQFg6|`VwLHP3Q%*0FnYQZPZRAzxOzeSP*)(te4^38*s8>!1x7G8?6K6!3 z=ZsNU34g38QV=oe*uhcyru0jWrU`;vH(;?SR`2s9NH{qE9$y-2u8`mlc~RoVS?^X9MZ`_GV9X04#7L_$96 z1p;>$1rz-hD}H@fOltW}_zo4F-nU-vCr?Y@S!r3MKDcYCxet+yb)=G95!c*DLR)EO zvnA5KS^hi^5Lla{;a`xT1jF9jCuHNU;=|66g#vt9BbD

KT=a_ssj|k9# zV|LklG(nsg;0s;cR|b)dP58*qzV%JfH83zV6s|~sJpKLQX?nbIfU)l8CZl>^7Uhb7 zJl_S7ljh!8;X~gP(vGIn<_s+$G*NVPAq&nS2?5&1htBSx?gV3{}pm2gN|W!06S9+FH@TmpaUF4UbxE z9B7;1>NOmS`BY@8YLCPFKdEnLK3HI=9d}|q2Tjk?QfBgHBtt0 zu>>U@yYJe8I`~FO;}U>kz!d17sWYCtZguHkuWC!-A z2xmy#;`paepJWUfZEY>S7{U1*c3#Ay@d1>WSVs4#QAo)Q;wZdurZxN9)9@0kfVZT%;uzZdbH%=w3y_7**g~z|F(f1~&^rYw) zGGp%}wI4kqMF(}RX?LCD33zSsP%q_szICwrmqMQ;7KvpFheOG59&$ry#k1lA*0O;c zCam4uH1=X|7V8P{tz<()6MxP<=3m?n#7$*oR$Ye}l7o9i$4afvT3C2Q=9=q-``$&Y zG@|MWAbx&X8R+PCHLO~uFbTn{n?V9^o4(7cu`aU`gdc{-A&c|(XHKVRCe8N9H~9S7 z-D%0fS1i_fXk4kakoP+)sX<9t_5d`5$vDkL0Nkw{g1(R!yzxd1P(6l*fN%+neg_AaMb;KLhFAZxq{ zflVW!0|tCJ>56!+6EQ~OY3wAqkllWCiD*s7h!34bqtQC%;q{=k$LOGwa7h~7TkLl0 zMBxJPBPt_DhjDhuo8o5FqrICND|{S9@OZH}xceZ-af}$&{tLy@$`l$)VISc{9A!V@ z8q7%bpznH3j7Dvo=IV_X28)(eHLc@5_Z=<%sI9FnRR6fT+MuhhhhFZ-5)&9m~MMD@Ox7gmEHA! zdjU1K8YX)3SlxGc(MMX!-g4S}#*zr5aQ)I}B}5$0f>iY0G9s?$%3?zXErB}ow*aoW zBX6d~QyHDJvE8tr$>{sCFz4H(bn=cwgwI&EE~m1x0ybh*+H12GeAD_%$C=`nu0I}7 zzW+laQ6~)rLDm#0J~R&ZFkoyVwUDSwOrs@MYnuQRf#+>CtHDQShi!p24HQZmxt9Uw z4{)oZ#Nk7WY|}qswQ&ABp?ob7hdq7sgWQLzg%Zn7s4%i9nBy@AAiLtTsU-?7o zeJy}L$9NS6H5EBZ@HL^(kMv%&bssrxLHZi^-kJ{~8HzuW!F z>4aVmKZ;V@({tqqz2OZuwz830h zp}yklKQVj1BIPSmz9Qu-QvTOX(0`Ky9iyhAqEtd!qqMhHTHpRXzpAPn4kM-JYmjbl QMO3a|b-Yr0`EK&R0oMU`#Q*>R 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 1ee76e0259f91f8af93d20ffe3b29256eea71d19..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2370 zcmeAS@N?(olHy`uVBq!ia0y~yVDx2RV7kD;1QeOwv~@BA1IKqy7srr_IdATI_uV!S zVQ{qi@OS+hj=;7D0iPI`Eze5i__ph#%d5xV3uTx+ej7Jf&2^rCZlVA43(JdNT((`z zXQ5vb=Q$sf`gZKUi36FQAiubQDTXSCVK~z`$ugn)RUeE5`{~M z|2&^%CT#;nie~o3&o^p~CTy}j`04)voA!BYvtJi;+`7lPviQWTt*>Ps*{o;V-$Y)@ z8nECcHtCN#kmyokG^j>{ikRADG@Xp56AIGFZ*B&L|Nq0Yet831LTsQW5HrKF6up=q SX;C|XA`G6celF{r5}E)?=NFFv 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..3a647632 --- /dev/null +++ b/packages/vue/src/__tests__/components/account/user-profile-verification.test.ts @@ -0,0 +1,558 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {AttributeSchema, User, getDefaultI18nBundles, substituteTranslationParams} from '@thunderid/browser'; +import {DOMWrapper, VueWrapper, 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, +})); + +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'), + ]); + +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'); + + expect(wrapper.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 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 5c2d7d4b..b87772e4 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 {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'; @@ -102,3 +103,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..caa8fad3 100644 --- a/packages/vue/src/components/account/user-profile/BaseUserProfile.ts +++ b/packages/vue/src/components/account/user-profile/BaseUserProfile.ts @@ -1,8 +1,34 @@ // 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, + type VNode, + defineComponent, + h, + inject, + 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'; @@ -55,6 +81,46 @@ 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; +} + +/** + * 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 +144,7 @@ const FIELDS_TO_SKIP: string[] = [ 'attributes', 'isReadOnly', 'isReadonly', + AttributeVerificationConstants.VERIFIED_ATTRIBUTES_KEY, ]; const READONLY_FIELDS: string[] = [ @@ -113,6 +180,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 +279,55 @@ 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); + // 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]) { + verifyingField.value = pending.name; + } + pendingVerification.value = null; + }, + ); + // ── Visibility ──────────────────────────────────────────────────────────── function shouldShowField(fieldName: string, isSchemaBased = false): boolean { @@ -217,6 +345,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,12 +408,107 @@ 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, + }, + [ + 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 renderVerificationPanel({displayName: props.userSchema?.[name]?.displayName, name}); + }, + verificationStatus: verificationStatusOf, + verifiedAttributes: reportedVerifiedAttributes(), + verify: (name: string): void => { + if (canVerify(name)) verifyingField.value = name; + }, + verifyingAttribute: verifyingField.value, + }; + }; + + function renderVerificationPanel(schema: ExtendedSchema): VNode | null { + const name: string | undefined = schema.name; + if (!slots['verification'] || !name || verifyingField.value !== name) return null; + + return h( + 'div', + {class: px('user-profile__verification')}, + slots['verification']({ + attribute: name, + attributeDisplayName: schema.displayName ?? startCase(name), + onCancel: (): void => { + verifyingField.value = null; + }, + } as BaseUserProfileVerificationSlotProps), + ); } // ── Input rendering per schema type ─────────────────────────────────────── @@ -333,6 +563,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 +600,26 @@ 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: () => { + verifyingField.value = name; + }, + size: 'small' as const, + variant: 'text' as const, + }, + () => tp('user.verification.buttons.verify.text'), + ), + ]) + : null, isEditable && !isReadonly ? h('div', {class: px('user-profile__field-actions')}, [ isEditing @@ -385,7 +649,10 @@ 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; + startEditing(name, value); + }, size: 'small' as const, startIcon: h(PencilIcon), title: t('userProfile.actions.edit', 'Edit'), @@ -476,12 +743,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; @@ -535,7 +812,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..684507e1 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,40 @@ img.thunderid-user-profile__avatar { gap: calc(var(--thunderid-spacing-unit) * 0.5); } +.thunderid-user-profile__verification-status { + flex-shrink: 0; + padding: 0 var(--thunderid-spacing-unit); + border: 1px solid currentColor; + border-radius: var(--thunderid-border-radius-small, 4px); + font-size: 0.75rem; + line-height: 20px; + white-space: nowrap; +} + +.thunderid-user-profile__verification-status--verified { + color: var(--thunderid-color-success-main); +} + +.thunderid-user-profile__verification-status--unverified { + color: var(--thunderid-color-text-secondary); +} + +.thunderid-user-profile__verification { + padding: var(--thunderid-spacing-unit) 0; +} + +.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..086b8111 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,21 @@ const UserProfile: Component = defineComponent({ } } + const renderVerification = ({ + attribute, + attributeDisplayName, + onCancel, + }: BaseUserProfileVerificationSlotProps): VNode => + h(VerifyAttribute, { + attribute, + attributeDisplayName, + flowHandle: props.verificationFlowHandle, + key: attribute, + onCancel, + onDone: onCancel, + preferences: resolvedPreferences.value, + }); + return (): VNode => h( BaseUserProfile, @@ -162,7 +182,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..38d5f23b --- /dev/null +++ b/packages/vue/src/components/account/verify-attribute/BaseVerifyAttribute.ts @@ -0,0 +1,531 @@ +// 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) + : null, + props.showSubtitle && destinationText + ? h(Typography, {class: px('verify-attribute__destination'), variant: 'body2'}, () => destinationText) + : 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..9b87d5ec --- /dev/null +++ b/packages/vue/src/components/account/verify-attribute/VerifyAttribute.css.ts @@ -0,0 +1,68 @@ +// 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__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 acc20207..ba9eb17b 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: @@ -744,6 +765,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'],