From 972d3a84dc2a982609ca0404647a0a7874942f39 Mon Sep 17 00:00:00 2001 From: Yathusiga27 Date: Wed, 28 Oct 2026 10:08:34 +0530 Subject: [PATCH] Add Device Grant support across JavaScript SDKs Signed-off-by: Yathusiga27 --- .../src/__tests__/deviceGrantExports.test.ts | 12 + .../src/__tests__/deviceGrantExports.test.ts | 12 + .../executeEmbeddedDeviceGrantFlow.test.ts | 342 ++++++++++++++++++ .../auth/executeEmbeddedDeviceGrantFlow.ts | 241 ++++++++++++ packages/javascript/src/i18n/models/i18n.ts | 12 + .../javascript/src/i18n/translations/en-US.ts | 12 + .../javascript/src/i18n/translations/fr-FR.ts | 11 + .../javascript/src/i18n/translations/hi-IN.ts | 11 + .../javascript/src/i18n/translations/ja-JP.ts | 11 + .../javascript/src/i18n/translations/pt-BR.ts | 11 + .../javascript/src/i18n/translations/pt-PT.ts | 11 + .../javascript/src/i18n/translations/si-LK.ts | 11 + .../javascript/src/i18n/translations/ta-IN.ts | 11 + .../javascript/src/i18n/translations/te-IN.ts | 11 + .../__tests__/getDefaultI18nBundles.test.ts | 4 + packages/javascript/src/index.ts | 8 + .../javascript/src/models/embedded-flow.ts | 1 + .../auth/DeviceGrant/DeviceGrant.test.ts | 17 + .../auth/DeviceGrant/DeviceGrant.tsx | 23 ++ packages/nextjs/src/client/index.ts | 3 + .../src/__tests__/deviceGrantExports.test.ts | 12 + packages/nuxt/src/module.ts | 1 + .../runtime/components/auth/DeviceGrant.ts | 12 + packages/nuxt/tests/unit/device-grant.test.ts | 12 + packages/nuxt/tests/unit/module.test.ts | 13 + .../DeviceGrant/BaseDeviceGrant.styles.ts | 28 ++ .../auth/DeviceGrant/BaseDeviceGrant.tsx | 92 +++++ .../auth/DeviceGrant/DeviceGrant.tsx | 268 ++++++++++++++ .../__tests__/BaseDeviceGrant.test.tsx | 82 +++++ .../__tests__/DeviceGrant.test.tsx | 271 ++++++++++++++ .../src/components/auth/SignIn/BaseSignIn.tsx | 28 +- .../auth/SignIn/__tests__/BaseSignIn.test.tsx | 50 ++- .../auth/flow/AuthOptionFactory.tsx | 40 +- .../flow/__tests__/AuthOptionFactory.test.tsx | 46 +++ .../Typography/Typography.styles.ts | 6 + packages/react/src/index.ts | 6 + .../__tests__/components/auth/consent.test.ts | 45 +++ .../components/auth/device-grant.test.ts | 296 +++++++++++++++ .../__tests__/components/auth/sign-in.test.ts | 27 ++ .../auth/device-grant/BaseDeviceGrant.css.ts | 32 ++ .../auth/device-grant/BaseDeviceGrant.ts | 122 +++++++ .../auth/device-grant/DeviceGrant.ts | 205 +++++++++++ .../auth/flow/AuthOptionFactoryCore.ts | 39 +- .../src/components/auth/flow/Consent.css.ts | 139 +++++++ .../vue/src/components/auth/flow/Consent.ts | 214 +++++++++++ .../src/components/auth/sign-in/BaseSignIn.ts | 34 +- .../vue/src/components/auth/sign-in/SignIn.ts | 3 +- .../primitives/Typography/Typography.ts | 36 +- packages/vue/src/index.ts | 10 + packages/vue/src/styles/injectStyles.ts | 4 + playgrounds/nextjs/src/explorer/stories.tsx | 38 ++ playgrounds/nuxt/lib/stories.ts | 38 ++ .../react/src/pages/explorer/stories.tsx | 38 ++ playgrounds/vue/src/pages/explorer/stories.ts | 39 ++ 54 files changed, 3080 insertions(+), 21 deletions(-) create mode 100644 packages/browser/src/__tests__/deviceGrantExports.test.ts create mode 100644 packages/express/src/__tests__/deviceGrantExports.test.ts create mode 100644 packages/javascript/src/api/auth/__tests__/executeEmbeddedDeviceGrantFlow.test.ts create mode 100644 packages/javascript/src/api/auth/executeEmbeddedDeviceGrantFlow.ts create mode 100644 packages/nextjs/src/client/components/auth/DeviceGrant/DeviceGrant.test.ts create mode 100644 packages/nextjs/src/client/components/auth/DeviceGrant/DeviceGrant.tsx create mode 100644 packages/node/src/__tests__/deviceGrantExports.test.ts create mode 100644 packages/nuxt/src/runtime/components/auth/DeviceGrant.ts create mode 100644 packages/nuxt/tests/unit/device-grant.test.ts create mode 100644 packages/react/src/components/auth/DeviceGrant/BaseDeviceGrant.styles.ts create mode 100644 packages/react/src/components/auth/DeviceGrant/BaseDeviceGrant.tsx create mode 100644 packages/react/src/components/auth/DeviceGrant/DeviceGrant.tsx create mode 100644 packages/react/src/components/auth/DeviceGrant/__tests__/BaseDeviceGrant.test.tsx create mode 100644 packages/react/src/components/auth/DeviceGrant/__tests__/DeviceGrant.test.tsx create mode 100644 packages/vue/src/__tests__/components/auth/consent.test.ts create mode 100644 packages/vue/src/__tests__/components/auth/device-grant.test.ts create mode 100644 packages/vue/src/components/auth/device-grant/BaseDeviceGrant.css.ts create mode 100644 packages/vue/src/components/auth/device-grant/BaseDeviceGrant.ts create mode 100644 packages/vue/src/components/auth/device-grant/DeviceGrant.ts create mode 100644 packages/vue/src/components/auth/flow/Consent.css.ts create mode 100644 packages/vue/src/components/auth/flow/Consent.ts diff --git a/packages/browser/src/__tests__/deviceGrantExports.test.ts b/packages/browser/src/__tests__/deviceGrantExports.test.ts new file mode 100644 index 00000000..5bb6f9b9 --- /dev/null +++ b/packages/browser/src/__tests__/deviceGrantExports.test.ts @@ -0,0 +1,12 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {executeEmbeddedDeviceGrantFlow as executeFromJavaScript} from '@thunderid/javascript'; +import {describe, expect, it} from 'vitest'; +import {executeEmbeddedDeviceGrantFlow} from '../index'; + +describe('Device Grant exports', () => { + it('exposes the framework-agnostic executor without reimplementing it', () => { + expect(executeEmbeddedDeviceGrantFlow).toBe(executeFromJavaScript); + }); +}); diff --git a/packages/express/src/__tests__/deviceGrantExports.test.ts b/packages/express/src/__tests__/deviceGrantExports.test.ts new file mode 100644 index 00000000..f698edbe --- /dev/null +++ b/packages/express/src/__tests__/deviceGrantExports.test.ts @@ -0,0 +1,12 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {executeEmbeddedDeviceGrantFlow as executeFromNode} from '@thunderid/node'; +import {describe, expect, it} from 'vitest'; +import {executeEmbeddedDeviceGrantFlow} from '../index'; + +describe('Device Grant exports', () => { + it('exposes the Node SDK executor without reimplementing it', () => { + expect(executeEmbeddedDeviceGrantFlow).toBe(executeFromNode); + }); +}); diff --git a/packages/javascript/src/api/auth/__tests__/executeEmbeddedDeviceGrantFlow.test.ts b/packages/javascript/src/api/auth/__tests__/executeEmbeddedDeviceGrantFlow.test.ts new file mode 100644 index 00000000..81fb69d4 --- /dev/null +++ b/packages/javascript/src/api/auth/__tests__/executeEmbeddedDeviceGrantFlow.test.ts @@ -0,0 +1,342 @@ +// Copyright 2025-2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {afterEach, describe, expect, it, vi} from 'vitest'; +import {EmbeddedFlowType} from '../../../models/embedded-flow'; +import executeEmbeddedDeviceGrantFlow, {type EmbeddedDeviceGrantFlowPayload} from '../executeEmbeddedDeviceGrantFlow'; + +const URL = 'https://localhost:8090/flow/execute'; + +describe('executeEmbeddedDeviceGrantFlow', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('starts the Device Grant flow with the DEVICE_GRANT type and SDK-owned verbose metadata', async () => { + const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response(JSON.stringify({executionId: 'execution-1', flowStatus: 'INCOMPLETE', data: {}}), { + status: 200, + }), + ); + + await executeEmbeddedDeviceGrantFlow({ + url: URL, + payload: { + applicationId: 'app-1', + flowType: EmbeddedFlowType.DeviceGrant, + inputs: {user_code: 'BCDF-GHJK'}, + }, + }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith( + URL, + expect.objectContaining({ + credentials: 'include', + method: 'POST', + body: JSON.stringify({ + applicationId: 'app-1', + flowType: EmbeddedFlowType.DeviceGrant, + inputs: {user_code: 'BCDF-GHJK'}, + verbose: true, + }), + }), + ); + }); + + it('starts an application Device Grant flow before the user code is entered', async () => { + const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response(JSON.stringify({executionId: 'execution-1', flowStatus: 'INCOMPLETE', data: {}}), { + status: 200, + }), + ); + + await executeEmbeddedDeviceGrantFlow({ + url: URL, + payload: { + applicationId: 'app-1', + flowType: EmbeddedFlowType.DeviceGrant, + inputs: {}, + }, + }); + + expect(fetchMock).toHaveBeenCalledWith( + URL, + expect.objectContaining({ + body: JSON.stringify({ + applicationId: 'app-1', + flowType: EmbeddedFlowType.DeviceGrant, + inputs: {}, + verbose: true, + }), + }), + ); + }); + + it('strips caller-provided verbose metadata from step submissions', async () => { + const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response(JSON.stringify({executionId: 'execution-1', flowStatus: 'INCOMPLETE', data: {}}), { + status: 200, + }), + ); + + const payload = { + action: 'continue', + executionId: 'execution-1', + inputs: {user_code: 'BCDF-GHJK'}, + verbose: true, + } as EmbeddedDeviceGrantFlowPayload & {verbose: boolean}; + + await executeEmbeddedDeviceGrantFlow({ + url: URL, + payload, + }); + + expect(fetchMock).toHaveBeenCalledWith( + URL, + expect.objectContaining({ + body: JSON.stringify({ + action: 'continue', + executionId: 'execution-1', + inputs: {user_code: 'BCDF-GHJK'}, + }), + }), + ); + }); + + it('relays a terminal assertion with the server-provided authId to the existing OAuth callback', async () => { + const fetchMock = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + executionId: 'execution-1', + flowStatus: 'COMPLETE', + assertion: 'signed-assertion', + data: {additionalData: {authId: 'request-1', callbackType: 'urn:ietf:params:oauth:grant-type:device_code'}}, + }), + {status: 200}, + ), + ) + .mockResolvedValueOnce(new Response(JSON.stringify({}), {status: 200})); + + await executeEmbeddedDeviceGrantFlow({ + url: URL, + payload: {executionId: 'execution-1', action: 'continue'}, + }); + + expect(fetchMock).toHaveBeenNthCalledWith( + 2, + 'https://localhost:8090/oauth2/auth/callback', + expect.objectContaining({ + credentials: 'include', + body: JSON.stringify({ + assertion: 'signed-assertion', + authId: 'request-1', + type: 'urn:ietf:params:oauth:grant-type:device_code', + }), + }), + ); + }); + + it('preserves a base URL path when relaying a terminal assertion', async () => { + const baseUrl = 'https://localhost:8090/reverse-proxy'; + const fetchMock = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + executionId: 'execution-1', + flowStatus: 'COMPLETE', + assertion: 'signed-assertion', + data: {additionalData: {authId: 'request-1', callbackType: 'device_code'}}, + }), + {status: 200}, + ), + ) + .mockResolvedValueOnce(new Response(JSON.stringify({}), {status: 200})); + + await executeEmbeddedDeviceGrantFlow({ + baseUrl, + payload: {executionId: 'execution-1', action: 'continue'}, + }); + + expect(fetchMock).toHaveBeenNthCalledWith(1, `${baseUrl}/flow/execute`, expect.anything()); + expect(fetchMock).toHaveBeenNthCalledWith( + 2, + `${baseUrl}/oauth2/auth/callback`, + expect.objectContaining({ + body: JSON.stringify({assertion: 'signed-assertion', authId: 'request-1', type: 'device_code'}), + }), + ); + }); + + it('relays a terminal denial assertion and returns the flow response when accepted', async () => { + vi.spyOn(globalThis, 'fetch') + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + executionId: 'execution-1', + flowStatus: 'ERROR', + errorAssertion: 'signed-error-assertion', + data: {additionalData: {authId: 'request-1', callbackType: 'device_code'}}, + }), + {status: 200}, + ), + ) + .mockResolvedValueOnce(new Response(JSON.stringify({status: 'OK'}), {status: 200})); + + await expect( + executeEmbeddedDeviceGrantFlow({ + url: URL, + payload: {executionId: 'execution-1', action: 'continue'}, + }), + ).resolves.toMatchObject({flowStatus: 'ERROR', errorAssertion: 'signed-error-assertion'}); + }); + + it('surfaces callback rejection for a terminal error assertion', async () => { + vi.spyOn(globalThis, 'fetch') + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + executionId: 'execution-1', + flowStatus: 'ERROR', + errorAssertion: 'signed-error-assertion', + data: {additionalData: {authId: 'request-1', callbackType: 'device_code'}}, + }), + {status: 200}, + ), + ) + .mockResolvedValueOnce(new Response(JSON.stringify({error: 'invalid_request'}), {status: 400})); + + await expect( + executeEmbeddedDeviceGrantFlow({ + url: URL, + payload: {executionId: 'execution-1', action: 'continue'}, + }), + ).rejects.toMatchObject({code: 'executeEmbeddedDeviceGrantFlow-CallbackError-001', statusCode: 400}); + }); + + it('throws when a completed flow assertion is rejected by the callback', async () => { + vi.spyOn(globalThis, 'fetch') + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + executionId: 'execution-1', + flowStatus: 'COMPLETE', + assertion: 'signed-assertion', + data: {additionalData: {authId: 'request-1', callbackType: 'device_code'}}, + }), + {status: 200}, + ), + ) + .mockResolvedValueOnce(new Response(JSON.stringify({error: 'access_denied'}), {status: 400})); + + await expect( + executeEmbeddedDeviceGrantFlow({ + url: URL, + payload: {executionId: 'execution-1', action: 'continue'}, + }), + ).rejects.toMatchObject({code: 'executeEmbeddedDeviceGrantFlow-CallbackError-001', statusCode: 400}); + }); + + it('rejects terminal flow responses without callback data', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response(JSON.stringify({executionId: 'execution-1', flowStatus: 'COMPLETE', assertion: 'assertion'}), { + status: 200, + }), + ); + + await expect( + executeEmbeddedDeviceGrantFlow({ + url: URL, + payload: {executionId: 'execution-1', action: 'continue'}, + }), + ).rejects.toMatchObject({code: 'executeEmbeddedDeviceGrantFlow-CallbackError-002'}); + }); + + it('rejects missing flow payloads', async () => { + await expect(executeEmbeddedDeviceGrantFlow({url: URL})).rejects.toMatchObject({ + code: 'executeEmbeddedDeviceGrantFlow-ValidationError-001', + }); + }); + + it('rejects a missing server URL before making a request', async () => { + const fetchMock = vi.spyOn(globalThis, 'fetch'); + + await expect( + executeEmbeddedDeviceGrantFlow({ + payload: { + applicationId: 'app-1', + flowType: EmbeddedFlowType.DeviceGrant, + inputs: {user_code: 'BCDF-GHJK'}, + }, + }), + ).rejects.toMatchObject({code: 'executeEmbeddedDeviceGrantFlow-ValidationError-002'}); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('rejects payloads that are neither Device Grant starts nor continuations', async () => { + const fetchMock = vi.spyOn(globalThis, 'fetch'); + + await expect( + executeEmbeddedDeviceGrantFlow({ + url: URL, + payload: {flowType: EmbeddedFlowType.Authentication} as never, + }), + ).rejects.toMatchObject({code: 'executeEmbeddedDeviceGrantFlow-ValidationError-003'}); + + await expect( + executeEmbeddedDeviceGrantFlow({ + url: URL, + payload: { + flowType: 'SYSTEM', + inputs: {user_code: 'BCDF-GHJK'}, + systemFlow: 'DEVICE_GRANT', + } as never, + }), + ).rejects.toMatchObject({code: 'executeEmbeddedDeviceGrantFlow-ValidationError-003'}); + + await expect( + executeEmbeddedDeviceGrantFlow({ + url: URL, + payload: { + flowType: EmbeddedFlowType.DeviceGrant, + inputs: {user_code: 'BCDF-GHJK'}, + } as never, + }), + ).rejects.toMatchObject({code: 'executeEmbeddedDeviceGrantFlow-ValidationError-003'}); + + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('surfaces flow execution HTTP failures', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('flow unavailable', {status: 503})); + + await expect( + executeEmbeddedDeviceGrantFlow({url: URL, payload: {executionId: 'execution-1'}}), + ).rejects.toMatchObject({code: 'executeEmbeddedDeviceGrantFlow-FlowError-001', statusCode: 503}); + }); + + it('returns the callback redirect URI on a completed flow', async () => { + vi.spyOn(globalThis, 'fetch') + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + executionId: 'execution-1', + flowStatus: 'COMPLETE', + assertion: 'signed-assertion', + data: {additionalData: {authId: 'request-1', callbackType: 'device_code'}}, + }), + {status: 200}, + ), + ) + .mockResolvedValueOnce( + new Response(JSON.stringify({redirect_uri: 'https://client.example.com/device-complete'}), {status: 200}), + ); + + await expect( + executeEmbeddedDeviceGrantFlow({url: URL, payload: {executionId: 'execution-1'}}), + ).resolves.toMatchObject({redirectUrl: 'https://client.example.com/device-complete'}); + }); +}); diff --git a/packages/javascript/src/api/auth/executeEmbeddedDeviceGrantFlow.ts b/packages/javascript/src/api/auth/executeEmbeddedDeviceGrantFlow.ts new file mode 100644 index 00000000..90b42152 --- /dev/null +++ b/packages/javascript/src/api/auth/executeEmbeddedDeviceGrantFlow.ts @@ -0,0 +1,241 @@ +// Copyright 2025-2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import ThunderIDAPIError from '../../errors/ThunderIDAPIError'; +import {EmbeddedFlowExecuteRequestConfig, EmbeddedFlowType} from '../../models/embedded-flow'; +import {EmbeddedSignInFlowResponse, EmbeddedSignInFlowStatus} from '../../models/embedded-signin-flow'; + +export interface EmbeddedDeviceGrantFlowStartPayload { + applicationId: string; + flowType: EmbeddedFlowType.DeviceGrant; + inputs: {user_code?: string}; +} + +export interface EmbeddedDeviceGrantFlowContinuationPayload { + action?: string; + challengeToken?: string; + executionId: string; + inputs?: Record; +} + +export type EmbeddedDeviceGrantFlowPayload = + | EmbeddedDeviceGrantFlowStartPayload + | EmbeddedDeviceGrantFlowContinuationPayload; + +/** Device Grant flow response returned by the Flow Execution API. */ +export type EmbeddedDeviceGrantFlowResponse = EmbeddedSignInFlowResponse; + +export interface EmbeddedDeviceGrantFlowRequestConfig + extends Omit, 'authId' | 'flowSecret'> { + payload?: EmbeddedDeviceGrantFlowPayload; +} + +interface DeviceGrantCallbackData { + assertion: string; + authId: string; + type: string; +} + +const resolveCallbackUrl = (endpoint: string, baseUrl?: string): string => { + if (baseUrl) { + return `${baseUrl.replace(/\/$/, '')}/oauth2/auth/callback`; + } + + const flowEndpoint: URL = new URL(endpoint); + const basePath: string = flowEndpoint.pathname.replace(/\/flow\/execute\/?$/, ''); + return new URL(`${basePath}/oauth2/auth/callback`, flowEndpoint.origin).toString(); +}; + +const postAuthCallback = async ( + callbackUrl: string, + body: DeviceGrantCallbackData, + headers: HeadersInit | undefined, + allowAccessDenied: boolean, +): Promise> => { + const callbackResponse: Response = await fetch(callbackUrl, { + body: JSON.stringify(body), + credentials: 'include', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + ...headers, + } as HeadersInit, + method: 'POST', + }); + + if (!callbackResponse.ok) { + const errorText: string = await callbackResponse.text(); + let errorPayload: unknown; + try { + errorPayload = JSON.parse(errorText); + } catch { + errorPayload = undefined; + } + const isAccessDenied: boolean = + typeof errorPayload === 'object' && + errorPayload !== null && + 'error' in errorPayload && + errorPayload.error === 'access_denied'; + + // A denied terminal flow is expected to receive access_denied from the callback. + // Return its flow response so the UI can show the configured failure prompt. + if (allowAccessDenied && callbackResponse.status === 400 && isAccessDenied) return {}; + + throw new ThunderIDAPIError( + errorText, + 'executeEmbeddedDeviceGrantFlow-CallbackError-001', + 'javascript', + callbackResponse.status, + callbackResponse.statusText, + ); + } + + return (await callbackResponse.json()) as Record; +}; + +/** + * Executes or continues the application's configured Device Grant flow. + * + * The request starts at the existing flow execution endpoint. When the terminal flow response + * carries the server-issued authorization request ID and assertion, this function relays them + * to the existing OAuth callback endpoint. A start request requires its application ID; an optional + * user code is submitted as flow input. + */ +const executeEmbeddedDeviceGrantFlow = async ({ + baseUrl, + payload, + url, + ...requestConfig +}: EmbeddedDeviceGrantFlowRequestConfig): Promise => { + if (!payload) { + throw new ThunderIDAPIError( + 'A flow payload is required', + 'executeEmbeddedDeviceGrantFlow-ValidationError-001', + 'javascript', + 400, + 'The Device Grant flow cannot be executed without a request payload.', + ); + } + + if (!url && !baseUrl) { + throw new ThunderIDAPIError( + 'A server URL is required', + 'executeEmbeddedDeviceGrantFlow-ValidationError-002', + 'javascript', + 400, + 'The Device Grant flow endpoint cannot be resolved without a server URL.', + ); + } + + const isContinuation: boolean = + typeof payload === 'object' && + payload !== null && + 'executionId' in payload && + typeof payload.executionId === 'string' && + payload.executionId.length > 0; + const isNewDeviceGrantFlow: boolean = + typeof payload === 'object' && + payload !== null && + 'flowType' in payload && + payload.flowType === EmbeddedFlowType.DeviceGrant && + 'inputs' in payload && + typeof payload.applicationId === 'string' && + payload.applicationId.trim().length > 0; + + if (!isContinuation && !isNewDeviceGrantFlow) { + throw new ThunderIDAPIError( + 'The Device Grant flow payload is invalid', + 'executeEmbeddedDeviceGrantFlow-ValidationError-003', + 'javascript', + 400, + 'Start requests require the DEVICE_GRANT flow type and a non-empty application ID; continuation requests require an execution ID.', + ); + } + + const endpoint: string = url ?? `${baseUrl}/flow/execute`; + const callbackUrl: string = resolveCallbackUrl(endpoint, baseUrl); + + // Strip any caller-provided `verbose` value. The SDK controls when component + // metadata is requested, matching the other embedded-flow executors. + const cleanPayload: EmbeddedDeviceGrantFlowPayload = + typeof payload === 'object' && payload !== null + ? (Object.fromEntries( + Object.entries(payload).filter(([key]: [string, unknown]) => key !== 'verbose'), + ) as EmbeddedDeviceGrantFlowPayload) + : payload; + + // Bare execution-ID requests resume a flow and also need verbose component metadata. + const hasOnlyFlowId: boolean = + typeof cleanPayload === 'object' && + cleanPayload !== null && + 'executionId' in cleanPayload && + Object.keys(cleanPayload).length === 1; + + const requestPayload: Record = + isNewDeviceGrantFlow || hasOnlyFlowId + ? {...cleanPayload, verbose: true} + : (cleanPayload as unknown as Record); + + const response: Response = await fetch(endpoint, { + // Device Grant verification may use the existing browser SSO session. + // Callers can still override this through requestConfig. + credentials: 'include', + ...requestConfig, + body: JSON.stringify(requestPayload), + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + ...requestConfig.headers, + } as HeadersInit, + method: requestConfig.method ?? 'POST', + }); + + if (!response.ok) { + const errorText: string = await response.text(); + + throw new ThunderIDAPIError( + errorText, + 'executeEmbeddedDeviceGrantFlow-FlowError-001', + 'javascript', + response.status, + response.statusText, + ); + } + + const flowResponse = (await response.json()) as EmbeddedDeviceGrantFlowResponse; + const additionalData: Record | undefined = flowResponse.data?.additionalData; + const authId: unknown = additionalData?.['authId']; + const callbackType: unknown = additionalData?.['callbackType']; + const assertion: string | undefined = + flowResponse.flowStatus === EmbeddedSignInFlowStatus.Complete + ? flowResponse.assertion + : flowResponse.errorAssertion; + const isTerminal: boolean = + flowResponse.flowStatus === EmbeddedSignInFlowStatus.Complete || + flowResponse.flowStatus === EmbeddedSignInFlowStatus.Error; + + if (isTerminal) { + if (typeof authId !== 'string' || typeof callbackType !== 'string' || typeof assertion !== 'string') { + throw new ThunderIDAPIError( + 'The completed Device Grant flow did not include its callback data.', + 'executeEmbeddedDeviceGrantFlow-CallbackError-002', + 'javascript', + ); + } + + const callbackResponse: Record = await postAuthCallback( + callbackUrl, + {assertion, authId, type: callbackType}, + requestConfig.headers, + flowResponse.flowStatus === EmbeddedSignInFlowStatus.Error, + ); + const redirectUri: unknown = callbackResponse['redirect_uri']; + if (typeof redirectUri === 'string') { + flowResponse.redirectUrl = redirectUri; + } + } + + return flowResponse; +}; + +export default executeEmbeddedDeviceGrantFlow; diff --git a/packages/javascript/src/i18n/models/i18n.ts b/packages/javascript/src/i18n/models/i18n.ts index 04e26f90..2f688a76 100644 --- a/packages/javascript/src/i18n/models/i18n.ts +++ b/packages/javascript/src/i18n/models/i18n.ts @@ -69,6 +69,14 @@ export interface I18nTranslations { /* | Widgets | */ /* |---------------------------------------------------------------| */ + /* Device Grant */ + 'deviceGrant.errors.start': string; + 'deviceGrant.errors.continue': string; + 'deviceGrant.messages.complete': string; + 'deviceGrant.messages.completeDescription': string; + 'deviceGrant.messages.failed': string; + 'deviceGrant.messages.failedDescription': string; + /* Base Sign In */ 'signin.heading': string; 'signin.subheading': string; @@ -182,6 +190,8 @@ export interface I18nTranslations { 'consent.essential_claims': string; 'consent.optional_claims': string; 'consent.authorize_scope': string; + 'consent.essential_scope': string; + 'consent.optional_scope': string; 'consent.essential_claims.info': string; 'consent.optional_claims.info': string; 'consent.authorize_scope.info': string; @@ -199,6 +209,8 @@ export interface I18nTranslations { 'consent.authorization_details.add_item': string; 'consent.authorization_details.add_back_item': string; 'consent.authorization_details.new_item': string; + 'consent.essential_scope.info': string; + 'consent.optional_scope.info': string; /* |---------------------------------------------------------------| */ /* | Errors | */ diff --git a/packages/javascript/src/i18n/translations/en-US.ts b/packages/javascript/src/i18n/translations/en-US.ts index 12c38e24..830dfabd 100644 --- a/packages/javascript/src/i18n/translations/en-US.ts +++ b/packages/javascript/src/i18n/translations/en-US.ts @@ -69,6 +69,14 @@ const translations: I18nTranslations = { /* | Widgets | */ /* |---------------------------------------------------------------| */ + /* Device Grant */ + 'deviceGrant.errors.start': 'Could not start device verification. Please reload this page.', + 'deviceGrant.errors.continue': 'Could not continue device authorization. Please try again.', + 'deviceGrant.messages.complete': 'Device authorized', + 'deviceGrant.messages.completeDescription': 'You can return to your device.', + 'deviceGrant.messages.failed': 'Device authorization failed', + 'deviceGrant.messages.failedDescription': 'Return to your device and start again.', + /* Base Sign In */ 'signin.heading': 'Sign In', 'signin.subheading': 'Welcome back! Please sign in to continue.', @@ -179,6 +187,8 @@ const translations: I18nTranslations = { 'consent.essential_claims': 'Essential Attributes', 'consent.optional_claims': 'Optional Attributes', 'consent.authorize_scope': 'Permissions', + 'consent.essential_scope': 'Essential Permissions', + 'consent.optional_scope': 'Optional Permissions', 'consent.essential_claims.info': 'These attributes are required for the service to function properly.', 'consent.optional_claims.info': 'These attributes are optional and can be provided at your discretion.', 'consent.authorize_scope.info': 'These permissions allow the service to access additional features.', @@ -196,6 +206,8 @@ const translations: I18nTranslations = { 'consent.authorization_details.add_item': '{item}, add', 'consent.authorization_details.add_back_item': '{item}, add back', 'consent.authorization_details.new_item': '{label}, add', + 'consent.essential_scope.info': 'These permissions are required for the service to function properly.', + 'consent.optional_scope.info': 'These permissions are optional and can be granted at your discretion.', /* |---------------------------------------------------------------| */ /* | Errors | */ diff --git a/packages/javascript/src/i18n/translations/fr-FR.ts b/packages/javascript/src/i18n/translations/fr-FR.ts index 2078a882..c811c090 100644 --- a/packages/javascript/src/i18n/translations/fr-FR.ts +++ b/packages/javascript/src/i18n/translations/fr-FR.ts @@ -70,6 +70,13 @@ const translations: I18nTranslations = { /* |---------------------------------------------------------------| */ /* Base Sign In */ + 'deviceGrant.errors.start': 'Impossible de démarrer la vérification de l’appareil. Rechargez cette page.', + 'deviceGrant.errors.continue': 'Impossible de poursuivre l’autorisation de l’appareil. Réessayez.', + 'deviceGrant.messages.complete': 'Appareil autorisé', + 'deviceGrant.messages.completeDescription': 'Vous pouvez revenir à votre appareil.', + 'deviceGrant.messages.failed': 'Échec de l’autorisation de l’appareil', + 'deviceGrant.messages.failedDescription': 'Revenez à votre appareil et recommencez.', + 'signin.heading': 'Se connecter', 'signin.subheading': 'Entrez vos identifiants pour continuer.', 'signin.images.app_logo.alt': "Logo de l'application", @@ -182,10 +189,14 @@ const translations: I18nTranslations = { 'consent.essential_claims': 'Attributs essentiels', 'consent.optional_claims': 'Attributs facultatifs', 'consent.authorize_scope': 'Autorisations', + 'consent.essential_scope': 'Autorisations essentielles', + 'consent.optional_scope': 'Autorisations facultatives', 'consent.essential_claims.info': 'Ces attributs sont nécessaires au bon fonctionnement du service.', 'consent.optional_claims.info': 'Ces attributs sont facultatifs et peuvent être fournis à votre discrétion.', 'consent.authorize_scope.info': "Ces autorisations permettent au service d'accéder à des fonctionnalités supplémentaires.", + 'consent.essential_scope.info': 'Ces autorisations sont nécessaires au bon fonctionnement du service.', + 'consent.optional_scope.info': 'Ces autorisations sont facultatives et peuvent être accordées à votre discrétion.', /* |---------------------------------------------------------------| */ /* | Errors | */ diff --git a/packages/javascript/src/i18n/translations/hi-IN.ts b/packages/javascript/src/i18n/translations/hi-IN.ts index 11d23a29..1dfe8124 100644 --- a/packages/javascript/src/i18n/translations/hi-IN.ts +++ b/packages/javascript/src/i18n/translations/hi-IN.ts @@ -70,6 +70,13 @@ const translations: I18nTranslations = { /* |---------------------------------------------------------------| */ /* Base Sign In */ + 'deviceGrant.errors.start': 'डिवाइस सत्यापन शुरू नहीं हो सका। कृपया यह पेज फिर से लोड करें।', + 'deviceGrant.errors.continue': 'डिवाइस प्राधिकरण जारी नहीं रह सका। कृपया फिर से प्रयास करें।', + 'deviceGrant.messages.complete': 'डिवाइस अधिकृत है', + 'deviceGrant.messages.completeDescription': 'आप अपने डिवाइस पर लौट सकते हैं।', + 'deviceGrant.messages.failed': 'डिवाइस प्राधिकरण विफल हुआ', + 'deviceGrant.messages.failedDescription': 'अपने डिवाइस पर लौटें और फिर से शुरू करें।', + 'signin.heading': 'साइन इन', 'signin.subheading': 'जारी रखने के लिए अपनी प्रमाणिक जानकारी दर्ज करें।', 'signin.images.app_logo.alt': 'एप्लिकेशन लोगो', @@ -179,6 +186,8 @@ const translations: I18nTranslations = { 'consent.essential_claims': 'आवश्यक विशेषताएँ', 'consent.optional_claims': 'वैकल्पिक विशेषताएँ', 'consent.authorize_scope': 'अनुमतियाँ', + 'consent.essential_scope': 'आवश्यक अनुमतियाँ', + 'consent.optional_scope': 'वैकल्पिक अनुमतियाँ', 'consent.essential_claims.info': 'सेवा के सही ढंग से कार्य करने के लिए ये विशेषताएँ आवश्यक हैं।', 'consent.optional_claims.info': 'ये विशेषताएँ वैकल्पिक हैं और आपके विवेकानुसार प्रदान की जा सकती हैं।', 'consent.authorize_scope.info': 'ये अनुमतियाँ सेवा को अतिरिक्त सुविधाओं तक पहुँचने की अनुमति देती हैं।', @@ -196,6 +205,8 @@ const translations: I18nTranslations = { 'consent.authorization_details.add_item': '{item}, जोड़ें', 'consent.authorization_details.add_back_item': '{item}, फिर से जोड़ें', 'consent.authorization_details.new_item': '{label}, जोड़ें', + 'consent.essential_scope.info': 'सेवा के सही ढंग से कार्य करने के लिए ये अनुमतियाँ आवश्यक हैं।', + 'consent.optional_scope.info': 'ये अनुमतियाँ वैकल्पिक हैं और आपके विवेकानुसार दी जा सकती हैं।', /* |---------------------------------------------------------------| */ /* | Errors | */ diff --git a/packages/javascript/src/i18n/translations/ja-JP.ts b/packages/javascript/src/i18n/translations/ja-JP.ts index 75b922b6..a35890c0 100644 --- a/packages/javascript/src/i18n/translations/ja-JP.ts +++ b/packages/javascript/src/i18n/translations/ja-JP.ts @@ -70,6 +70,13 @@ const translations: I18nTranslations = { /* |---------------------------------------------------------------| */ /* Base Sign In */ + 'deviceGrant.errors.start': 'デバイスの確認を開始できませんでした。このページを再読み込みしてください。', + 'deviceGrant.errors.continue': 'デバイスの認可を続行できませんでした。もう一度お試しください。', + 'deviceGrant.messages.complete': 'デバイスが認可されました', + 'deviceGrant.messages.completeDescription': 'デバイスに戻ることができます。', + 'deviceGrant.messages.failed': 'デバイスの認可に失敗しました', + 'deviceGrant.messages.failedDescription': 'デバイスに戻り、もう一度開始してください。', + 'signin.heading': 'ログイン', 'signin.subheading': '続行するには認証情報を入力してください。', 'signin.images.app_logo.alt': 'アプリケーションロゴ', @@ -179,6 +186,8 @@ const translations: I18nTranslations = { 'consent.essential_claims': '必須属性', 'consent.optional_claims': '任意属性', 'consent.authorize_scope': '権限', + 'consent.essential_scope': '必須権限', + 'consent.optional_scope': '任意権限', 'consent.essential_claims.info': 'これらの属性は、サービスが正常に機能するために必要です。', 'consent.optional_claims.info': 'これらの属性は任意であり、お客様の判断で提供できます。', 'consent.authorize_scope.info': 'これらの権限により、サービスが追加機能にアクセスできるようになります。', @@ -196,6 +205,8 @@ const translations: I18nTranslations = { 'consent.authorization_details.add_item': '{item}、追加', 'consent.authorization_details.add_back_item': '{item}、再追加', 'consent.authorization_details.new_item': '{label}、追加', + 'consent.essential_scope.info': 'これらの権限は、サービスが正常に機能するために必要です。', + 'consent.optional_scope.info': 'これらの権限は任意であり、お客様の判断で付与できます。', /* |---------------------------------------------------------------| */ /* | Errors | */ diff --git a/packages/javascript/src/i18n/translations/pt-BR.ts b/packages/javascript/src/i18n/translations/pt-BR.ts index 44e676c6..6d4ccd0c 100644 --- a/packages/javascript/src/i18n/translations/pt-BR.ts +++ b/packages/javascript/src/i18n/translations/pt-BR.ts @@ -70,6 +70,13 @@ const translations: I18nTranslations = { /* |---------------------------------------------------------------| */ /* Base Sign In */ + 'deviceGrant.errors.start': 'Não foi possível iniciar a verificação do dispositivo. Recarregue esta página.', + 'deviceGrant.errors.continue': 'Não foi possível continuar a autorização do dispositivo. Tente novamente.', + 'deviceGrant.messages.complete': 'Dispositivo autorizado', + 'deviceGrant.messages.completeDescription': 'Você pode voltar ao seu dispositivo.', + 'deviceGrant.messages.failed': 'Falha na autorização do dispositivo', + 'deviceGrant.messages.failedDescription': 'Volte ao seu dispositivo e comece novamente.', + 'signin.heading': 'Entrar', 'signin.subheading': 'Digite suas credencias para continuar.', 'signin.images.app_logo.alt': 'Logotipo do aplicativo', @@ -179,6 +186,8 @@ const translations: I18nTranslations = { 'consent.essential_claims': 'Atributos essenciais', 'consent.optional_claims': 'Atributos opcionais', 'consent.authorize_scope': 'Permissões', + 'consent.essential_scope': 'Permissões essenciais', + 'consent.optional_scope': 'Permissões opcionais', 'consent.essential_claims.info': 'Esses atributos são necessários para o funcionamento adequado do serviço.', 'consent.optional_claims.info': 'Esses atributos são opcionais e podem ser fornecidos a seu critério.', 'consent.authorize_scope.info': 'Essas permissões permitem que o serviço acesse recursos adicionais.', @@ -196,6 +205,8 @@ const translations: I18nTranslations = { 'consent.authorization_details.add_item': '{item}, adicionar', 'consent.authorization_details.add_back_item': '{item}, adicionar novamente', 'consent.authorization_details.new_item': '{label}, adicionar', + 'consent.essential_scope.info': 'Essas permissões são necessárias para o funcionamento adequado do serviço.', + 'consent.optional_scope.info': 'Essas permissões são opcionais e podem ser concedidas a seu critério.', /* |---------------------------------------------------------------| */ /* | Errors | */ diff --git a/packages/javascript/src/i18n/translations/pt-PT.ts b/packages/javascript/src/i18n/translations/pt-PT.ts index 662490d1..35919bcd 100644 --- a/packages/javascript/src/i18n/translations/pt-PT.ts +++ b/packages/javascript/src/i18n/translations/pt-PT.ts @@ -70,6 +70,13 @@ const translations: I18nTranslations = { /* |---------------------------------------------------------------| */ /* Base Sign In */ + 'deviceGrant.errors.start': 'Não foi possível iniciar a verificação do dispositivo. Atualize esta página.', + 'deviceGrant.errors.continue': 'Não foi possível continuar a autorização do dispositivo. Tente novamente.', + 'deviceGrant.messages.complete': 'Dispositivo autorizado', + 'deviceGrant.messages.completeDescription': 'Pode regressar ao seu dispositivo.', + 'deviceGrant.messages.failed': 'Falha na autorização do dispositivo', + 'deviceGrant.messages.failedDescription': 'Regresse ao seu dispositivo e comece novamente.', + 'signin.heading': 'Iniciar Sessão', 'signin.subheading': 'Introduza as suas credenciais para continuar.', 'signin.images.app_logo.alt': 'Logótipo da aplicação', @@ -179,6 +186,8 @@ const translations: I18nTranslations = { 'consent.essential_claims': 'Atributos essenciais', 'consent.optional_claims': 'Atributos opcionais', 'consent.authorize_scope': 'Permissões', + 'consent.essential_scope': 'Permissões essenciais', + 'consent.optional_scope': 'Permissões opcionais', 'consent.essential_claims.info': 'Estes atributos são necessários para o correto funcionamento do serviço.', 'consent.optional_claims.info': 'Estes atributos são opcionais e podem ser fornecidos ao seu critério.', 'consent.authorize_scope.info': 'Estas permissões permitem que o serviço aceda a funcionalidades adicionais.', @@ -196,6 +205,8 @@ const translations: I18nTranslations = { 'consent.authorization_details.add_item': '{item}, adicionar', 'consent.authorization_details.add_back_item': '{item}, adicionar novamente', 'consent.authorization_details.new_item': '{label}, adicionar', + 'consent.essential_scope.info': 'Estas permissões são necessárias para o correto funcionamento do serviço.', + 'consent.optional_scope.info': 'Estas permissões são opcionais e podem ser concedidas ao seu critério.', /* |---------------------------------------------------------------| */ /* | Errors | */ diff --git a/packages/javascript/src/i18n/translations/si-LK.ts b/packages/javascript/src/i18n/translations/si-LK.ts index 6b50d1f8..8c9300d6 100644 --- a/packages/javascript/src/i18n/translations/si-LK.ts +++ b/packages/javascript/src/i18n/translations/si-LK.ts @@ -70,6 +70,13 @@ const translations: I18nTranslations = { /* |---------------------------------------------------------------| */ /* Base Sign In */ + 'deviceGrant.errors.start': 'උපාංගය සත්\u200dයාපනය ආරම්භ කළ නොහැක. මෙම පිටුව නැවත පූරණය කරන්න.', + 'deviceGrant.errors.continue': 'උපාංග අවසරය ඉදිරියට ගෙන යා නොහැක. නැවත උත්සාහ කරන්න.', + 'deviceGrant.messages.complete': 'උපාංගයට අවසර ලබා දී ඇත', + 'deviceGrant.messages.completeDescription': 'ඔබට ඔබගේ උපාංගය වෙත ආපසු යා හැක.', + 'deviceGrant.messages.failed': 'උපාංග අවසරය අසාර්ථක විය', + 'deviceGrant.messages.failedDescription': 'ඔබගේ උපාංගය වෙත ආපසු ගොස් නැවත ආරම්භ කරන්න.', + 'signin.heading': 'ලොග් වෙන්න', 'signin.subheading': 'ඉදිරියට යාමට ඔබේ සත්‍යාපන තොරතුරු ඇතුළත් කරන්න.', 'signin.images.app_logo.alt': 'යෙදුමේ ලාංඡනය', @@ -180,6 +187,8 @@ const translations: I18nTranslations = { 'consent.essential_claims': 'අත්‍යවශ්‍ය ගුණාංග', 'consent.optional_claims': 'විකල්ප ගුණාංග', 'consent.authorize_scope': 'අවසර', + 'consent.essential_scope': 'අත්‍යවශ්‍ය අවසර', + 'consent.optional_scope': 'විකල්ප අවසර', 'consent.essential_claims.info': 'සේවාව නිසි ලෙස ක්‍රියාත්මක වීම සඳහා මෙම ගුණාංග අවශ්‍ය වේ.', 'consent.optional_claims.info': 'මෙම ගුණාංග විකල්ප වන අතර ඔබේ අභිමතය පරිදි සැපයිය හැක.', 'consent.authorize_scope.info': 'මෙම අවසරයන් මගින් සේවාවට අමතර විශේෂාංග වෙත ප්‍රවේශ වීමට ඉඩ සලසයි.', @@ -197,6 +206,8 @@ const translations: I18nTranslations = { 'consent.authorization_details.add_item': '{item}, එක් කරන්න', 'consent.authorization_details.add_back_item': '{item}, නැවත එක් කරන්න', 'consent.authorization_details.new_item': '{label}, එක් කරන්න', + 'consent.essential_scope.info': 'සේවාව නිසි ලෙස ක්‍රියාත්මක වීම සඳහා මෙම අවසර අවශ්‍ය වේ.', + 'consent.optional_scope.info': 'මෙම අවසර විකල්ප වන අතර ඔබේ අභිමතය පරිදි ලබා දිය හැක.', /* |---------------------------------------------------------------| */ /* | Errors | */ diff --git a/packages/javascript/src/i18n/translations/ta-IN.ts b/packages/javascript/src/i18n/translations/ta-IN.ts index 5c06c8e8..47668dd2 100644 --- a/packages/javascript/src/i18n/translations/ta-IN.ts +++ b/packages/javascript/src/i18n/translations/ta-IN.ts @@ -70,6 +70,13 @@ const translations: I18nTranslations = { /* |---------------------------------------------------------------| */ /* Base Sign In */ + 'deviceGrant.errors.start': 'சாதனச் சரிபார்ப்பைத் தொடங்க முடியவில்லை. இந்தப் பக்கத்தை மீண்டும் ஏற்றவும்.', + 'deviceGrant.errors.continue': 'சாதன அங்கீகாரத்தைத் தொடர முடியவில்லை. மீண்டும் முயற்சிக்கவும்.', + 'deviceGrant.messages.complete': 'சாதனத்திற்கு அங்கீகாரம் வழங்கப்பட்டது', + 'deviceGrant.messages.completeDescription': 'உங்கள் சாதனத்திற்குத் திரும்பலாம்.', + 'deviceGrant.messages.failed': 'சாதன அங்கீகாரம் தோல்வியடைந்தது', + 'deviceGrant.messages.failedDescription': 'உங்கள் சாதனத்திற்குத் திரும்பி மீண்டும் தொடங்கவும்.', + 'signin.heading': 'உள்நுழை', 'signin.subheading': 'தொடர உங்கள் சான்றுகளை உள்ளிடவும்.', 'signin.images.app_logo.alt': 'பயன்பாட்டு லோகோ', @@ -181,6 +188,8 @@ const translations: I18nTranslations = { 'consent.essential_claims': 'அத்தியாவசிய பண்புகள்', 'consent.optional_claims': 'விருப்பத் பண்புகள்', 'consent.authorize_scope': 'அனுமதிகள்', + 'consent.essential_scope': 'அத்தியாவசிய அனுமதிகள்', + 'consent.optional_scope': 'விருப்ப அனுமதிகள்', 'consent.essential_claims.info': 'சேவை சரியாக செயல்பட இந்த பண்புகள் தேவை.', 'consent.optional_claims.info': 'இந்த பண்புகள் விருப்பத்தேர்வு மற்றும் உங்கள் விருப்பப்படி வழங்கப்படலாம்.', 'consent.authorize_scope.info': 'இந்த அனுமதிகள் கூடுதல் அம்சங்களை அணுக சேவைக்கு உதவுகின்றன.', @@ -198,6 +207,8 @@ const translations: I18nTranslations = { 'consent.authorization_details.add_item': '{item}, சேர்', 'consent.authorization_details.add_back_item': '{item}, மீண்டும் சேர்', 'consent.authorization_details.new_item': '{label}, சேர்', + 'consent.essential_scope.info': 'சேவை சரியாக செயல்பட இந்த அனுமதிகள் தேவை.', + 'consent.optional_scope.info': 'இந்த அனுமதிகள் விருப்பமானவை; உங்கள் விருப்பப்படி வழங்கலாம்.', /* |---------------------------------------------------------------| */ /* | Errors | */ diff --git a/packages/javascript/src/i18n/translations/te-IN.ts b/packages/javascript/src/i18n/translations/te-IN.ts index 9d5d96c2..be91ba96 100644 --- a/packages/javascript/src/i18n/translations/te-IN.ts +++ b/packages/javascript/src/i18n/translations/te-IN.ts @@ -70,6 +70,13 @@ const translations: I18nTranslations = { /* |---------------------------------------------------------------| */ /* Base Sign In */ + 'deviceGrant.errors.start': 'పరికర ధృవీకరణను ప్రారంభించలేకపోయింది. ఈ పేజీని మళ్లీ లోడ్ చేయండి.', + 'deviceGrant.errors.continue': 'పరికర అధికారాన్ని కొనసాగించలేకపోయింది. మళ్లీ ప్రయత్నించండి.', + 'deviceGrant.messages.complete': 'పరికరానికి అధికారం ఇచ్చారు', + 'deviceGrant.messages.completeDescription': 'మీరు మీ పరికరానికి తిరిగి వెళ్లవచ్చు.', + 'deviceGrant.messages.failed': 'పరికర అధికారం విఫలమైంది', + 'deviceGrant.messages.failedDescription': 'మీ పరికరానికి తిరిగి వెళ్లి మళ్లీ ప్రారంభించండి.', + 'signin.heading': 'సైన్ ఇన్ చేయండి', 'signin.subheading': 'కొనసాగించడానికి మీ వివరాలు ఇవ్వండి.', 'signin.images.app_logo.alt': 'అప్లికేషన్ లోగో', @@ -181,6 +188,8 @@ const translations: I18nTranslations = { 'consent.essential_claims': 'ఆవశ్యక లక్షణాలు', 'consent.optional_claims': 'ఐచ్ఛిక లక్షణాలు', 'consent.authorize_scope': 'అనుమతులు', + 'consent.essential_scope': 'అవసరమైన అనుమతులు', + 'consent.optional_scope': 'ఐచ్ఛిక అనుమతులు', 'consent.essential_claims.info': 'సేవ సరిగ్గా పనిచేయడానికి ఈ లక్షణాలు అవసరం.', 'consent.optional_claims.info': 'ఈ లక్షణాలు ఐచ్ఛికం మరియు మీ విచక్షణ మేరకు అందించవచ్చు.', 'consent.authorize_scope.info': 'ఈ అనుమతులు సేవను అదనపు ఫీచర్‌లను యాక్సెస్ చేయడానికి అనుమతిస్తాయి.', @@ -198,6 +207,8 @@ const translations: I18nTranslations = { 'consent.authorization_details.add_item': '{item}, జోడించు', 'consent.authorization_details.add_back_item': '{item}, మళ్లీ జోడించు', 'consent.authorization_details.new_item': '{label}, జోడించు', + 'consent.essential_scope.info': 'సేవ సరిగ్గా పనిచేయడానికి ఈ అనుమతులు అవసరం.', + 'consent.optional_scope.info': 'ఈ అనుమతులు ఐచ్ఛికం మరియు మీ విచక్షణ మేరకు మంజూరు చేయవచ్చు.', /* |---------------------------------------------------------------| */ /* | Errors | */ diff --git a/packages/javascript/src/i18n/utils/__tests__/getDefaultI18nBundles.test.ts b/packages/javascript/src/i18n/utils/__tests__/getDefaultI18nBundles.test.ts index 17f9e7ca..19c8be74 100644 --- a/packages/javascript/src/i18n/utils/__tests__/getDefaultI18nBundles.test.ts +++ b/packages/javascript/src/i18n/utils/__tests__/getDefaultI18nBundles.test.ts @@ -78,6 +78,10 @@ describe('getDefaultI18nBundles', (): void => { expect(translations).toHaveProperty('signin.heading'); expect(translations).toHaveProperty('signin.subheading'); expect(translations).toHaveProperty('errors.heading'); + expect(translations).toHaveProperty('consent.essential_scope'); + expect(translations).toHaveProperty('consent.optional_scope'); + expect(translations).toHaveProperty('consent.essential_scope.info'); + expect(translations).toHaveProperty('consent.optional_scope.info'); Object.values(translations).forEach((value: string) => { expect(typeof value).toBe('string'); diff --git a/packages/javascript/src/index.ts b/packages/javascript/src/index.ts index 7b11705b..9e1bb0e3 100644 --- a/packages/javascript/src/index.ts +++ b/packages/javascript/src/index.ts @@ -4,6 +4,14 @@ export {IsomorphicCrypto} from './IsomorphicCrypto'; export {default as executeEmbeddedSignInFlow} from './api/auth/executeEmbeddedSignInFlow'; +export {default as executeEmbeddedDeviceGrantFlow} from './api/auth/executeEmbeddedDeviceGrantFlow'; +export type { + EmbeddedDeviceGrantFlowContinuationPayload, + EmbeddedDeviceGrantFlowPayload, + EmbeddedDeviceGrantFlowRequestConfig, + EmbeddedDeviceGrantFlowResponse, + EmbeddedDeviceGrantFlowStartPayload, +} from './api/auth/executeEmbeddedDeviceGrantFlow'; export {default as executeEmbeddedSignUpFlow} from './api/auth/executeEmbeddedSignUpFlow'; export {default as executeEmbeddedRecoveryFlow} from './api/auth/executeEmbeddedRecoveryFlow'; export {default as executeEmbeddedUserOnboardingFlow} from './api/auth/executeEmbeddedUserOnboardingFlow'; diff --git a/packages/javascript/src/models/embedded-flow.ts b/packages/javascript/src/models/embedded-flow.ts index 90ce1fbf..aecfe2ad 100644 --- a/packages/javascript/src/models/embedded-flow.ts +++ b/packages/javascript/src/models/embedded-flow.ts @@ -3,6 +3,7 @@ export enum EmbeddedFlowType { Authentication = 'AUTHENTICATION', + DeviceGrant = 'DEVICE_GRANT', Recovery = 'RECOVERY', Registration = 'REGISTRATION', UserOnboarding = 'USER_ONBOARDING', diff --git a/packages/nextjs/src/client/components/auth/DeviceGrant/DeviceGrant.test.ts b/packages/nextjs/src/client/components/auth/DeviceGrant/DeviceGrant.test.ts new file mode 100644 index 00000000..c74f9b15 --- /dev/null +++ b/packages/nextjs/src/client/components/auth/DeviceGrant/DeviceGrant.test.ts @@ -0,0 +1,17 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {DeviceGrant as ReactDeviceGrant} from '@thunderid/react'; +import type {ReactElement} from 'react'; +import {describe, expect, it} from 'vitest'; +import DeviceGrant, {type DeviceGrantProps} from './DeviceGrant'; + +describe('DeviceGrant', () => { + it('delegates browser verification to the React DeviceGrant component', () => { + const props: DeviceGrantProps = {variant: 'default'}; + const element: ReactElement = DeviceGrant(props) as ReactElement; + + expect(element.type).toBe(ReactDeviceGrant); + expect(element.props).toMatchObject(props); + }); +}); diff --git a/packages/nextjs/src/client/components/auth/DeviceGrant/DeviceGrant.tsx b/packages/nextjs/src/client/components/auth/DeviceGrant/DeviceGrant.tsx new file mode 100644 index 00000000..72e897ba --- /dev/null +++ b/packages/nextjs/src/client/components/auth/DeviceGrant/DeviceGrant.tsx @@ -0,0 +1,23 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +'use client'; + +import {DeviceGrant as ReactDeviceGrant} from '@thunderid/react'; +import type {DeviceGrantProps as ReactDeviceGrantProps} from '@thunderid/react'; +import type {FC, ReactElement} from 'react'; + +/** Props supported by the Next.js Device Grant verification component. */ +export type DeviceGrantProps = ReactDeviceGrantProps; + +/** + * Next.js client adapter for Device Grant verification. + * + * The React component owns the browser flow lifecycle. The Next.js provider's + * existing React context bridge supplies its base URL, metadata and theme. + */ +const DeviceGrant: FC = (props: DeviceGrantProps): ReactElement => ; + +DeviceGrant.displayName = 'DeviceGrant'; + +export default DeviceGrant; diff --git a/packages/nextjs/src/client/index.ts b/packages/nextjs/src/client/index.ts index 777df5b1..cef9bb99 100644 --- a/packages/nextjs/src/client/index.ts +++ b/packages/nextjs/src/client/index.ts @@ -18,6 +18,9 @@ export type {SignUpButtonProps} from './components/auth/buttons/SignUpButton/Sig export {default as SignIn} from './components/auth/SignIn/SignIn'; export type {SignInProps} from './components/auth/SignIn/SignIn'; +export {default as DeviceGrant} from './components/auth/DeviceGrant/DeviceGrant'; +export type {DeviceGrantProps} from './components/auth/DeviceGrant/DeviceGrant'; + export {default as SignOutButton} from './components/auth/buttons/SignOutButton/SignOutButton'; export type {SignOutButtonProps} from './components/auth/buttons/SignOutButton/SignOutButton'; diff --git a/packages/node/src/__tests__/deviceGrantExports.test.ts b/packages/node/src/__tests__/deviceGrantExports.test.ts new file mode 100644 index 00000000..5bb6f9b9 --- /dev/null +++ b/packages/node/src/__tests__/deviceGrantExports.test.ts @@ -0,0 +1,12 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {executeEmbeddedDeviceGrantFlow as executeFromJavaScript} from '@thunderid/javascript'; +import {describe, expect, it} from 'vitest'; +import {executeEmbeddedDeviceGrantFlow} from '../index'; + +describe('Device Grant exports', () => { + it('exposes the framework-agnostic executor without reimplementing it', () => { + expect(executeEmbeddedDeviceGrantFlow).toBe(executeFromJavaScript); + }); +}); diff --git a/packages/nuxt/src/module.ts b/packages/nuxt/src/module.ts index f4c3b0fc..48f51421 100644 --- a/packages/nuxt/src/module.ts +++ b/packages/nuxt/src/module.ts @@ -283,6 +283,7 @@ export default defineNuxtModule({ // ── Embedded auth flows ────────────────────────────────────────────────── addComponent({filePath: resolve('./runtime/components/auth/SignIn'), name: 'SignIn'}); addComponent({filePath: resolve('./runtime/components/auth/SignUp'), name: 'SignUp'}); + addComponent({filePath: resolve('./runtime/components/auth/DeviceGrant'), name: 'DeviceGrant'}); // ── User ───────────────────────────────────────────────────────────────── addComponent({filePath: resolve('./runtime/components/account/User'), name: 'User'}); diff --git a/packages/nuxt/src/runtime/components/auth/DeviceGrant.ts b/packages/nuxt/src/runtime/components/auth/DeviceGrant.ts new file mode 100644 index 00000000..620a6aa6 --- /dev/null +++ b/packages/nuxt/src/runtime/components/auth/DeviceGrant.ts @@ -0,0 +1,12 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {DeviceGrant} from '@thunderid/vue'; + +/** + * Nuxt adapter for the Vue Device Grant verification component. + * + * ThunderIDRoot supplies the Vue SDK contexts; the delegated component owns + * the browser flow lifecycle and does not create a Nuxt authentication session. + */ +export default DeviceGrant; diff --git a/packages/nuxt/tests/unit/device-grant.test.ts b/packages/nuxt/tests/unit/device-grant.test.ts new file mode 100644 index 00000000..6103508a --- /dev/null +++ b/packages/nuxt/tests/unit/device-grant.test.ts @@ -0,0 +1,12 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {DeviceGrant as VueDeviceGrant} from '@thunderid/vue'; +import {describe, expect, it} from 'vitest'; +import DeviceGrant from '../../src/runtime/components/auth/DeviceGrant'; + +describe('DeviceGrant', () => { + it('delegates browser verification to the Vue DeviceGrant component', () => { + expect(DeviceGrant).toBe(VueDeviceGrant); + }); +}); diff --git a/packages/nuxt/tests/unit/module.test.ts b/packages/nuxt/tests/unit/module.test.ts index 073494ff..2598d728 100644 --- a/packages/nuxt/tests/unit/module.test.ts +++ b/packages/nuxt/tests/unit/module.test.ts @@ -25,6 +25,7 @@ vi.mock('@nuxt/kit', () => ({ })); const {default: thunderIDModule} = await import('../../src/module'); +const {addComponent} = await import('@nuxt/kit'); const setupModule = (userOptions: ThunderIDNuxtConfig) => { const nuxt = {options: {runtimeConfig: {public: {}}}} as any; @@ -35,6 +36,7 @@ const setupModule = (userOptions: ThunderIDNuxtConfig) => { const ENV_KEYS = ['NUXT_PUBLIC_THUNDERID_BASE_URL', 'NUXT_PUBLIC_THUNDERID_CLIENT_ID']; beforeEach(() => { + vi.clearAllMocks(); viteConfigExtenders.length = 0; ENV_KEYS.forEach((key) => vi.stubEnv(key, undefined as unknown as string)); }); @@ -44,6 +46,17 @@ afterEach(() => { }); describe('@thunderid/nuxt module', () => { + describe('components', () => { + it('registers DeviceGrant through the Nuxt component surface', () => { + setupModule({}); + + expect(addComponent).toHaveBeenCalledWith({ + filePath: './runtime/components/auth/DeviceGrant', + name: 'DeviceGrant', + }); + }); + }); + describe('scopes', () => { it('uses an array of scopes from nuxt.config as given, without adding the default scopes to it', () => { const runtimeConfig = setupModule({scopes: ['openid', 'profile', 'email']}); diff --git a/packages/react/src/components/auth/DeviceGrant/BaseDeviceGrant.styles.ts b/packages/react/src/components/auth/DeviceGrant/BaseDeviceGrant.styles.ts new file mode 100644 index 00000000..80d931fe --- /dev/null +++ b/packages/react/src/components/auth/DeviceGrant/BaseDeviceGrant.styles.ts @@ -0,0 +1,28 @@ +// Copyright 2025-2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {Theme} from '@thunderid/browser'; +import {useMemo} from 'react'; +import {css} from '../../../styles/emotion'; + +const useStyles = (theme: Theme): Record => + useMemo( + () => ({ + terminalCard: css` + box-sizing: border-box; + width: 100%; + max-width: calc(${theme.vars.spacing.unit} * 60); + margin: 0 auto; + gap: ${theme.vars.spacing.unit}; + `, + terminalDescription: css` + margin: 0; + `, + terminalTitle: css` + margin: 0; + `, + }), + [theme], + ); + +export default useStyles; diff --git a/packages/react/src/components/auth/DeviceGrant/BaseDeviceGrant.tsx b/packages/react/src/components/auth/DeviceGrant/BaseDeviceGrant.tsx new file mode 100644 index 00000000..09318438 --- /dev/null +++ b/packages/react/src/components/auth/DeviceGrant/BaseDeviceGrant.tsx @@ -0,0 +1,92 @@ +// Copyright 2025-2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import type {EmbeddedFlowComponent} from '@thunderid/browser'; +import {FC, ReactElement} from 'react'; +import useStyles from './BaseDeviceGrant.styles'; +import useTheme from '../../../contexts/Theme/useTheme'; +import useTranslation from '../../../hooks/useTranslation'; +import CardPrimitive from '../../primitives/Card/Card'; +import Typography from '../../primitives/Typography/Typography'; +import BaseSignIn, {type BaseSignInProps} from '../SignIn/BaseSignIn'; + +export type DeviceGrantResult = 'failure' | 'success' | null; + +export interface BaseDeviceGrantProps extends Omit { + components: EmbeddedFlowComponent[]; + completeMessage?: string; + completeDescription?: string; + failedMessage?: string; + failedDescription?: string; + onSubmit: NonNullable; + result?: DeviceGrantResult; +} + +/** Renders the server-described Device Grant prompt independently of SDK orchestration. */ +const BaseDeviceGrant: FC = ({ + components, + className = '', + completeMessage = undefined, + completeDescription = undefined, + failedMessage = undefined, + failedDescription = undefined, + error, + isLoading, + preferences, + result = null, + variant = 'default', + ...signInProps +}: BaseDeviceGrantProps): ReactElement => { + const {theme} = useTheme(); + const {t} = useTranslation(preferences?.i18n); + const styles = useStyles(theme); + + if ((error || result) && components.length === 0) { + const isSuccess = result === 'success' && !error; + const resolvedCompleteMessage = completeMessage ?? t('deviceGrant.messages.complete'); + const resolvedCompleteDescription = completeDescription ?? t('deviceGrant.messages.completeDescription'); + const resolvedFailedMessage = failedMessage ?? t('deviceGrant.messages.failed'); + const resolvedFailedDescription = failedDescription ?? t('deviceGrant.messages.failedDescription'); + const title = isSuccess ? resolvedCompleteMessage : resolvedFailedMessage; + const description = isSuccess + ? resolvedCompleteDescription + : !error || error.message === resolvedFailedMessage + ? resolvedFailedDescription + : error.message; + + return ( + + + {title} + + + {description} + + + ); + } + + return ( + component.id).join('|')} + components={components} + error={error} + isLoading={isLoading} + preferences={preferences} + /> + ); +}; + +export default BaseDeviceGrant; diff --git a/packages/react/src/components/auth/DeviceGrant/DeviceGrant.tsx b/packages/react/src/components/auth/DeviceGrant/DeviceGrant.tsx new file mode 100644 index 00000000..9864e9b0 --- /dev/null +++ b/packages/react/src/components/auth/DeviceGrant/DeviceGrant.tsx @@ -0,0 +1,268 @@ +// Copyright 2025-2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {EmbeddedFlowType, EmbeddedSignInFlowStatus, executeEmbeddedDeviceGrantFlow} from '@thunderid/browser'; +import type {EmbeddedFlowComponent, EmbeddedSignInFlowRequest, FlowMetadataResponse} from '@thunderid/browser'; +import {useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import type {FC, ReactElement, ReactNode} from 'react'; +import BaseDeviceGrant, {type BaseDeviceGrantProps, type DeviceGrantResult} from './BaseDeviceGrant'; +import useThunderID from '../../../contexts/ThunderID/useThunderID'; +import useTranslation from '../../../hooks/useTranslation'; +import {extractErrorMessage, normalizeFlowResponse} from '../../../utils/flowTransformer'; + +type EmbeddedDeviceGrantFlowResponse = Awaited>; +type DeviceGrantFieldError = NonNullable[number]; + +/** Values exposed to consumers that provide custom Device Grant UI. */ +export interface DeviceGrantRenderProps { + /** Additional values returned for the active flow step. */ + additionalData: Record; + /** Components returned by the configured flow. */ + components: EmbeddedFlowComponent[]; + /** Current flow error, when one needs to be shown. */ + error: Error | null; + /** First server validation message for each field. */ + fieldErrors: Record; + /** Values prefilled from the verification URI. */ + initialValues: Record; + /** Whether a flow request is in progress. */ + isLoading: boolean; + /** Flow metadata used to resolve application-specific template values. */ + meta: FlowMetadataResponse | null; + /** Submits user-code input or continues the active flow. */ + onSubmit: (payload: EmbeddedSignInFlowRequest) => Promise; + /** Terminal flow result when the flow has no configured prompt to render. */ + result: DeviceGrantResult; +} + +export type DeviceGrantProps = Omit< + BaseDeviceGrantProps, + 'children' | 'components' | 'error' | 'initialValues' | 'isLoading' | 'onSubmit' | 'result' | 'serverFieldErrors' +> & { + /** Render props for Gate or other consumers that provide their own flow UI. */ + children?: (props: DeviceGrantRenderProps) => ReactNode; + onComplete?: (response: EmbeddedDeviceGrantFlowResponse) => void; +}; + +const clearVerificationParameters = (): void => { + if (!window.location.href) return; + const url = new URL(window.location.href); + url.searchParams.delete('user_code'); + url.searchParams.delete('applicationId'); + window.history.replaceState(window.history.state, '', url.toString()); +}; + +const hasUserCodeInput = (components: EmbeddedFlowComponent[]): boolean => + components.some( + (component: EmbeddedFlowComponent) => + component.ref === 'user_code' || (Array.isArray(component.components) && hasUserCodeInput(component.components)), + ); + +/** + * Runs the application's configured Device Grant flow and exposes its state for custom renderers. + */ +const DeviceGrant: FC = ({ + children = undefined, + className = '', + onComplete = undefined, + onError = undefined, + preferences = {}, + size = 'medium', + variant = 'default', + ...baseProps +}: DeviceGrantProps): ReactElement => { + const {applicationId, baseUrl, meta} = useThunderID(); + const {t} = useTranslation(preferences?.i18n); + const [components, setComponents] = useState([]); + const [additionalData, setAdditionalData] = useState>({}); + const [initialValues, setInitialValues] = useState>({}); + const [serverFieldErrors, setServerFieldErrors] = useState< + EmbeddedDeviceGrantFlowResponse['data']['fieldErrors'] | null + >(null); + const [executionId, setExecutionId] = useState(''); + const [challengeToken, setChallengeToken] = useState(''); + const [error, setError] = useState(null); + const [isLoading, setIsLoading] = useState(() => { + if (applicationId) return true; + return typeof window !== 'undefined' && Boolean(new URL(window.location.href).searchParams.get('applicationId')); + }); + const [result, setResult] = useState(null); + const flowStartAttempted = useRef(false); + const verificationParametersRead = useRef(false); + + const handleError = useCallback( + (flowError: Error): void => { + setComponents([]); + setAdditionalData({}); + setServerFieldErrors(null); + setError(flowError); + setResult('failure'); + onError?.(flowError); + }, + [onError], + ); + + const applyResponse = useCallback( + (response: EmbeddedDeviceGrantFlowResponse): void => { + const normalized = normalizeFlowResponse( + response, + t, + { + throwOnError: false, + resolveTranslations: false, + }, + meta, + ); + setExecutionId(response.executionId); + setChallengeToken(response.challengeToken ?? ''); + setComponents(normalized.components); + setAdditionalData(normalized.additionalData); + setServerFieldErrors(response.data?.fieldErrors ?? null); + setError(response.error ? new Error(extractErrorMessage(response, t)) : null); + + if (response.flowStatus === EmbeddedSignInFlowStatus.Complete) { + setResult('success'); + onComplete?.(response); + return; + } + + if (response.flowStatus === EmbeddedSignInFlowStatus.Error) { + const flowError = new Error(extractErrorMessage(response, t, 'deviceGrant.messages.failed')); + setError(flowError); + setResult('failure'); + onError?.(flowError); + return; + } + + setResult(null); + }, + [meta, onComplete, onError, t], + ); + + const startFlow = useCallback( + async (appId: string): Promise => { + setIsLoading(true); + setError(null); + try { + const response = await executeEmbeddedDeviceGrantFlow({ + baseUrl, + payload: { + applicationId: appId, + flowType: EmbeddedFlowType.DeviceGrant, + inputs: {}, + }, + }); + clearVerificationParameters(); + applyResponse(response); + } catch (flowError) { + handleError(flowError instanceof Error ? flowError : new Error(t('deviceGrant.errors.continue'))); + } finally { + setIsLoading(false); + } + }, + [applyResponse, baseUrl, handleError, t], + ); + + useEffect(() => { + const url = new URL(window.location.href); + if (!verificationParametersRead.current) { + verificationParametersRead.current = true; + const userCode = url.searchParams.get('user_code') ?? ''; + if (userCode) setInitialValues({user_code: userCode}); + } + + const applicationIdFromUrl = url.searchParams.get('applicationId'); + const appId = applicationIdFromUrl?.trim() ? applicationIdFromUrl : applicationId; + if (flowStartAttempted.current) return; + if (!appId) { + flowStartAttempted.current = true; + handleError(new Error(t('deviceGrant.errors.start'))); + return; + } + + flowStartAttempted.current = true; + void startFlow(appId); + }, [applicationId, handleError, startFlow, t]); + + const submit = async (payload: EmbeddedSignInFlowRequest, actionId?: string): Promise => { + setIsLoading(true); + setError(null); + setResult(null); + try { + if (!executionId) return; + + const response = await executeEmbeddedDeviceGrantFlow({ + baseUrl, + payload: { + action: payload.action ?? actionId, + challengeToken, + executionId, + inputs: { + ...Object.fromEntries( + Object.entries(payload.inputs ?? {}).filter( + ([key]: [string, unknown]) => !key.startsWith('__consent_opt__'), + ), + ), + ...(!hasUserCodeInput(components) ? {user_code: undefined} : {}), + }, + }, + }); + applyResponse(response); + } catch (flowError) { + handleError(flowError instanceof Error ? flowError : new Error(t('deviceGrant.errors.continue'))); + } finally { + setIsLoading(false); + } + }; + + const handleBaseSubmit = (payload: EmbeddedSignInFlowRequest, component: EmbeddedFlowComponent): Promise => + submit(payload, component.id); + + const fieldErrors = useMemo>(() => { + const errors: Record = {}; + (serverFieldErrors ?? []).forEach((fieldError: DeviceGrantFieldError) => { + if (!(fieldError.identifier in errors)) errors[fieldError.identifier] = fieldError.message; + }); + + return errors; + }, [serverFieldErrors]); + + if (children) { + return ( + <> + {children({ + additionalData, + components, + error, + fieldErrors, + initialValues, + isLoading, + meta, + onSubmit: submit, + result, + })} + + ); + } + + return ( + + ); +}; + +export default DeviceGrant; diff --git a/packages/react/src/components/auth/DeviceGrant/__tests__/BaseDeviceGrant.test.tsx b/packages/react/src/components/auth/DeviceGrant/__tests__/BaseDeviceGrant.test.tsx new file mode 100644 index 00000000..013091a4 --- /dev/null +++ b/packages/react/src/components/auth/DeviceGrant/__tests__/BaseDeviceGrant.test.tsx @@ -0,0 +1,82 @@ +// Copyright 2025-2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {cleanup, render, screen} from '@testing-library/react'; +import {afterEach, describe, expect, it} from 'vitest'; +import I18nProvider from '../../../../contexts/I18n/I18nProvider'; +import ThemeProvider from '../../../../contexts/Theme/ThemeProvider'; +import BaseDeviceGrant from '../BaseDeviceGrant'; + +const renderResult = (error: Error | null = null): void => { + render( + + + Promise.resolve()} + result={error ? 'failure' : 'success'} + /> + + , + ); +}; + +afterEach(() => cleanup()); + +describe('BaseDeviceGrant terminal result', () => { + it('renders a compact success card', () => { + renderResult(); + + expect(screen.getByTestId('device-grant-result')).toHaveTextContent( + 'Device authorizedYou can return to your device.', + ); + }); + + it('uses bundled result text without requiring copy props', () => { + render( + + + Promise.resolve()} result="success" /> + + , + ); + + expect(screen.getByTestId('device-grant-result')).toHaveTextContent( + 'Device authorizedYou can return to your device.', + ); + }); + + it('renders a compact failure card', () => { + renderResult(new Error('Device authorization failed')); + + expect(screen.getByTestId('device-grant-result')).toHaveTextContent( + 'Device authorization failedReturn to your device and start again.', + ); + }); + + it('preserves unexpected errors as the failure description', () => { + renderResult(new Error('The callback endpoint rejected the response.')); + + expect(screen.getByTestId('device-grant-result')).toHaveTextContent( + 'Device authorization failedThe callback endpoint rejected the response.', + ); + }); + + it('does not infer success from an incomplete response with no components', () => { + render( + + + Promise.resolve()} result={null} /> + + , + ); + + expect(screen.queryByTestId('device-grant-result')).not.toBeInTheDocument(); + }); +}); diff --git a/packages/react/src/components/auth/DeviceGrant/__tests__/DeviceGrant.test.tsx b/packages/react/src/components/auth/DeviceGrant/__tests__/DeviceGrant.test.tsx new file mode 100644 index 00000000..338e5e11 --- /dev/null +++ b/packages/react/src/components/auth/DeviceGrant/__tests__/DeviceGrant.test.tsx @@ -0,0 +1,271 @@ +// Copyright 2025-2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react'; +import {EmbeddedFlowType, executeEmbeddedDeviceGrantFlow} from '@thunderid/browser'; +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import ThemeProvider from '../../../../contexts/Theme/ThemeProvider'; +import DeviceGrant from '../DeviceGrant'; + +vi.mock('@thunderid/browser', async (importOriginal) => ({ + ...(await importOriginal()), + executeEmbeddedDeviceGrantFlow: vi.fn(), +})); + +vi.mock('../../../../contexts/ThunderID/useThunderID', () => ({ + default: () => ({baseUrl: 'https://id.example.com', meta: {application: {name: 'Example App'}}}), +})); + +vi.mock('../../../../hooks/useTranslation', () => ({ + default: () => ({t: (key: string) => key}), +})); + +vi.mock('../../../../utils/flowTransformer', () => ({ + extractErrorMessage: () => 'deviceGrant.messages.failed', + normalizeFlowResponse: (response: { + data?: {additionalData?: Record; meta?: {components?: unknown[]}}; + }) => ({ + additionalData: response.data?.additionalData ?? {}, + components: response.data?.meta?.components ?? [], + }), +})); + +vi.mock('../BaseDeviceGrant', () => ({ + default: (props: { + components: unknown[]; + additionalData?: Record; + error?: Error | null; + initialValues?: Record; + onSubmit: (payload: {action: string; inputs: Record}, component: {id: string}) => void; + result?: string | null; + }) => ( +
+ {props.initialValues?.['user_code'] ?? ''} + {props.components.length} + {JSON.stringify(props.additionalData?.['consentPrompt'] ?? null)} + {props.error?.message ?? ''} + {props.result ?? ''} + + +
+ ), +})); + +const mockedExecute = vi.mocked(executeEmbeddedDeviceGrantFlow); + +const renderDeviceGrant = (): void => { + render( + + + , + ); +}; + +beforeEach(() => { + window.history.replaceState({}, '', '/device?applicationId=app-1&user_code=BCDF-GHJK'); + mockedExecute.mockReset(); + mockedExecute.mockResolvedValue({ + executionId: 'execution-1', + flowStatus: 'INCOMPLETE', + challengeToken: 'challenge-1', + data: { + meta: {components: [{id: 'user-code', ref: 'user_code'}]}, + additionalData: {consentPrompt: JSON.stringify([{purposeId: 'profile', optional: [{name: 'name'}]}])}, + }, + } as never); +}); + +afterEach(() => { + cleanup(); + window.history.replaceState({}, '', '/'); +}); + +describe('DeviceGrant', () => { + it('prefills the configured flow code prompt without submitting the code at flow start', async () => { + window.history.replaceState({}, '', '/device?applicationId=app-1&user_code=BCDF-GHJK'); + + renderDeviceGrant(); + + await waitFor(() => { + expect(mockedExecute).toHaveBeenCalledWith({ + baseUrl: 'https://id.example.com', + payload: { + applicationId: 'app-1', + flowType: EmbeddedFlowType.DeviceGrant, + inputs: {}, + }, + }); + }); + expect(screen.getByTestId('code-value')).toHaveTextContent('BCDF-GHJK'); + expect(window.location.search).toBe(''); + + fireEvent.click(screen.getByRole('button', {name: 'Continue'})); + + await waitFor(() => expect(mockedExecute).toHaveBeenCalledTimes(2)); + expect(mockedExecute).toHaveBeenLastCalledWith({ + baseUrl: 'https://id.example.com', + payload: { + action: 'continue', + challengeToken: 'challenge-1', + executionId: 'execution-1', + inputs: {user_code: 'BCDF-GHJK'}, + }, + }); + }); + + it('lets the configured flow provide its code prompt when the URI has no code', async () => { + window.history.replaceState({}, '', '/device?applicationId=app-1'); + renderDeviceGrant(); + + await waitFor(() => { + expect(mockedExecute).toHaveBeenCalledWith({ + baseUrl: 'https://id.example.com', + payload: { + applicationId: 'app-1', + flowType: EmbeddedFlowType.DeviceGrant, + inputs: {}, + }, + }); + }); + expect(screen.getByTestId('component-count')).toHaveTextContent('1'); + }); + + it('continues the Device Grant flow after it has started', async () => { + mockedExecute.mockResolvedValueOnce({ + executionId: 'execution-1', + flowStatus: 'INCOMPLETE', + challengeToken: 'challenge-1', + data: {meta: {components: [{id: 'sign-in', ref: 'username'}]}}, + } as never); + mockedExecute.mockResolvedValueOnce({ + executionId: 'execution-1', + flowStatus: 'INCOMPLETE', + challengeToken: 'challenge-2', + data: {meta: {components: [{id: 'sign-in', ref: 'username'}]}}, + } as never); + renderDeviceGrant(); + + await waitFor(() => expect(mockedExecute).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(window.location.search).toBe('')); + fireEvent.click(screen.getByRole('button', {name: 'Continue'})); + + await waitFor(() => expect(mockedExecute).toHaveBeenCalledTimes(2)); + expect(mockedExecute).toHaveBeenLastCalledWith({ + baseUrl: 'https://id.example.com', + payload: { + action: 'continue', + challengeToken: 'challenge-1', + executionId: 'execution-1', + inputs: {user_code: undefined}, + }, + }); + }); + + it('strips internal consent option fields but preserves consent_decisions', async () => { + window.history.replaceState({}, '', '/device?applicationId=app-1'); + mockedExecute.mockResolvedValueOnce({ + executionId: 'execution-1', + flowStatus: 'INCOMPLETE', + challengeToken: 'challenge-1', + data: {meta: {components: [{id: 'consent', type: 'CONSENT'}]}}, + } as never); + renderDeviceGrant(); + + await waitFor(() => expect(mockedExecute).toHaveBeenCalledTimes(1)); + fireEvent.click(screen.getByRole('button', {name: 'Submit consent'})); + + await waitFor(() => expect(mockedExecute).toHaveBeenCalledTimes(2)); + expect(mockedExecute).toHaveBeenLastCalledWith({ + baseUrl: 'https://id.example.com', + payload: { + action: 'authorize', + challengeToken: 'challenge-1', + executionId: 'execution-1', + inputs: {consent_decisions: {profile: {email: true}}, user_code: undefined}, + }, + }); + }); + + it('shows continuation failures from the OAuth callback', async () => { + mockedExecute + .mockResolvedValueOnce({ + executionId: 'execution-1', + flowStatus: 'INCOMPLETE', + challengeToken: 'challenge-1', + data: {meta: {components: [{id: 'user-code', ref: 'user_code'}]}}, + } as never) + .mockRejectedValueOnce(new Error('callback rejected approval')); + renderDeviceGrant(); + + await waitFor(() => expect(mockedExecute).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(window.location.search).toBe('')); + fireEvent.click(screen.getByRole('button', {name: 'Continue'})); + + await waitFor(() => expect(screen.getByTestId('flow-error')).toHaveTextContent('callback rejected approval')); + expect(screen.getByTestId('flow-result')).toHaveTextContent('failure'); + }); + + it('maps only a COMPLETE flow response to success and calls onComplete', async () => { + const onComplete = vi.fn(); + mockedExecute.mockResolvedValueOnce({ + executionId: 'execution-1', + flowStatus: 'COMPLETE', + data: {meta: {components: []}}, + } as never); + + render( + + + , + ); + + await waitFor(() => expect(screen.getByTestId('flow-result')).toHaveTextContent('success')); + expect(onComplete).toHaveBeenCalledTimes(1); + }); + + it('keeps an empty INCOMPLETE response non-terminal', async () => { + mockedExecute.mockResolvedValueOnce({ + executionId: 'execution-1', + flowStatus: 'INCOMPLETE', + data: {meta: {components: []}}, + } as never); + + renderDeviceGrant(); + + await waitFor(() => expect(mockedExecute).toHaveBeenCalledTimes(1)); + expect(screen.getByTestId('flow-result')).toBeEmptyDOMElement(); + }); + + it('reports a start error when no application ID is available', async () => { + window.history.replaceState({}, '', '/device'); + + renderDeviceGrant(); + + await waitFor(() => expect(screen.getByTestId('flow-error')).toHaveTextContent('deviceGrant.errors.start')); + expect(mockedExecute).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/react/src/components/auth/SignIn/BaseSignIn.tsx b/packages/react/src/components/auth/SignIn/BaseSignIn.tsx index 3f7459b1..1870af45 100644 --- a/packages/react/src/components/auth/SignIn/BaseSignIn.tsx +++ b/packages/react/src/components/auth/SignIn/BaseSignIn.tsx @@ -10,7 +10,7 @@ import { Preferences, buildValidatorFromRules, } from '@thunderid/browser'; -import {FC, useEffect, useMemo, useState, useCallback, useContext, ReactElement, ReactNode} from 'react'; +import {FC, useEffect, useMemo, useRef, useState, useCallback, useContext, ReactElement, ReactNode} from 'react'; import useStyles from './BaseSignIn.styles'; import ComponentRendererContext, { ComponentRendererMap, @@ -166,6 +166,11 @@ export interface BaseSignInProps { */ isLoading?: boolean; + /** + * Values to seed form fields with, such as a value supplied by a verification URI. + */ + initialValues?: Record; + /** * Timer flag disabling actions */ @@ -251,6 +256,7 @@ const BaseSignInContent: FC = ({ size = 'medium', variant = 'outlined', isLoading: externalIsLoading, + initialValues, children, additionalData = {}, isTimeoutDisabled = false, @@ -376,6 +382,7 @@ const BaseSignInContent: FC = ({ errors: formErrors, isValid: isFormValid, setValue: setFormValue, + setValues: setFormValues, setTouched: setFormTouched, setTouchedFields, setErrors: setFormErrors, @@ -386,6 +393,25 @@ const BaseSignInContent: FC = ({ revalidateTouchedFields, } = form; + const initialValuesKey: string = useMemo( + () => JSON.stringify(Object.entries(initialValues ?? {}).sort(([left], [right]) => left.localeCompare(right))), + [initialValues], + ); + const appliedInitialValuesKeyRef = useRef(null); + + useEffect(() => { + if ( + !initialValues || + Object.keys(initialValues).length === 0 || + appliedInitialValuesKeyRef.current === initialValuesKey + ) { + return; + } + + appliedInitialValuesKeyRef.current = initialValuesKey; + setFormValues(initialValues); + }, [initialValues, initialValuesKey, setFormValues]); + // Project server-side fieldErrors into form state. `setTouchedFields` is used instead // of `setTouched` so client-side `validateField` doesn't wipe server errors when the // client rules happen to pass. diff --git a/packages/react/src/components/auth/SignIn/__tests__/BaseSignIn.test.tsx b/packages/react/src/components/auth/SignIn/__tests__/BaseSignIn.test.tsx index c2ecef1d..f432db69 100644 --- a/packages/react/src/components/auth/SignIn/__tests__/BaseSignIn.test.tsx +++ b/packages/react/src/components/auth/SignIn/__tests__/BaseSignIn.test.tsx @@ -1,7 +1,7 @@ // Copyright 2026 The ThunderID Authors // SPDX-License-Identifier: Apache-2.0 -import {cleanup, render} from '@testing-library/react'; +import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react'; import {afterEach, describe, expect, it, vi} from 'vitest'; import I18nProvider from '../../../../contexts/I18n/I18nProvider'; import ThemeProvider from '../../../../contexts/Theme/ThemeProvider'; @@ -38,4 +38,52 @@ describe('BaseSignIn', () => { expect.objectContaining({additionalData: {linkingPromptDetails: '[]'}}), ); }); + + it('applies supplied initial values to the form render props', async () => { + let renderedValues: Record = {}; + + render( + + + + + {({values}) => { + renderedValues = values; + return
; + }} + + + + , + ); + + await waitFor(() => expect(renderedValues).toEqual({user_code: 'ABCD-EFGH'})); + }); + + it('does not reapply equivalent initial values after the user edits a field', async () => { + const renderBaseSignIn = (initialValues: Record) => ( + + + + + {({handleInputChange, values}) => ( + handleInputChange('user_code', event.target.value)} + /> + )} + + + + + ); + const {rerender} = render(renderBaseSignIn({user_code: 'ABCD-EFGH'})); + + await waitFor(() => expect(screen.getByRole('textbox', {name: 'Verification code'})).toHaveValue('ABCD-EFGH')); + fireEvent.change(screen.getByRole('textbox', {name: 'Verification code'}), {target: {value: 'WXYZ-1234'}}); + rerender(renderBaseSignIn({user_code: 'ABCD-EFGH'})); + + expect(screen.getByRole('textbox', {name: 'Verification code'})).toHaveValue('WXYZ-1234'); + }); }); diff --git a/packages/react/src/components/auth/flow/AuthOptionFactory.tsx b/packages/react/src/components/auth/flow/AuthOptionFactory.tsx index 275eed7c..12ed0ce5 100644 --- a/packages/react/src/components/auth/flow/AuthOptionFactory.tsx +++ b/packages/react/src/components/auth/flow/AuthOptionFactory.tsx @@ -29,7 +29,7 @@ import { getConsentElementDecision, } from '@thunderid/browser'; import DOMPurify from 'dompurify'; -import {ChangeEvent, cloneElement, CSSProperties, FormEvent, ReactElement} from 'react'; +import {ChangeEvent, cloneElement, CSSProperties, FormEvent, MouseEvent, ReactElement} from 'react'; import Consent from './Consent'; import {getConsentOptionalKey} from './ConsentCheckboxList'; import {createField} from './FieldFactory'; @@ -54,7 +54,7 @@ import flowIconRegistry from '../../primitives/Icons/flowIconRegistry'; import KeyValueList from '../../primitives/KeyValueList/KeyValueList'; import Select from '../../primitives/Select/Select'; import Typography from '../../primitives/Typography/Typography'; -import {TypographyVariant} from '../../primitives/Typography/Typography.styles'; +import {TypographyColor, TypographyVariant} from '../../primitives/Typography/Typography.styles'; import BaseUserSelect, {BaseUserSelectProps} from '../../primitives/UserSelect/BaseUserSelect'; import BaseUserTypeSelect from '../../primitives/UserTypeSelect/BaseUserTypeSelect'; import FacebookButton from '../buttons/FacebookButton'; @@ -170,6 +170,26 @@ const getTypographyVariant = (variant: string): any => { return variantMap[variant] || 'h3'; }; +/** Maps flow text color values to SDK typography color variants. */ +const getTypographyColor = (color?: string): TypographyColor | undefined => { + switch (color?.toUpperCase()) { + case 'ERROR': + return 'error'; + case 'WARNING': + return 'warning'; + case 'SUCCESS': + return 'success'; + case 'INFO': + return 'info'; + case 'PRIMARY': + return 'primary'; + case 'SECONDARY': + return 'secondary'; + default: + return undefined; + } +}; + /** * Check if a button text or action matches a social provider. */ @@ -446,7 +466,12 @@ const createAuthComponentFromFlow = ( formValues, ); - const handleClick = (): any => { + const handleClick = (event: MouseEvent): void => { + // Flow actions are handled by the SDK. Cancel the native button action before + // onSubmit can switch steps or replace the form; otherwise the browser tries to + // submit a form that React has already disconnected from the document. + event.preventDefault(); + if (options.onSubmit) { const formData: Record = {}; Object.keys(formValues).forEach((field: any) => { @@ -567,13 +592,20 @@ const createAuthComponentFromFlow = ( case EmbeddedFlowComponentType.Text: { const variant: any = getTypographyVariant(component.variant!); + const color: TypographyColor | undefined = getTypographyColor(component.color); const textAlignClass: string = css({ marginBottom: 2, textAlign: typeof component?.align === 'string' ? (component.align as React.CSSProperties['textAlign']) : 'left', }); return ( - + {resolve(component.label)} ); 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 c976038d..4b875fae 100644 --- a/packages/react/src/components/auth/flow/__tests__/AuthOptionFactory.test.tsx +++ b/packages/react/src/components/auth/flow/__tests__/AuthOptionFactory.test.tsx @@ -172,6 +172,52 @@ describe('AuthOptionFactory rich-text action', () => { }); }); +describe('AuthOptionFactory form actions', () => { + it('cancels the native submit action before dispatching the SDK submission', () => { + const onSubmit = vi.fn(); + const components: EmbeddedFlowComponent[] = [ + { + components: [ + { + eventType: EmbeddedFlowEventType.Submit, + id: 'continue', + label: 'Continue', + type: EmbeddedFlowComponentType.Action, + }, + ], + id: 'device-code-form', + type: EmbeddedFlowComponentType.Block, + }, + ]; + const elements = renderSignInComponents( + components, + {user_code: 'ABCD-EFGH'}, + {}, + {}, + false, + true, + () => undefined, + () => undefined, + {onSubmit}, + ); + const {container} = render( + +
{elements}
+
, + ); + const form = container.querySelector('#device-code-form')!; + const nativeSubmit = vi.fn((event: Event): void => event.preventDefault()); + form.addEventListener('submit', nativeSubmit); + + fireEvent.click(screen.getByRole('button', {name: 'Continue'})); + + expect(onSubmit).toHaveBeenCalledTimes(1); + expect(nativeSubmit).not.toHaveBeenCalled(); + }); +}); + describe('AuthOptionFactory stack grid layout', () => { const stackWith = (extra: Record, childCount = 4): EmbeddedFlowComponent => ({ diff --git a/packages/react/src/components/primitives/Typography/Typography.styles.ts b/packages/react/src/components/primitives/Typography/Typography.styles.ts index 10b340ac..d30dbcc2 100644 --- a/packages/react/src/components/primitives/Typography/Typography.styles.ts +++ b/packages/react/src/components/primitives/Typography/Typography.styles.ts @@ -70,6 +70,12 @@ const useStyles = ( return theme.colors.secondary.main; case 'error': return theme.colors.error.main; + case 'success': + return theme.colors.success.main; + case 'warning': + return theme.colors.warning.main; + case 'info': + return theme.colors.info.main; case 'textPrimary': return theme.colors.text.primary; case 'textSecondary': diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 216c2fb9..6d78cf74 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -90,6 +90,12 @@ export * from './components/auth/guards/SignedOut'; export {default as Loading} from './components/auth/guards/Loading'; export * from './components/auth/guards/Loading'; +export {default as DeviceGrant} from './components/auth/DeviceGrant/DeviceGrant'; +export * from './components/auth/DeviceGrant/DeviceGrant'; + +export {default as BaseDeviceGrant} from './components/auth/DeviceGrant/BaseDeviceGrant'; +export * from './components/auth/DeviceGrant/BaseDeviceGrant'; + export {default as BaseSignIn} from './components/auth/SignIn/BaseSignIn'; export * from './components/auth/SignIn/BaseSignIn'; diff --git a/packages/vue/src/__tests__/components/auth/consent.test.ts b/packages/vue/src/__tests__/components/auth/consent.test.ts new file mode 100644 index 00000000..d21060e1 --- /dev/null +++ b/packages/vue/src/__tests__/components/auth/consent.test.ts @@ -0,0 +1,45 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {mount} from '@vue/test-utils'; +import {describe, expect, it, vi} from 'vitest'; +import {getConsentOptionalKey, default as Consent} from '../../../components/auth/flow/Consent'; + +describe('Consent', () => { + it('renders the optional-consent master switch and individual switches', async () => { + const onInputChange = vi.fn(); + const wrapper = mount(Consent, { + props: { + consentData: { + purposes: [ + { + essential: [], + optional: [{name: 'booking:read'}, {name: 'booking:write'}], + purposeId: 'permissions', + purposeName: 'Permissions', + type: 'permissions', + }, + ], + }, + formValues: {}, + onInputChange, + t: (key: string): string => key, + }, + }); + + expect(wrapper.text()).toContain('Optional Permissions'); + expect(wrapper.findAll('[role="switch"]')).toHaveLength(3); + + await wrapper.findAll('[role="switch"]')[0].setValue(true); + expect(onInputChange).toHaveBeenCalledWith(getConsentOptionalKey('permissions', 'booking:read'), 'true'); + expect(onInputChange).toHaveBeenCalledWith(getConsentOptionalKey('permissions', 'booking:write'), 'true'); + }); + + it('normalizes malformed prompt payloads to an empty render', () => { + const wrapper = mount(Consent, { + props: {consentData: '{not json}', formValues: {}, onInputChange: vi.fn()}, + }); + + expect(wrapper.find('.thunderid-consent').exists()).toBe(false); + }); +}); diff --git a/packages/vue/src/__tests__/components/auth/device-grant.test.ts b/packages/vue/src/__tests__/components/auth/device-grant.test.ts new file mode 100644 index 00000000..84d7e086 --- /dev/null +++ b/packages/vue/src/__tests__/components/auth/device-grant.test.ts @@ -0,0 +1,296 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {executeEmbeddedDeviceGrantFlow, EmbeddedFlowType} from '@thunderid/browser'; +import {flushPromises, mount} from '@vue/test-utils'; +import {afterEach, describe, expect, it, vi} from 'vitest'; +import {nextTick, ref} from 'vue'; +import BaseDeviceGrant from '../../../components/auth/device-grant/BaseDeviceGrant'; +import DeviceGrant from '../../../components/auth/device-grant/DeviceGrant'; +import {FLOW_KEY, FLOW_META_KEY, I18N_KEY, THUNDERID_KEY} from '../../../keys'; + +vi.mock('@thunderid/browser', async (importOriginal) => ({ + ...(await importOriginal()), + executeEmbeddedDeviceGrantFlow: vi.fn(), +})); + +afterEach(() => { + vi.mocked(executeEmbeddedDeviceGrantFlow).mockReset(); + window.history.replaceState({}, '', '/'); +}); + +describe('BaseDeviceGrant', () => { + it('renders server-provided consent data on a Device Grant step', () => { + const wrapper = mount(BaseDeviceGrant, { + global: { + provide: { + [FLOW_KEY as symbol]: { + addMessage: () => undefined, + clearMessages: () => undefined, + messages: ref([]), + subtitle: ref(null), + title: ref(null), + }, + [FLOW_META_KEY as symbol]: {meta: ref(null)}, + [I18N_KEY as symbol]: {currentLanguage: ref('en-US'), t: (key: string): string => key}, + }, + }, + props: { + additionalData: { + consentPrompt: { + purposes: [ + { + essential: [], + optional: [{name: 'booking:read'}], + purposeId: 'permissions', + type: 'permissions', + }, + ], + }, + }, + components: [{id: 'consent', type: 'CONSENT'}], + onSubmit: () => Promise.resolve(), + }, + }); + + expect(wrapper.text()).toContain('Optional Permissions'); + expect(wrapper.text()).toContain('booking:read'); + expect(wrapper.findAll('[role="switch"]')).toHaveLength(2); + expect(wrapper.get('.thunderid-card').classes()).toContain('thunderid-card--flat'); + }); + + it('prefills the user code from the verification URI values', async () => { + const wrapper = mount(BaseDeviceGrant, { + global: { + provide: { + [FLOW_KEY as symbol]: { + addMessage: () => undefined, + clearMessages: () => undefined, + messages: ref([]), + subtitle: ref(null), + title: ref(null), + }, + [FLOW_META_KEY as symbol]: {meta: ref(null)}, + [I18N_KEY as symbol]: {currentLanguage: ref('en-US'), t: (key: string): string => key}, + }, + }, + props: { + components: [ + { + id: 'user-code', + label: 'Verification code', + placeholder: 'ABCD-EFGH', + ref: 'user_code', + required: true, + type: 'TEXT_INPUT', + }, + ], + initialValues: {user_code: 'ABCD-EFGH'}, + onSubmit: () => Promise.resolve(), + }, + }); + + await nextTick(); + expect((wrapper.find('input[name="user_code"]').element as HTMLInputElement).value).toBe('ABCD-EFGH'); + }); +}); + +describe('DeviceGrant', () => { + it('prefills the configured flow code prompt without submitting the code at flow start', async () => { + window.history.replaceState({}, '', '/gate/device?applicationId=app-1&user_code=BCDF-GHJK'); + vi.mocked(executeEmbeddedDeviceGrantFlow).mockResolvedValue({ + challengeToken: 'challenge-1', + data: { + meta: { + components: [ + {id: 'user-code', label: 'Verification code', ref: 'user_code', required: true, type: 'TEXT_INPUT'}, + {eventType: 'SUBMIT', id: 'continue', label: 'Continue', type: 'ACTION'}, + ], + }, + }, + executionId: 'execution-1', + flowStatus: 'INCOMPLETE', + } as never); + + const wrapper = mount(DeviceGrant, { + global: { + provide: { + [FLOW_KEY as symbol]: { + addMessage: vi.fn(), + clearMessages: vi.fn(), + messages: ref([]), + subtitle: ref(null), + title: ref(null), + }, + [FLOW_META_KEY as symbol]: {meta: ref(null)}, + [I18N_KEY as symbol]: {currentLanguage: ref('en-US'), t: (key: string): string => key}, + [THUNDERID_KEY as symbol]: {applicationId: undefined, baseUrl: 'https://id.example.com'}, + }, + }, + }); + + await flushPromises(); + + expect(executeEmbeddedDeviceGrantFlow).toHaveBeenCalledWith({ + baseUrl: 'https://id.example.com', + payload: { + applicationId: 'app-1', + flowType: EmbeddedFlowType.DeviceGrant, + inputs: {}, + }, + }); + expect((wrapper.find('input[name="user_code"]').element as HTMLInputElement).value).toBe('BCDF-GHJK'); + expect(window.location.search).toBe(''); + await wrapper.get('button').trigger('click'); + await flushPromises(); + expect(executeEmbeddedDeviceGrantFlow).toHaveBeenLastCalledWith({ + baseUrl: 'https://id.example.com', + payload: { + action: 'continue', + challengeToken: 'challenge-1', + executionId: 'execution-1', + inputs: {user_code: 'BCDF-GHJK'}, + }, + }); + wrapper.unmount(); + }); + + it('renders the configured code prompt when the verification URI has no code', async () => { + window.history.replaceState({}, '', '/gate/device?applicationId=app-1'); + vi.mocked(executeEmbeddedDeviceGrantFlow).mockResolvedValue({ + challengeToken: 'challenge-1', + data: { + meta: { + components: [ + { + id: 'user-code', + label: 'Verification code', + ref: 'user_code', + required: true, + type: 'TEXT_INPUT', + }, + ], + }, + }, + executionId: 'execution-1', + flowStatus: 'INCOMPLETE', + } as never); + + const wrapper = mount(DeviceGrant, { + global: { + provide: { + [FLOW_KEY as symbol]: { + addMessage: vi.fn(), + clearMessages: vi.fn(), + messages: ref([]), + subtitle: ref(null), + title: ref(null), + }, + [FLOW_META_KEY as symbol]: {meta: ref(null)}, + [I18N_KEY as symbol]: {currentLanguage: ref('en-US'), t: (key: string): string => key}, + [THUNDERID_KEY as symbol]: {applicationId: undefined, baseUrl: 'https://id.example.com'}, + }, + }, + }); + + await flushPromises(); + + expect(executeEmbeddedDeviceGrantFlow).toHaveBeenCalledWith({ + baseUrl: 'https://id.example.com', + payload: { + applicationId: 'app-1', + flowType: EmbeddedFlowType.DeviceGrant, + inputs: {}, + }, + }); + expect(wrapper.find('input[name="user_code"]').exists()).toBe(true); + wrapper.unmount(); + }); + + it('does not start when the verification URL has no application ID', async () => { + window.history.replaceState({}, '', '/gate/device?user_code=BCDF-GHJK'); + const wrapper = mount(DeviceGrant, { + global: { + provide: { + [FLOW_KEY as symbol]: { + addMessage: vi.fn(), + clearMessages: vi.fn(), + messages: ref([]), + subtitle: ref(null), + title: ref(null), + }, + [FLOW_META_KEY as symbol]: {meta: ref(null)}, + [I18N_KEY as symbol]: {currentLanguage: ref('en-US'), t: (key: string): string => key}, + [THUNDERID_KEY as symbol]: {applicationId: undefined, baseUrl: 'https://id.example.com'}, + }, + }, + }); + + await flushPromises(); + + expect(executeEmbeddedDeviceGrantFlow).not.toHaveBeenCalled(); + expect(wrapper.text()).toContain('deviceGrant.errors.start'); + wrapper.unmount(); + }); + + it('does not retain the user code when a later step no longer contains that input', async () => { + window.history.replaceState({}, '', '/gate/device?applicationId=app-1&user_code=BCDF-GHJK'); + vi.mocked(executeEmbeddedDeviceGrantFlow) + .mockResolvedValueOnce({ + challengeToken: 'challenge-1', + data: { + meta: { + components: [{eventType: 'SUBMIT', id: 'continue', label: 'Continue', type: 'ACTION'}], + }, + }, + executionId: 'execution-1', + flowStatus: 'INCOMPLETE', + } as never) + .mockResolvedValueOnce({ + challengeToken: 'challenge-2', + data: {meta: {components: []}}, + executionId: 'execution-1', + flowStatus: 'INCOMPLETE', + } as never); + + const wrapper = mount(DeviceGrant, { + global: { + provide: { + [FLOW_KEY as symbol]: { + addMessage: vi.fn(), + clearMessages: vi.fn(), + messages: ref([]), + subtitle: ref(null), + title: ref(null), + }, + [FLOW_META_KEY as symbol]: {meta: ref(null)}, + [I18N_KEY as symbol]: {currentLanguage: ref('en-US'), t: (key: string): string => key}, + [THUNDERID_KEY as symbol]: {applicationId: undefined, baseUrl: 'https://id.example.com'}, + }, + }, + }); + + await flushPromises(); + await wrapper.get('button').trigger('click'); + await flushPromises(); + + expect(executeEmbeddedDeviceGrantFlow).toHaveBeenNthCalledWith(1, { + baseUrl: 'https://id.example.com', + payload: { + applicationId: 'app-1', + flowType: EmbeddedFlowType.DeviceGrant, + inputs: {}, + }, + }); + expect(executeEmbeddedDeviceGrantFlow).toHaveBeenLastCalledWith({ + baseUrl: 'https://id.example.com', + payload: { + action: 'continue', + challengeToken: 'challenge-1', + executionId: 'execution-1', + inputs: {}, + }, + }); + wrapper.unmount(); + }); +}); 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 d6c120c9..5c2d7d4b 100644 --- a/packages/vue/src/__tests__/components/auth/sign-in.test.ts +++ b/packages/vue/src/__tests__/components/auth/sign-in.test.ts @@ -10,6 +10,33 @@ import SignIn from '../../../components/auth/sign-in/SignIn'; import {FLOW_KEY, FLOW_META_KEY, I18N_KEY, THUNDERID_KEY} from '../../../keys'; describe('BaseSignIn', () => { + it('applies semantic colors from server-provided flow text', () => { + const wrapper = mount(BaseSignIn, { + global: { + provide: { + [FLOW_KEY as symbol]: { + addMessage: vi.fn(), + clearMessages: vi.fn(), + messages: ref([]), + subtitle: ref(null), + title: ref(null), + }, + [FLOW_META_KEY as symbol]: {meta: ref(null)}, + [I18N_KEY as symbol]: {currentLanguage: ref('en-US'), t: (key: string): string => key}, + }, + }, + props: { + components: [ + {color: 'SUCCESS', id: 'success-title', label: 'Device authorized', type: 'TEXT', variant: 'HEADING_1'}, + ], + }, + }); + + expect(wrapper.get('#success-title').element.getAttribute('style')).toContain( + 'color: var(--thunderid-color-success-main)', + ); + }); + it('exposes the step additionalData to the default slot', () => { const slot: Mock = vi.fn(() => []); mount(BaseSignIn, { diff --git a/packages/vue/src/components/auth/device-grant/BaseDeviceGrant.css.ts b/packages/vue/src/components/auth/device-grant/BaseDeviceGrant.css.ts new file mode 100644 index 00000000..7506f1a6 --- /dev/null +++ b/packages/vue/src/components/auth/device-grant/BaseDeviceGrant.css.ts @@ -0,0 +1,32 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {withVendorCSSClassPrefix} from '@thunderid/browser'; + +const BASE_DEVICE_GRANT_CSS = ` +.${withVendorCSSClassPrefix('devicegrant__terminal')} { + box-sizing: border-box; + width: 100%; + max-width: calc(var(--thunderid-spacing-unit) * 60); + margin: 0 auto; + gap: var(--thunderid-spacing-unit); +} + +.${withVendorCSSClassPrefix('devicegrant__terminal-title')} { + margin: 0; +} + +.${withVendorCSSClassPrefix('devicegrant__terminal-title--success')} { + color: var(--thunderid-color-success-main); +} + +.${withVendorCSSClassPrefix('devicegrant__terminal-title--failure')} { + color: var(--thunderid-color-error-main); +} + +.${withVendorCSSClassPrefix('devicegrant__terminal-description')} { + margin: 0; +} +`; + +export default BASE_DEVICE_GRANT_CSS; diff --git a/packages/vue/src/components/auth/device-grant/BaseDeviceGrant.ts b/packages/vue/src/components/auth/device-grant/BaseDeviceGrant.ts new file mode 100644 index 00000000..6866c164 --- /dev/null +++ b/packages/vue/src/components/auth/device-grant/BaseDeviceGrant.ts @@ -0,0 +1,122 @@ +// Copyright 2025-2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {withVendorCSSClassPrefix, type EmbeddedFlowComponent} from '@thunderid/browser'; +import {type Component, type PropType, type SetupContext, type VNode, defineComponent, h} from 'vue'; +import useI18n from '../../../composables/useI18n'; +import Card from '../../primitives/Card'; +import Typography from '../../primitives/Typography'; +import BaseSignIn from '../sign-in/BaseSignIn'; +import type {BaseSignInRenderProps, BaseSignInProps} from '../sign-in/BaseSignIn'; + +export type DeviceGrantResult = 'success' | 'failure' | null; + +export interface BaseDeviceGrantRenderProps extends BaseSignInRenderProps { + result: DeviceGrantResult; +} + +export interface BaseDeviceGrantProps extends Omit { + components: EmbeddedFlowComponent[]; + initialValues?: Record; + onSubmit: NonNullable; + result?: DeviceGrantResult; + completeMessage?: string; + completeDescription?: string; + failedMessage?: string; + failedDescription?: string; +} + +/** Renders Device Grant flow steps and terminal status without owning flow execution. */ +const BaseDeviceGrant: Component = defineComponent({ + name: 'BaseDeviceGrant', + props: { + components: {default: (): EmbeddedFlowComponent[] => [], type: Array as PropType}, + additionalData: {default: (): Record => ({}), type: Object as PropType>}, + initialValues: {default: (): Record => ({}), type: Object as PropType>}, + onSubmit: {required: true, type: Function as PropType}, + result: {default: null, type: String as PropType}, + className: {default: '', type: String}, + variant: {default: 'flat', type: String as PropType<'elevated' | 'outlined' | 'flat'>}, + size: {default: 'medium', type: String as PropType<'small' | 'medium' | 'large'>}, + isLoading: {default: false, type: Boolean}, + error: {default: null, type: Object as PropType}, + serverFieldErrors: {default: null, type: Array as PropType}, + completeMessage: {default: undefined, type: String}, + completeDescription: {default: undefined, type: String}, + failedMessage: {default: undefined, type: String}, + failedDescription: {default: undefined, type: String}, + }, + setup(props: Readonly, {slots, attrs}: SetupContext): () => VNode { + const {t} = useI18n(); + + return (): VNode => { + if (props.result && props.components.length === 0) { + const success: boolean = props.result === 'success' && !props.error; + const failedMessage: string = props.failedMessage ?? t('deviceGrant.messages.failed'); + const title: string = success ? (props.completeMessage ?? t('deviceGrant.messages.complete')) : failedMessage; + const description: string = success + ? (props.completeDescription ?? t('deviceGrant.messages.completeDescription')) + : !props.error || props.error.message === failedMessage + ? (props.failedDescription ?? t('deviceGrant.messages.failedDescription')) + : props.error.message; + return h( + Card, + { + ...attrs, + class: [withVendorCSSClassPrefix('devicegrant__terminal'), props.className].filter(Boolean).join(' '), + 'data-testid': 'device-grant-result', + variant: props.variant, + }, + () => [ + h( + Typography, + { + class: [ + withVendorCSSClassPrefix('devicegrant__terminal-title'), + withVendorCSSClassPrefix(`devicegrant__terminal-title--${success ? 'success' : 'failure'}`), + ].join(' '), + component: 'h2', + variant: 'h6', + }, + () => title, + ), + h( + Typography, + { + class: withVendorCSSClassPrefix('devicegrant__terminal-description'), + component: 'p', + variant: 'caption', + }, + () => description, + ), + ], + ); + } + + return h( + BaseSignIn, + { + ...attrs, + className: props.className, + components: props.components, + additionalData: props.additionalData, + error: props.error, + initialValues: props.initialValues, + isLoading: props.isLoading, + onSubmit: props.onSubmit, + serverFieldErrors: props.serverFieldErrors, + size: props.size, + variant: props.variant, + }, + slots['default'] + ? { + default: (renderProps: BaseSignInRenderProps) => + slots['default']?.({...renderProps, result: props.result}), + } + : undefined, + ); + }; + }, +}); + +export default BaseDeviceGrant; diff --git a/packages/vue/src/components/auth/device-grant/DeviceGrant.ts b/packages/vue/src/components/auth/device-grant/DeviceGrant.ts new file mode 100644 index 00000000..d3c986fd --- /dev/null +++ b/packages/vue/src/components/auth/device-grant/DeviceGrant.ts @@ -0,0 +1,205 @@ +// Copyright 2025-2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import { + EmbeddedFlowType, + EmbeddedSignInFlowStatus, + executeEmbeddedDeviceGrantFlow, + type EmbeddedDeviceGrantFlowResponse, + type EmbeddedFlowComponent, + type EmbeddedSignInFlowRequest, +} from '@thunderid/browser'; +import {type Component, type PropType, type SetupContext, type VNode, defineComponent, h, onMounted, ref} from 'vue'; +import BaseDeviceGrant from './BaseDeviceGrant'; +import type {BaseDeviceGrantRenderProps, DeviceGrantResult} from './BaseDeviceGrant'; +import useFlowMeta from '../../../composables/useFlowMeta'; +import useI18n from '../../../composables/useI18n'; +import useThunderID from '../../../composables/useThunderID'; +import {extractErrorMessage, normalizeFlowResponse} from '../../../utils/flowTransformer'; + +export type DeviceGrantRenderProps = BaseDeviceGrantRenderProps; + +interface DeviceGrantProps { + className: string; + onComplete?: (response: EmbeddedDeviceGrantFlowResponse) => void; + onError?: (error: Error) => void; + size: 'small' | 'medium' | 'large'; + variant: 'elevated' | 'outlined' | 'flat'; +} + +const hasUserCodeInput = (components: EmbeddedFlowComponent[]): boolean => + components.some( + (component: EmbeddedFlowComponent) => + component.ref === 'user_code' || (Array.isArray(component.components) && hasUserCodeInput(component.components)), + ); + +/** Runs the application's configured Device Grant flow and exposes its state for custom renderers. */ +const DeviceGrant: Component = defineComponent({ + name: 'DeviceGrant', + props: { + className: {default: '', type: String}, + size: {default: 'medium', type: String as PropType<'small' | 'medium' | 'large'>}, + variant: {default: 'flat', type: String as PropType<'elevated' | 'outlined' | 'flat'>}, + onComplete: { + default: undefined, + type: Function as PropType<(response: EmbeddedDeviceGrantFlowResponse) => void>, + }, + onError: {default: undefined, type: Function as PropType<(error: Error) => void>}, + }, + emits: ['complete', 'error'], + setup(props: Readonly, {emit, slots}: SetupContext): () => VNode { + const {applicationId, baseUrl} = useThunderID(); + const {meta} = useFlowMeta(); + const {t} = useI18n(); + const components = ref([]); + const initialValues = ref>({}); + const additionalData = ref>({}); + const serverFieldErrors = ref(null); + const executionId = ref(''); + const challengeToken = ref(''); + const error = ref(null); + const isLoading = ref(false); + const result = ref(null); + const flowStartAttempted = ref(false); + + const applyResponse = (response: EmbeddedDeviceGrantFlowResponse): void => { + const normalized = normalizeFlowResponse(response, t, {resolveTranslations: false}, meta.value); + executionId.value = response.executionId ?? ''; + challengeToken.value = response.challengeToken ?? ''; + components.value = normalized.components ?? []; + additionalData.value = normalized.additionalData ?? {}; + serverFieldErrors.value = response.data?.fieldErrors ?? null; + error.value = response.error ? new Error(extractErrorMessage(response, t)) : null; + + if (response.flowStatus === EmbeddedSignInFlowStatus.Complete) { + result.value = 'success'; + props.onComplete?.(response); + emit('complete', response); + return; + } + if (response.flowStatus === EmbeddedSignInFlowStatus.Error) { + error.value = new Error(extractErrorMessage(response, t, 'deviceGrant.messages.failed')); + result.value = 'failure'; + props.onError?.(error.value); + emit('error', error.value); + return; + } + result.value = null; + }; + + const clearVerificationParameters = (): void => { + const url = new URL(window.location.href); + url.searchParams.delete('user_code'); + url.searchParams.delete('applicationId'); + window.history.replaceState(window.history.state, '', url.toString()); + }; + + const startFlow = async (appId: string): Promise => { + isLoading.value = true; + error.value = null; + try { + const response = await executeEmbeddedDeviceGrantFlow({ + baseUrl, + payload: { + applicationId: appId, + flowType: EmbeddedFlowType.DeviceGrant, + inputs: {}, + }, + }); + clearVerificationParameters(); + applyResponse(response); + } catch (cause: unknown) { + const flowError: Error = cause instanceof Error ? cause : new Error(t('deviceGrant.errors.continue')); + components.value = []; + additionalData.value = {}; + serverFieldErrors.value = null; + error.value = flowError; + result.value = 'failure'; + props.onError?.(flowError); + emit('error', flowError); + } finally { + isLoading.value = false; + } + }; + + const submit = async (payload: EmbeddedSignInFlowRequest, actionId?: string): Promise => { + isLoading.value = true; + error.value = null; + result.value = null; + try { + if (!executionId.value) return; + + // The shared flow Action renderer already compiles consent choices into consent_decisions. + // Remove only the temporary form fields before continuing the flow. + const inputs: Record = Object.fromEntries( + Object.entries(payload.inputs ?? {}).filter(([key]: [string, unknown]) => !key.startsWith('__consent_opt__')), + ); + if (!hasUserCodeInput(components.value)) delete inputs['user_code']; + const response = await executeEmbeddedDeviceGrantFlow({ + baseUrl, + payload: { + action: payload.action ?? actionId, + challengeToken: challengeToken.value, + executionId: executionId.value, + inputs, + }, + }); + applyResponse(response); + } catch (cause: unknown) { + const flowError: Error = cause instanceof Error ? cause : new Error(t('deviceGrant.errors.continue')); + components.value = []; + additionalData.value = {}; + serverFieldErrors.value = null; + error.value = flowError; + result.value = 'failure'; + props.onError?.(flowError); + emit('error', flowError); + } finally { + isLoading.value = false; + } + }; + + onMounted(() => { + const url = new URL(window.location.href); + const code = url.searchParams.get('user_code'); + if (code) initialValues.value = {user_code: code}; + + const applicationIdFromUrl: string | null = url.searchParams.get('applicationId'); + const appId: string | undefined = applicationIdFromUrl?.trim() ? applicationIdFromUrl : applicationId; + if (!appId) { + const flowError: Error = new Error(t('deviceGrant.errors.start')); + error.value = flowError; + result.value = 'failure'; + props.onError?.(flowError); + emit('error', flowError); + return; + } + if (flowStartAttempted.value) return; + + flowStartAttempted.value = true; + void startFlow(appId); + }); + + return (): VNode => + h( + BaseDeviceGrant, + { + className: props.className, + components: components.value, + additionalData: additionalData.value, + error: error.value, + initialValues: initialValues.value, + isLoading: isLoading.value, + onSubmit: (payload: EmbeddedSignInFlowRequest, component: EmbeddedFlowComponent) => + submit(payload, component.id), + result: result.value, + serverFieldErrors: serverFieldErrors.value, + size: props.size, + variant: props.variant, + }, + slots['default'] ? {default: slots['default']} : undefined, + ); + }, +}); + +export default DeviceGrant; diff --git a/packages/vue/src/components/auth/flow/AuthOptionFactoryCore.ts b/packages/vue/src/components/auth/flow/AuthOptionFactoryCore.ts index 65c5cb33..acc20207 100644 --- a/packages/vue/src/components/auth/flow/AuthOptionFactoryCore.ts +++ b/packages/vue/src/components/auth/flow/AuthOptionFactoryCore.ts @@ -26,6 +26,7 @@ import { import type {FetchAuthFlows, FetchUsers, FetchUserTypes} from '@thunderid/browser'; import DOMPurify from 'dompurify'; import {h, type VNode} from 'vue'; +import Consent, {getConsentOptionalKey} from './Consent'; import {createField} from './FieldFactory'; import {createVueLogger} from '../../../utils/logger'; import BaseAuthFlowSelect from '../../primitives/AuthFlowSelect/BaseAuthFlowSelect'; @@ -44,11 +45,6 @@ import MicrosoftButton from '../buttons/MicrosoftButton'; const logger: ReturnType = createVueLogger('AuthOptionFactory'); -/** - * Inline helper for consent optional attribute key (mirrors ConsentCheckboxList.getConsentOptionalKey). - */ -const getConsentOptionalKey = (purposeId: string | number, attr: string): string => `consent_${purposeId}_${attr}`; - /** * Replaces `emoji:` URIs embedded in HTML before DOMPurify sanitization. * @@ -78,6 +74,7 @@ const resolveEmojiUrisInHtml = (html: string): string => { }; type TranslationFn = (key: string, params?: Record) => string; +type TypographyColor = 'primary' | 'secondary' | 'error' | 'success' | 'warning' | 'info'; /** * Get the appropriate FieldType for an input component. @@ -119,6 +116,25 @@ const getTypographyVariant = (variant: string): any => { return variantMap[variant] || 'h3'; }; +const getTypographyColor = (color?: string): TypographyColor | undefined => { + switch (color?.toUpperCase()) { + case 'ERROR': + return 'error'; + case 'INFO': + return 'info'; + case 'PRIMARY': + return 'primary'; + case 'SECONDARY': + return 'secondary'; + case 'SUCCESS': + return 'success'; + case 'WARNING': + return 'warning'; + default: + return undefined; + } +}; + /** * Check if a button text or action matches a social provider. */ @@ -385,11 +401,14 @@ const createAuthComponentFromFlow = ( case EmbeddedFlowComponentType.Text: { const variant: any = getTypographyVariant(component.variant ?? ''); const align: string = typeof (component as any).align === 'string' ? (component as any).align : 'left'; + const color: TypographyColor | undefined = getTypographyColor(component.color); return h( Typography, { + id: component.id, key, + color, style: {marginBottom: '0.5rem', textAlign: align}, variant, }, @@ -588,9 +607,13 @@ const createAuthComponentFromFlow = ( } case EmbeddedFlowComponentType.Consent: { - // Consent component is not yet implemented in the Vue SDK. - logger.warn(`Consent component type is not yet fully supported in the Vue SDK.`); - return null; + return h(Consent, { + consentData: options.additionalData?.['consentPrompt'] as ConsentPromptData | string | undefined, + formValues, + key, + onInputChange, + t: options.t, + }); } case EmbeddedFlowComponentType.Timer: { diff --git a/packages/vue/src/components/auth/flow/Consent.css.ts b/packages/vue/src/components/auth/flow/Consent.css.ts new file mode 100644 index 00000000..584de11c --- /dev/null +++ b/packages/vue/src/components/auth/flow/Consent.css.ts @@ -0,0 +1,139 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {withVendorCSSClassPrefix} from '@thunderid/browser'; + +const CONSENT_CSS = ` +.${withVendorCSSClassPrefix('consent')} { + display: flex; + flex-direction: column; + gap: calc(var(--thunderid-spacing-unit) * 2); + margin-top: calc(var(--thunderid-spacing-unit) * 0.5); +} + +.${withVendorCSSClassPrefix('consent__purpose')} { + padding-bottom: calc(var(--thunderid-spacing-unit) * 2); +} + +.${withVendorCSSClassPrefix('consent__section')} { + margin-top: var(--thunderid-spacing-unit); +} + +.${withVendorCSSClassPrefix('consent__header')} { + display: flex; + align-items: center; + gap: calc(var(--thunderid-spacing-unit) * 0.5); + margin-bottom: calc(var(--thunderid-spacing-unit) * 1.25); +} + +.${withVendorCSSClassPrefix('consent__optional-header')} { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--thunderid-spacing-unit); + padding-right: calc(var(--thunderid-spacing-unit) * 0.5); +} + +.${withVendorCSSClassPrefix('consent__title')} { + color: var(--thunderid-color-text-primary); + font-weight: var(--thunderid-typography-fontWeight-bold); +} + +.${withVendorCSSClassPrefix('consent__info')} { + display: inline-flex; + color: var(--thunderid-color-text-secondary); + cursor: help; +} + +.${withVendorCSSClassPrefix('consent__optional-header')} > .${withVendorCSSClassPrefix('consent__header')} { + margin-bottom: calc(var(--thunderid-spacing-unit) * 1.25); +} + +.${withVendorCSSClassPrefix('consent__items')} { + display: flex; + flex-direction: column; + gap: calc(var(--thunderid-spacing-unit) * 1.25); +} + +.${withVendorCSSClassPrefix('consent__item')} { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--thunderid-spacing-unit); + min-height: 20px; +} + +.${withVendorCSSClassPrefix('consent__item-label')} { + display: flex; + align-items: center; + gap: calc(var(--thunderid-spacing-unit) * 0.75); +} + +.${withVendorCSSClassPrefix('consent__bullet')} { + width: 4px; + height: 4px; + flex: 0 0 4px; + border-radius: 50%; + background: var(--thunderid-color-text-secondary); +} + +.${withVendorCSSClassPrefix('consent__toggle-container')} { + position: relative; + display: inline-flex; + align-items: center; + cursor: pointer; +} + +.${withVendorCSSClassPrefix('consent__toggle-input')} { + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; +} + +.${withVendorCSSClassPrefix('consent__toggle-track')} { + position: relative; + display: inline-flex; + align-items: center; + width: 36px; + height: 20px; + border-radius: 9999px; + background: var(--thunderid-color-text-secondary); + opacity: 0.25; + transition: background-color 0.2s ease, opacity 0.2s ease; +} + +.${withVendorCSSClassPrefix('consent__toggle-input')}:checked + .${withVendorCSSClassPrefix('consent__toggle-track')} { + background: var(--thunderid-color-primary-main); + opacity: 1; +} + +.${withVendorCSSClassPrefix('consent__toggle-input')}:focus-visible + .${withVendorCSSClassPrefix('consent__toggle-track')} { + outline: 2px solid var(--thunderid-color-border-focus); + outline-offset: 2px; +} + +.${withVendorCSSClassPrefix('consent__toggle-thumb')} { + position: absolute; + left: 2px; + width: 16px; + height: 16px; + border-radius: 50%; + background: var(--thunderid-color-background-surface); + transition: transform 0.2s ease; +} + +.${withVendorCSSClassPrefix('consent__toggle-input')}:checked + .${withVendorCSSClassPrefix('consent__toggle-track')} .${withVendorCSSClassPrefix('consent__toggle-thumb')} { + transform: translateX(16px); +} + +.${withVendorCSSClassPrefix('consent__toggle-input')}:disabled + .${withVendorCSSClassPrefix('consent__toggle-track')} { + cursor: not-allowed; + opacity: 0.4; +} +`; + +export default CONSENT_CSS; diff --git a/packages/vue/src/components/auth/flow/Consent.ts b/packages/vue/src/components/auth/flow/Consent.ts new file mode 100644 index 00000000..a085e547 --- /dev/null +++ b/packages/vue/src/components/auth/flow/Consent.ts @@ -0,0 +1,214 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {type ConsentPurposeData, withVendorCSSClassPrefix} from '@thunderid/browser'; +import {type Component, type PropType, type SetupContext, type VNode, defineComponent, h} from 'vue'; +import {InfoIcon} from '../../primitives/Icons'; +import Typography from '../../primitives/Typography'; + +export type ConsentData = string | ConsentPurposeData[] | {purposes: ConsentPurposeData[]}; + +export interface ConsentRenderProps { + formValues: Record; + onInputChange: (name: string, value: string) => void; + purposes: ConsentPurposeData[]; +} + +export interface ConsentProps { + consentData?: ConsentData; + formValues: Record; + onInputChange: (name: string, value: string) => void; + t?: (key: string) => string; +} + +export const getConsentOptionalKey = (purposeId: string, attrName: string): string => + `__consent_opt__${purposeId}__${attrName}`; + +const isObject = (value: unknown): value is Record => typeof value === 'object' && value !== null; + +const parsePurposes = (consentData: ConsentData | undefined): ConsentPurposeData[] => { + if (!consentData) return []; + + let parsed: unknown = consentData; + if (typeof consentData === 'string') { + try { + parsed = JSON.parse(consentData); + } catch { + return []; + } + } + + const rawPurposes: unknown = Array.isArray(parsed) ? parsed : (parsed as {purposes?: unknown})?.purposes; + if (!Array.isArray(rawPurposes)) return []; + + return rawPurposes + .filter( + (purpose: unknown): purpose is Record => + isObject(purpose) && typeof purpose['purposeId'] === 'string', + ) + .map( + (purpose: Record): ConsentPurposeData => ({ + ...(purpose as unknown as ConsentPurposeData), + essential: Array.isArray(purpose['essential']) + ? purpose['essential'].filter( + (element: unknown): element is {name: string} => isObject(element) && typeof element['name'] === 'string', + ) + : [], + optional: Array.isArray(purpose['optional']) + ? purpose['optional'].filter( + (element: unknown): element is {name: string} => isObject(element) && typeof element['name'] === 'string', + ) + : [], + }), + ) + .sort( + (a: ConsentPurposeData, b: ConsentPurposeData) => + Number(b.type === 'permissions') - Number(a.type === 'permissions'), + ); +}; + +const Consent: Component = defineComponent({ + name: 'Consent', + props: { + consentData: {default: undefined, type: [String, Array, Object] as PropType}, + formValues: {required: true, type: Object as PropType>}, + onInputChange: {required: true, type: Function as PropType}, + t: {default: undefined, type: Function as PropType}, + }, + setup(props: Readonly, {slots}: SetupContext): () => VNode | null { + const translate = (key: string, fallback = ''): string => { + const value: string | undefined = props.t?.(key); + return value && value !== key ? value : fallback; + }; + + const toggle = (id: string, checked: boolean, label: string, onChange: (value: boolean) => void): VNode => + h('label', {class: withVendorCSSClassPrefix('consent__toggle-container')}, [ + h('input', { + 'aria-label': label, + checked, + class: withVendorCSSClassPrefix('consent__toggle-input'), + id, + onChange: (event: Event) => onChange((event.target as HTMLInputElement).checked), + role: 'switch', + type: 'checkbox', + }), + h('span', {class: withVendorCSSClassPrefix('consent__toggle-track')}, [ + h('span', {class: withVendorCSSClassPrefix('consent__toggle-thumb')}), + ]), + ]); + + const sectionTitle = (label: string, info: string): VNode[] => + [ + h( + Typography, + {class: withVendorCSSClassPrefix('consent__title'), component: 'span', variant: 'subtitle2'}, + () => label, + ), + info + ? h( + 'span', + {class: withVendorCSSClassPrefix('consent__info'), title: info, 'aria-label': info, role: 'img'}, + [h(InfoIcon)], + ) + : null, + ].filter((node): node is VNode => node !== null); + + return (): VNode | null => { + const purposes: ConsentPurposeData[] = parsePurposes(props.consentData); + if (purposes.length === 0) return null; + if (slots['default']) + return h('div', slots['default']({formValues: props.formValues, onInputChange: props.onInputChange, purposes})); + + return h( + 'div', + {class: withVendorCSSClassPrefix('consent')}, + purposes.map((purpose: ConsentPurposeData) => { + const permission: boolean = purpose.type === 'permissions'; + const optionalName: string = permission + ? translate('consent.optional_scope', 'Optional Permissions') + : translate('consent.optional_claims', 'Optional Attributes'); + const essentialName: string = permission + ? translate('consent.essential_scope', 'Essential Permissions') + : translate('consent.essential_claims', 'Essential Attributes'); + const allSelected: boolean = + purpose.optional.length > 0 && + purpose.optional.every( + (element) => props.formValues[getConsentOptionalKey(purpose.purposeId, element.name)] === 'true', + ); + const itemList = (elements: {name: string}[], essential: boolean): VNode => + h( + 'div', + {class: withVendorCSSClassPrefix('consent__items')}, + elements.map((element: {name: string}) => { + const key: string = getConsentOptionalKey(purpose.purposeId, element.name); + const label: string = translate(`consent.${element.name}`, element.name); + return h('div', {class: withVendorCSSClassPrefix('consent__item'), key: element.name}, [ + h('div', {class: withVendorCSSClassPrefix('consent__item-label')}, [ + h('span', {class: withVendorCSSClassPrefix('consent__bullet')}), + h(Typography, {component: 'span', variant: 'body2'}, () => label), + ]), + essential + ? h(Typography, {component: 'span', variant: 'body2'}, () => + translate('consent.required', 'Required'), + ) + : toggle( + `consent_opt_${purpose.purposeId}_${element.name}`, + props.formValues[key] === 'true', + label, + (value) => props.onInputChange(key, value ? 'true' : 'false'), + ), + ]); + }), + ); + + return h('section', {class: withVendorCSSClassPrefix('consent__purpose'), key: purpose.purposeId}, [ + purpose.essential.length > 0 + ? h('div', {class: withVendorCSSClassPrefix('consent__section')}, [ + h( + 'div', + {class: withVendorCSSClassPrefix('consent__header')}, + sectionTitle( + essentialName, + translate( + permission ? 'consent.essential_scope.info' : 'consent.essential_claims.info', + permission ? '' : 'These attributes are required for the service to function properly.', + ), + ), + ), + itemList(purpose.essential, true), + ]) + : null, + purpose.optional.length > 0 + ? h('div', {class: withVendorCSSClassPrefix('consent__section')}, [ + h('div', {class: withVendorCSSClassPrefix('consent__optional-header')}, [ + h( + 'div', + {class: withVendorCSSClassPrefix('consent__header')}, + sectionTitle( + optionalName, + translate( + permission ? 'consent.optional_scope.info' : 'consent.optional_claims.info', + permission ? '' : 'These attributes are optional and can be provided at your discretion.', + ), + ), + ), + toggle(`consent_opt_${purpose.purposeId}_all`, allSelected, optionalName, (checked: boolean) => + purpose.optional.forEach((element) => + props.onInputChange( + getConsentOptionalKey(purpose.purposeId, element.name), + checked ? 'true' : 'false', + ), + ), + ), + ]), + itemList(purpose.optional, false), + ]) + : null, + ]); + }), + ); + }; + }, +}); + +export default Consent; diff --git a/packages/vue/src/components/auth/sign-in/BaseSignIn.ts b/packages/vue/src/components/auth/sign-in/BaseSignIn.ts index 9e4b789d..5a930c5f 100644 --- a/packages/vue/src/components/auth/sign-in/BaseSignIn.ts +++ b/packages/vue/src/components/auth/sign-in/BaseSignIn.ts @@ -5,6 +5,7 @@ import { withVendorCSSClassPrefix, EmbeddedSignInFlowRequest, EmbeddedFlowComponent, + FieldError, FlowMetadataResponse, } from '@thunderid/browser'; import { @@ -60,10 +61,12 @@ export interface BaseSignInProps { error?: Error | null; errorClassName?: string; inputClassName?: string; + initialValues?: Record; isLoading?: boolean; isTimeoutDisabled?: boolean; messageClassName?: string; onSubmit?: (payload: EmbeddedSignInFlowRequest, component: EmbeddedFlowComponent) => Promise; + serverFieldErrors?: FieldError[] | null; size?: 'small' | 'medium' | 'large'; variant?: 'elevated' | 'outlined' | 'flat'; } @@ -126,6 +129,10 @@ const BaseSignIn: Component = defineComponent({ error: {default: null, type: Object as PropType}, errorClassName: {default: '', type: String}, inputClassName: {default: '', type: String}, + initialValues: { + default: (): Record => ({}), + type: Object as PropType>, + }, isLoading: {default: false, type: Boolean}, isTimeoutDisabled: {default: false, type: Boolean}, messageClassName: {default: '', type: String}, @@ -140,6 +147,7 @@ const BaseSignIn: Component = defineComponent({ (payload: EmbeddedSignInFlowRequest, component: EmbeddedFlowComponent) => Promise >, }, + serverFieldErrors: {default: null, type: Array as PropType}, size: { default: 'medium', type: String as PropType<'small' | 'medium' | 'large'>, @@ -159,10 +167,12 @@ const BaseSignIn: Component = defineComponent({ error: Error | null; errorClassName: string; inputClassName: string; + initialValues: Record; isLoading: boolean; isTimeoutDisabled: boolean; messageClassName: string; onSubmit?: (payload: EmbeddedSignInFlowRequest, component: EmbeddedFlowComponent) => Promise; + serverFieldErrors: FieldError[] | null; size: 'small' | 'medium' | 'large'; variant: 'elevated' | 'outlined' | 'flat'; }>, @@ -186,9 +196,9 @@ const BaseSignIn: Component = defineComponent({ () => props.components, (newComponents: EmbeddedFlowComponent[]) => { const fields: FieldDefinition[] = extractFormFields(newComponents || []); - const freshValues: Record = {}; + const freshValues: Record = {...props.initialValues}; fields.forEach((f: FieldDefinition) => { - freshValues[f.name] = ''; + freshValues[f.name] = props.initialValues[f.name] ?? ''; }); formValues.value = freshValues; touchedFields.value = {}; @@ -196,17 +206,33 @@ const BaseSignIn: Component = defineComponent({ {deep: false, immediate: true}, ); + watch( + () => props.initialValues, + (initialValues: Record) => { + formValues.value = {...formValues.value, ...initialValues}; + }, + {deep: true}, + ); + // Computed form errors based on current values + touched const formErrors: ComputedRef> = computed>(() => { const fields: FieldDefinition[] = extractFormFields(props.components || []); const errors: Record = {}; + (props.serverFieldErrors ?? []).forEach((fieldError: FieldError) => { + if (!(fieldError.identifier in errors)) errors[fieldError.identifier] = fieldError.message; + }); fields.forEach((field: FieldDefinition) => { const value: string = formValues.value[field.name] || ''; const isTouched: boolean = touchedFields.value[field.name] || false; - if (field.required && isTouched && (!value || value.trim() === '')) { + if (!(field.name in errors) && field.required && isTouched && (!value || value.trim() === '')) { errors[field.name] = t('validations.required.field.error') || 'This field is required'; } - if (field.type === 'EMAIL_INPUT' && value && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) { + if ( + !(field.name in errors) && + field.type === 'EMAIL_INPUT' && + value && + !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value) + ) { errors[field.name] = t('field.email.invalid') || 'Invalid email address'; } }); diff --git a/packages/vue/src/components/auth/sign-in/SignIn.ts b/packages/vue/src/components/auth/sign-in/SignIn.ts index 327a87eb..684fedf4 100644 --- a/packages/vue/src/components/auth/sign-in/SignIn.ts +++ b/packages/vue/src/components/auth/sign-in/SignIn.ts @@ -34,6 +34,7 @@ import {useOAuthCallback, type OAuthCallbackPayload} from '../../../composables/ import useFlowMeta from '../../../composables/useFlowMeta'; import useI18n from '../../../composables/useI18n'; import useThunderID from '../../../composables/useThunderID'; +import {getConsentOptionalKey} from '../flow/Consent'; import {extractErrorMessage, normalizeFlowResponse} from '../../../utils/flowTransformer'; import {initiateOAuthRedirect} from '../../../utils/oauth'; import {handlePasskeyAuthentication, handlePasskeyRegistration} from '../../../utils/passkey'; @@ -346,7 +347,7 @@ const SignIn: Component = defineComponent({ elements: [ ...(p.essential ?? []).map((e) => ({approved: !isDeny, name: e.name})), ...(p.optional ?? []).map((e) => { - const key = `__consent_opt__${p.purposeId}__${e.name}`; + const key: string = getConsentOptionalKey(p.purposeId, e.name); return getConsentElementDecision( p.purposeId, e, diff --git a/packages/vue/src/components/primitives/Typography/Typography.ts b/packages/vue/src/components/primitives/Typography/Typography.ts index 920a8865..510e4cff 100644 --- a/packages/vue/src/components/primitives/Typography/Typography.ts +++ b/packages/vue/src/components/primitives/Typography/Typography.ts @@ -5,6 +5,17 @@ import {withVendorCSSClassPrefix} from '@thunderid/browser'; import {type Component, type SetupContext, type VNode, defineComponent, h, type PropType} from 'vue'; type TypographyProps = Readonly<{ + color: + | 'primary' + | 'secondary' + | 'error' + | 'success' + | 'warning' + | 'info' + | 'textPrimary' + | 'textSecondary' + | 'inherit' + | undefined; component: string | undefined; variant: | 'h1' @@ -24,6 +35,12 @@ type TypographyProps = Readonly<{ const Typography: Component = defineComponent({ name: 'Typography', props: { + color: { + default: undefined, + type: String as PropType< + 'primary' | 'secondary' | 'error' | 'success' | 'warning' | 'info' | 'textPrimary' | 'textSecondary' | 'inherit' + >, + }, component: { default: undefined, type: String as PropType, @@ -52,7 +69,22 @@ const Typography: Component = defineComponent({ subtitle2: 'h6', }; - const tag: string = props.component || tagMap[props.variant] || 'p'; + const tag: string = props.component ?? tagMap[props.variant] ?? 'p'; + const colorVar: Record, string> = { + error: 'var(--thunderid-color-error-main)', + info: 'var(--thunderid-color-info-main)', + inherit: 'inherit', + primary: 'var(--thunderid-color-primary-main)', + secondary: 'var(--thunderid-color-secondary-main)', + success: 'var(--thunderid-color-success-main)', + textPrimary: 'var(--thunderid-color-text-primary)', + textSecondary: 'var(--thunderid-color-text-secondary)', + warning: 'var(--thunderid-color-warning-main)', + }; + const style: Record = { + ...(attrs.style as Record | undefined), + ...(props.color ? {color: colorVar[props.color]} : {}), + }; return h( tag, @@ -64,7 +96,7 @@ const Typography: Component = defineComponent({ ] .filter(Boolean) .join(' '), - style: attrs.style, + style, }, slots['default']?.(), ); diff --git a/packages/vue/src/index.ts b/packages/vue/src/index.ts index 501c258c..b009245a 100644 --- a/packages/vue/src/index.ts +++ b/packages/vue/src/index.ts @@ -117,6 +117,16 @@ export {default as SignIn} from './components/auth/sign-in/SignIn'; export type {SignInRenderProps} from './components/auth/sign-in/SignIn'; export {default as BaseSignIn} from './components/auth/sign-in/BaseSignIn'; export type {BaseSignInRenderProps, BaseSignInProps} from './components/auth/sign-in/BaseSignIn'; +export {default as Consent} from './components/auth/flow/Consent'; +export type {ConsentData, ConsentProps, ConsentRenderProps} from './components/auth/flow/Consent'; +export {default as DeviceGrant} from './components/auth/device-grant/DeviceGrant'; +export type { + BaseDeviceGrantProps, + BaseDeviceGrantRenderProps, + DeviceGrantResult, +} from './components/auth/device-grant/BaseDeviceGrant'; +export type {DeviceGrantRenderProps} from './components/auth/device-grant/DeviceGrant'; +export {default as BaseDeviceGrant} from './components/auth/device-grant/BaseDeviceGrant'; export {default as SignUp} from './components/auth/sign-up/SignUp'; export type {SignUpRenderProps} from './components/auth/sign-up/SignUp'; export {default as BaseSignUp} from './components/auth/sign-up/BaseSignUp'; diff --git a/packages/vue/src/styles/injectStyles.ts b/packages/vue/src/styles/injectStyles.ts index 93d37a06..fefb2fbf 100644 --- a/packages/vue/src/styles/injectStyles.ts +++ b/packages/vue/src/styles/injectStyles.ts @@ -28,6 +28,8 @@ import CHANGE_CREDENTIAL_CSS from '../components/account/change-credential/Chang import USER_DROPDOWN_CSS from '../components/account/user-dropdown/UserDropdown.css'; import USER_PROFILE_CSS from '../components/account/user-profile/UserProfile.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'; import BASE_SIGN_IN_CSS from '../components/auth/sign-in/BaseSignIn.css'; import ATTRIBUTION_CSS from '../components/common/attribution/Attribution.css'; import LANGUAGE_SWITCHER_CSS from '../components/common/language-switcher/LanguageSwitcher.css'; @@ -78,6 +80,8 @@ const STYLES: string = [ DATA_TABLE_CSS, // Auth BASE_SIGN_IN_CSS, + CONSENT_CSS, + BASE_DEVICE_GRANT_CSS, // Presentation CHANGE_CREDENTIAL_CSS, LANGUAGE_SWITCHER_CSS, diff --git a/playgrounds/nextjs/src/explorer/stories.tsx b/playgrounds/nextjs/src/explorer/stories.tsx index 37a3422a..07646a90 100644 --- a/playgrounds/nextjs/src/explorer/stories.tsx +++ b/playgrounds/nextjs/src/explorer/stories.tsx @@ -6,6 +6,7 @@ import { AuthFlowSelect, ChangeCredential, + DeviceGrant, SignedIn, SignedOut, SignIn, @@ -224,6 +225,43 @@ export const COMPONENTS: ComponentDef[] = [ {args: {children: '', source: 'live'}, id: 'live', name: 'Live (signs out)'}, ], }, + { + code: (args: Args) => ({ + imports: ['DeviceGrant'], + jsx: jsx('DeviceGrant', args, { + defaults: {size: 'medium', variant: 'default'}, + extra: ['onComplete={handleComplete}', 'onError={handleError}'], + }), + }), + controls: [ + {control: SIZE, description: 'Spacing and control size.', name: 'size', type: "'small' | 'medium' | 'large'"}, + { + control: VARIANT, + description: 'Card variant of the flow container.', + name: 'variant', + type: 'CardVariant', + }, + ], + defaults: {size: 'medium', variant: 'default'}, + group: 'Auth flows', + id: 'devicegrant', + live: true, + name: 'DeviceGrant', + render: (args: Args, action: Action) => ( + + action('onComplete')} + onError={(error: Error) => action('onError', error.message)} + /> + + ), + stories: [ + {id: 'default', name: 'Default'}, + {args: {size: 'small', variant: 'elevated'}, id: 'compact', name: 'Compact'}, + ], + }, { code: (args: Args) => ({ imports: ['SignIn'], diff --git a/playgrounds/nuxt/lib/stories.ts b/playgrounds/nuxt/lib/stories.ts index ba39d141..e7583cf9 100644 --- a/playgrounds/nuxt/lib/stories.ts +++ b/playgrounds/nuxt/lib/stories.ts @@ -29,6 +29,7 @@ import {getDisplayName} from './user'; import { AuthFlowSelect, ChangeCredential, + DeviceGrant, Loading, SignedIn, SignedOut, @@ -572,6 +573,43 @@ export const COMPONENTS: ComponentDef[] = [ {args: {provider: 'FacebookButton'}, id: 'facebook', name: 'Facebook'}, ], }, + { + code: (args: Args) => ({ + imports: [], + template: tag('DeviceGrant', args, { + defaults: {size: 'medium', variant: 'flat'}, + extra: ['@complete="handleComplete"', '@error="handleError"'], + }), + }), + controls: [ + {control: SIZE, description: 'Spacing and control size.', name: 'size', type: "'small' | 'medium' | 'large'"}, + { + control: CARD_VARIANT, + description: 'Card variant of the flow container.', + name: 'variant', + type: "'elevated' | 'outlined' | 'flat'", + }, + ], + defaults: {size: 'medium', variant: 'flat'}, + group: 'Auth flows', + id: 'devicegrant', + live: true, + name: 'DeviceGrant', + render: (args: Args, action: Action) => + narrow( + 400, + h(DeviceGrant, { + onComplete: () => action('complete'), + onError: (error: Error) => action('error', error.message), + size: str(args, 'size'), + variant: str(args, 'variant'), + }), + ), + stories: [ + {id: 'default', name: 'Default'}, + {args: {size: 'small', variant: 'elevated'}, id: 'compact', name: 'Compact'}, + ], + }, { code: (args: Args) => ({ imports: [], diff --git a/playgrounds/react/src/pages/explorer/stories.tsx b/playgrounds/react/src/pages/explorer/stories.tsx index 06b24597..92ec9740 100644 --- a/playgrounds/react/src/pages/explorer/stories.tsx +++ b/playgrounds/react/src/pages/explorer/stories.tsx @@ -12,6 +12,7 @@ import { Button, Card, Checkbox, + DeviceGrant, Divider, FacebookButton, FlowTimer, @@ -693,6 +694,43 @@ export const COMPONENTS: ComponentDef[] = [ {args: {provider: 'SignInWithEthereumButton'}, id: 'ethereum', name: 'Ethereum'}, ], }, + { + code: (args: Args) => ({ + imports: ['DeviceGrant'], + jsx: jsx('DeviceGrant', args, { + defaults: {size: 'medium', variant: 'default'}, + extra: ['onComplete={handleComplete}', 'onError={handleError}'], + }), + }), + controls: [ + {control: SIZE, description: 'Spacing and control size.', name: 'size', type: "'small' | 'medium' | 'large'"}, + { + control: ['default', 'outlined', 'elevated'], + description: 'Card variant of the flow container.', + name: 'variant', + type: 'CardVariant', + }, + ], + defaults: {size: 'medium', variant: 'default'}, + group: 'Auth flows', + id: 'device-grant', + live: true, + name: 'DeviceGrant', + render: (args: Args, action: Action) => ( + + action('onComplete')} + onError={(error: Error) => action('onError', error.message)} + /> + + ), + stories: [ + {id: 'default', name: 'Default'}, + {args: {size: 'small', variant: 'elevated'}, id: 'compact', name: 'Compact'}, + ], + }, { code: (args: Args) => ({ imports: ['SignIn'], diff --git a/playgrounds/vue/src/pages/explorer/stories.ts b/playgrounds/vue/src/pages/explorer/stories.ts index 20f57ac7..3b3f49ef 100644 --- a/playgrounds/vue/src/pages/explorer/stories.ts +++ b/playgrounds/vue/src/pages/explorer/stories.ts @@ -16,6 +16,7 @@ import { Checkbox, CopyableText, DatePicker, + DeviceGrant, Divider, FacebookButton, GitHubButton, @@ -807,6 +808,44 @@ export const COMPONENTS: ComponentDef[] = [ {args: {provider: 'FacebookButton'}, id: 'facebook', name: 'Facebook'}, ], }, + { + code: (args: Args) => ({ + imports: ['DeviceGrant'], + script: [...handler('handleComplete'), ...handler('handleError')], + template: tpl('DeviceGrant', args, { + defaults: {size: 'medium', variant: 'flat'}, + extra: ['@complete="handleComplete"', '@error="handleError"'], + }), + }), + controls: [ + {control: SIZE, description: 'Spacing and control size.', name: 'size', type: "'small' | 'medium' | 'large'"}, + { + control: CARD_VARIANT, + description: 'Card variant of the flow container.', + name: 'variant', + type: "'elevated' | 'outlined' | 'flat'", + }, + ], + defaults: {size: 'medium', variant: 'flat'}, + group: 'Auth flows', + id: 'devicegrant', + live: true, + name: 'DeviceGrant', + render: (args: Args, action: Action) => + narrow( + 400, + h(DeviceGrant, { + onComplete: () => action('complete'), + onError: (error: Error) => action('error', error.message), + size: str(args, 'size') as 'small' | 'medium' | 'large', + variant: str(args, 'variant') as 'elevated' | 'outlined' | 'flat', + }), + ), + stories: [ + {id: 'default', name: 'Default'}, + {args: {size: 'small', variant: 'elevated'}, id: 'compact', name: 'Compact'}, + ], + }, { code: (args: Args) => ({ imports: ['SignIn'],