diff --git a/packages/browser/src/ThunderIDBrowserClient.ts b/packages/browser/src/ThunderIDBrowserClient.ts index a15a2c8e..4a588835 100644 --- a/packages/browser/src/ThunderIDBrowserClient.ts +++ b/packages/browser/src/ThunderIDBrowserClient.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { + AuthorizeRequestParamValue, ThunderIDJavaScriptClient, ThunderIDAuthException, User, @@ -316,7 +317,7 @@ class ThunderIDBrowserClient extends ThunderIDJavaScriptC } public override async signInSilently( - additionalParams?: Record, + additionalParams?: Record, tokenRequestConfig?: {params: Record}, ): Promise { await this.isInitialized(); @@ -335,7 +336,7 @@ class ThunderIDBrowserClient extends ThunderIDJavaScriptC } private async _signInSilentlyInternal( - additionalParams?: Record, + additionalParams?: Record, tokenRequestConfig?: {params: Record}, ): Promise { const {session} = await this.getStorageManager().getConfigData(); @@ -717,7 +718,9 @@ class ThunderIDBrowserClient extends ThunderIDJavaScriptC } } - private async _constructSilentSignInUrl(additionalParams: Record = {}): Promise { + private async _constructSilentSignInUrl( + additionalParams: Record = {}, + ): Promise { const sm = this.getStorageManager(); const config = await (sm as any).getConfigData(); diff --git a/packages/browser/src/__tests__/ThunderIDBrowserClient.authorizationDetails.test.ts b/packages/browser/src/__tests__/ThunderIDBrowserClient.authorizationDetails.test.ts new file mode 100644 index 00000000..3f7230c8 --- /dev/null +++ b/packages/browser/src/__tests__/ThunderIDBrowserClient.authorizationDetails.test.ts @@ -0,0 +1,92 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {AuthorizationDetail, ExtendedAuthorizeRequestUrlParams, StorageManager} from '@thunderid/javascript'; +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {BrowserAuthConfig} from '../models/BrowserConfig'; +import ThunderIDBrowserClient from '../ThunderIDBrowserClient'; +import SPAUtils from '../utils/SPAUtils'; + +const AUTHORIZE = 'https://example.com/oauth2/authorize'; +const PAYMENT: AuthorizationDetail = {actions: ['initiate'], instructedAmount: {amount: '20'}, type: 'payment'}; +const ACCOUNT: AuthorizationDetail = {type: 'account_information'}; + +/** Records every authorize URL the client builds, since signing in navigates away from the page. */ +class RecordingClient extends ThunderIDBrowserClient { + public signInUrls: string[] = []; + + public storage(): StorageManager { + return this.getStorageManager(); + } + + protected override async getSignInUrl( + requestConfig?: ExtendedAuthorizeRequestUrlParams, + userId?: string, + ): Promise { + const url: string = await super.getSignInUrl(requestConfig, userId); + this.signInUrls.push(url); + + return url; + } +} + +const createClient = async (config: Partial = {}): Promise => { + const client = new RecordingClient(); + await client.initialize({baseUrl: 'https://example.com', clientId: 'test-client', ...config}); + await client.storage().setOIDCProviderMetaData({ + authorization_endpoint: AUTHORIZE, + token_endpoint: 'https://example.com/token', + }); + await client.storage().setTemporaryDataParameter('op_config_initiated', true); + + return client; +}; + +const searchParamsOf = (client: RecordingClient): URLSearchParams => new URL(client.signInUrls[0]).searchParams; + +const authorizationDetailsOf = (client: RecordingClient): unknown => + JSON.parse(searchParamsOf(client).get('authorization_details') ?? 'null'); + +describe('ThunderIDBrowserClient authorization details', () => { + beforeEach(() => { + vi.spyOn(SPAUtils, 'waitTillPageRedirect').mockResolvedValue(); + // jsdom does not implement the navigation that signIn starts. + vi.spyOn(console, 'error').mockImplementation(() => undefined); + }); + + afterEach(() => { + vi.restoreAllMocks(); + sessionStorage.clear(); + document.body.innerHTML = ''; + }); + + it('sends the authorization details of a sign-in call on the authorize request', async () => { + const client = await createClient({authorizationDetails: [PAYMENT]}); + + await client.signIn({authorizationDetails: [ACCOUNT]}); + + expect(authorizationDetailsOf(client)).toEqual([ACCOUNT]); + expect(searchParamsOf(client).has('authorizationDetails')).toBe(false); + expect(searchParamsOf(client).get('code_challenge')).toBeTruthy(); + }); + + it('sends the configured authorization details on the authorize request', async () => { + const client = await createClient({authorizationDetails: [PAYMENT]}); + + await client.signIn(); + + expect(authorizationDetailsOf(client)).toEqual([PAYMENT]); + }); + + it('sends the authorization details of a silent sign-in call on its authorize request', async () => { + const client = await createClient({ + authorizationDetails: [PAYMENT], + session: {silentSignIn: {timeout: 1}}, + }); + + await expect(client.signInSilently({authorizationDetails: [ACCOUNT]})).resolves.toBe(false); + + expect(authorizationDetailsOf(client)).toEqual([ACCOUNT]); + expect(searchParamsOf(client).get('prompt')).toBe('none'); + }); +}); diff --git a/packages/browser/src/utils/AuthenticationHelper.ts b/packages/browser/src/utils/AuthenticationHelper.ts index 73bd87bd..12848339 100644 --- a/packages/browser/src/utils/AuthenticationHelper.ts +++ b/packages/browser/src/utils/AuthenticationHelper.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { + AuthorizeRequestParamValue, ThunderIDAuthException, User, IsomorphicCrypto, @@ -432,13 +433,13 @@ class AuthenticationHelper { * @returns The user session, or `false` if the user is not signed in. */ public async signInSilently( - constructSilentSignInUrl: (additionalParams?: Record) => Promise, + constructSilentSignInUrl: (additionalParams?: Record) => Promise, requestAccessToken: ( authzCode: string, state: string, tokenRequestConfig?: {params: Record}, ) => Promise, - additionalParams?: Record, + additionalParams?: Record, tokenRequestConfig?: {params: Record}, timeout: number = SILENT_SIGN_IN_TIMEOUT, ): Promise { diff --git a/packages/express/src/ThunderIDExpressClient.ts b/packages/express/src/ThunderIDExpressClient.ts index 8200b7e0..82b9483e 100644 --- a/packages/express/src/ThunderIDExpressClient.ts +++ b/packages/express/src/ThunderIDExpressClient.ts @@ -4,6 +4,7 @@ import { ThunderIDNodeClient, ThunderIDAuthException, + ExtendedAuthorizeRequestUrlParams, Storage, TokenResponse, User, @@ -59,7 +60,7 @@ class ThunderIDExpressClient, + signInConfig?: ExtendedAuthorizeRequestUrlParams, ): Promise { if (hasErrorInURL(req.originalUrl)) { return Promise.reject( diff --git a/packages/express/src/__tests__/authorizationDetails.test.ts b/packages/express/src/__tests__/authorizationDetails.test.ts new file mode 100644 index 00000000..e245302e --- /dev/null +++ b/packages/express/src/__tests__/authorizationDetails.test.ts @@ -0,0 +1,144 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {AuthorizationDetail, TokenResponse} from '@thunderid/node'; +import express from 'express'; +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {handleSignIn, thunderID} from '../middleware/authentication'; +import {ThunderIDExpressConfig} from '../models/config'; +import ThunderIDExpressClient from '../ThunderIDExpressClient'; + +const BASE_URL = 'https://auth.example.com'; + +const AUTHORIZATION_DETAILS: AuthorizationDetail[] = [ + {actions: ['read'], locations: ['https://api.example.com/accounts'], type: 'account'}, +]; + +const encode = (part: Record): string => Buffer.from(JSON.stringify(part)).toString('base64url'); + +const ID_TOKEN = `${encode({alg: 'none'})}.${encode({sid: 'server-session-1', sub: 'user-1'})}.`; + +interface MockResponse { + cookie: ReturnType; + end: ReturnType; + redirect: ReturnType; + status: ReturnType; +} + +describe('authorization details through the Express middleware', () => { + let fetchMock: ReturnType; + let client: ThunderIDExpressClient; + + const newResponse = (): MockResponse => { + const res: MockResponse = {cookie: vi.fn(), end: vi.fn(), redirect: vi.fn(), status: vi.fn()}; + + res.status.mockReturnValue(res); + + return res; + }; + + const newRequest = (query: Record = {}, cookies: Record = {}): express.Request => + ({ + cookies, + get: () => 'app.example.com', + originalUrl: '/login', + protocol: 'https', + query, + }) as unknown as express.Request; + + // Mounts `thunderID()` and then `handleSignIn()` for one request, as an Express app would. + const signIn = async ( + config: Partial, + req: express.Request, + res: MockResponse, + ): Promise => { + const middleware: express.RequestHandler = thunderID({ + afterSignInUrl: 'https://app.example.com/login', + baseUrl: BASE_URL, + clientId: 'test-client', + clientSecret: 'test-secret', + discovery: {wellKnown: {enabled: false}}, + tokenValidation: {idToken: {validate: false}}, + ...config, + } as ThunderIDExpressConfig); + + await middleware(req, res as unknown as express.Response, vi.fn()); + + client = (req as express.Request & {thunderIDAuth: ThunderIDExpressClient}).thunderIDAuth; + + await handleSignIn()(req, res as unknown as express.Response, vi.fn()); + }; + + const redirectedTo = (res: MockResponse): URL => new URL(res.redirect.mock.calls[0][0] as string); + + beforeEach(() => { + fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('sends the authorization details of the sign-in options on the authorize request', async () => { + const res = newResponse(); + + await signIn({signInOptions: {authorizationDetails: AUTHORIZATION_DETAILS}}, newRequest(), res); + + const authorizeUrl = redirectedTo(res); + + expect(authorizeUrl.origin + authorizeUrl.pathname).toBe(`${BASE_URL}/oauth2/authorize`); + expect(JSON.parse(authorizeUrl.searchParams.get('authorization_details')!)).toEqual(AUTHORIZATION_DETAILS); + }); + + it('sends the configured authorization details on the authorize request', async () => { + const res = newResponse(); + + await signIn({authorizationDetails: AUTHORIZATION_DETAILS}, newRequest(), res); + + expect(JSON.parse(redirectedTo(res).searchParams.get('authorization_details')!)).toEqual(AUTHORIZATION_DETAILS); + }); + + it('hands the granted authorization details to onSignIn', async () => { + const onSignIn = vi.fn(); + const config: Partial = { + onSignIn, + signInOptions: {authorizationDetails: AUTHORIZATION_DETAILS}, + }; + const start = newResponse(); + + await signIn(config, newRequest(), start); + + const [cookieName, sessionId] = start.cookie.mock.calls[0] as [string, string]; + const state = redirectedTo(start).searchParams.get('state')!; + + fetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + access_token: 'access-1', + authorization_details: AUTHORIZATION_DETAILS, + expires_in: '3600', + id_token: ID_TOKEN, + scope: 'openid', + token_type: 'Bearer', + }), + {headers: {'Content-Type': 'application/json'}, status: 200}, + ), + ); + + const callback = newResponse(); + + await handleSignIn()( + Object.assign(newRequest({code: 'auth-code', state}, {[cookieName]: sessionId}), {thunderIDAuth: client}), + callback as unknown as express.Response, + vi.fn(), + ); + + expect(onSignIn).toHaveBeenCalledTimes(1); + + const tokenResponse = onSignIn.mock.calls[0][1] as TokenResponse; + + expect(tokenResponse.accessToken).toBe('access-1'); + expect(tokenResponse.authorizationDetails).toEqual(AUTHORIZATION_DETAILS); + }); +}); diff --git a/packages/javascript/src/ThunderIDJavaScriptClient.ts b/packages/javascript/src/ThunderIDJavaScriptClient.ts index 2293c800..35a58e99 100644 --- a/packages/javascript/src/ThunderIDJavaScriptClient.ts +++ b/packages/javascript/src/ThunderIDJavaScriptClient.ts @@ -557,6 +557,7 @@ class ThunderIDJavaScriptClient implements ThunderIDClient { const authRequestConfig: ExtendedAuthorizeRequestUrlParams = {...requestConfig}; delete authRequestConfig?.forceInit; + delete authRequestConfig?.authorizationDetails; const buildSignInUrl = async (): Promise => { const authorizeEndpoint: string = (await this.storageManager.getOIDCProviderMetaDataParameter( @@ -593,6 +594,7 @@ class ThunderIDJavaScriptClient implements ThunderIDClient { const authorizeRequestParams: Map = getAuthorizeRequestUrlParams( Object.fromEntries( Object.entries({ + authorizationDetails: requestConfig?.authorizationDetails ?? configData.authorizationDetails, clientId: configData.clientId ?? '', codeChallenge, codeChallengeMethod: PKCEConstants.DEFAULT_CODE_CHALLENGE_METHOD, @@ -843,6 +845,7 @@ class ThunderIDJavaScriptClient implements ThunderIDClient { const sessionData: SessionData = await this.storageManager.getSessionData(userId); return { + authorizationDetails: sessionData?.authorization_details, scopes: sessionData?.scope?.split(' '), }; } @@ -950,6 +953,10 @@ class ThunderIDJavaScriptClient implements ThunderIDClient { body.set('scope', scope); } + if (configData.authorizationDetails?.length) { + body.set('authorization_details', JSON.stringify(configData.authorizationDetails)); + } + if (configData.tokenRequest?.params) { Object.entries(configData.tokenRequest.params).forEach(([key, value]: [string, unknown]) => { body.set(key, value as string); @@ -998,6 +1005,7 @@ class ThunderIDJavaScriptClient implements ThunderIDClient { return { accessToken: parsedResponse.access_token, + authorizationDetails: parsedResponse.authorization_details, createdAt: parsedResponse.created_at, expiresIn: parsedResponse.expires_in, idToken: parsedResponse.id_token ?? '', @@ -1135,6 +1143,8 @@ class ThunderIDJavaScriptClient implements ThunderIDClient { if (options.bindingMessage) body.set('binding_message', options.bindingMessage); if (options.acrValues) body.set('acr_values', options.acrValues); if (options.requestedExpiry !== undefined) body.set('requested_expiry', String(options.requestedExpiry)); + const authorizationDetails = options.authorizationDetails ?? configData.authorizationDetails; + if (authorizationDetails?.length) body.set('authorization_details', JSON.stringify(authorizationDetails)); const headers: Record = { Accept: 'application/json', diff --git a/packages/javascript/src/__tests__/AuthenticationHelper.test.ts b/packages/javascript/src/__tests__/AuthenticationHelper.test.ts index 70c9b518..8d7d3783 100644 --- a/packages/javascript/src/__tests__/AuthenticationHelper.test.ts +++ b/packages/javascript/src/__tests__/AuthenticationHelper.test.ts @@ -83,4 +83,46 @@ describe('AuthenticationHelper', () => { }); }); }); + + describe('handleTokenResponse()', () => { + const tokenResponse = (body: Record): Response => + ({json: () => Promise.resolve(body), ok: true, status: 200, statusText: 'OK'}) as unknown as Response; + + const TOKEN_BODY = { + access_token: 'access-token', + expires_in: '3600', + refresh_token: 'refresh-token', + scope: 'openid', + token_type: 'Bearer', + }; + + it('returns the authorization details the server granted', async () => { + const granted = [{actions: ['initiate'], instructedAmount: {amount: '20', currency: 'EUR'}, type: 'payment'}]; + + const response = await helper.handleTokenResponse(tokenResponse({...TOKEN_BODY, authorization_details: granted})); + + expect(response.authorizationDetails).toEqual(granted); + expect(response.accessToken).toBe('access-token'); + }); + + it('keeps the granted authorization details in the stored session', async () => { + const storageManager = new StorageManager('test-instance', new MemoryStore()); + await storageManager.setConfigData({baseUrl: BASE_URL, clientId: 'test-client'}); + const sessionHelper = new AuthenticationHelper( + storageManager, + {} as unknown as IsomorphicCrypto, + ); + const granted = [{type: 'account_information'}]; + + await sessionHelper.handleTokenResponse(tokenResponse({...TOKEN_BODY, authorization_details: granted})); + + expect((await storageManager.getSessionData()).authorization_details).toEqual(granted); + }); + + it('leaves the authorization details out when the server granted none', async () => { + const response = await helper.handleTokenResponse(tokenResponse(TOKEN_BODY)); + + expect(response.authorizationDetails).toBeUndefined(); + }); + }); }); diff --git a/packages/javascript/src/__tests__/ThunderIDJavaScriptClient.test.ts b/packages/javascript/src/__tests__/ThunderIDJavaScriptClient.test.ts index 426ce73f..3a5f5452 100644 --- a/packages/javascript/src/__tests__/ThunderIDJavaScriptClient.test.ts +++ b/packages/javascript/src/__tests__/ThunderIDJavaScriptClient.test.ts @@ -2,12 +2,21 @@ // SPDX-License-Identifier: Apache-2.0 import {describe, expect, it, vi, beforeEach, afterEach} from 'vitest'; +import type {Config} from '../models/config'; +import type {Crypto} from '../models/crypto'; +import type {AuthorizationDetail, ExtendedAuthorizeRequestUrlParams} from '../models/oauth-request'; +import type {SessionData, UserSession} from '../models/session'; import type {Storage} from '../models/store'; +import type {TokenResponse} from '../models/token'; +import type StorageManager from '../StorageManager'; import ThunderIDJavaScriptClient from '../ThunderIDJavaScriptClient'; vi.mock('../IsomorphicCrypto', () => ({ IsomorphicCrypto: class MockIsomorphicCrypto { constructor(_cryptoUtils: unknown) {} + generateState(): string { + return 'randomstate'; + } }, })); @@ -255,6 +264,52 @@ describe('ThunderIDJavaScriptClient', () => { const body: URLSearchParams = init.body; expect(body.get('client_secret')).toBe('test-secret'); }); + + it('should send authorization details, preferring the per-call option over the configuration', async () => { + const configured = [{type: 'payment_initiation', instructedAmount: {amount: 45, currency: 'EUR'}}]; + const requested = [{type: 'payment_initiation', instructedAmount: {amount: 20, currency: 'EUR'}}]; + const client = new ThunderIDJavaScriptClient(store, {} as any); + await client.initialize({...BASE_CONFIG, authorizationDetails: configured}); + const sm = (client as any).storageManager; + await sm.setOIDCProviderMetaData(OIDC_META); + await sm.setTemporaryDataParameter('op_config_initiated', true); + const response = {auth_req_id: 'req-rar', expires_in: 120, interval: 5}; + const fetchMock = vi.fn().mockResolvedValue({ + json: () => Promise.resolve(response), + text: () => Promise.resolve(JSON.stringify(response)), + ok: true, + status: 200, + statusText: 'OK', + }); + vi.stubGlobal('fetch', fetchMock); + + await client.initiateCIBA({loginHint: 'user@example.com'}); + await client.initiateCIBA({authorizationDetails: requested, loginHint: 'user@example.com'}); + + expect(JSON.parse((fetchMock.mock.calls[0][1].body as URLSearchParams).get('authorization_details')!)).toEqual( + configured, + ); + expect(JSON.parse((fetchMock.mock.calls[1][1].body as URLSearchParams).get('authorization_details')!)).toEqual( + requested, + ); + }); + + it('should not send authorization details when none are requested', async () => { + const client = await initClientWithOIDC(store); + const response = {auth_req_id: 'req-none', expires_in: 120, interval: 5}; + const fetchMock = vi.fn().mockResolvedValueOnce({ + json: () => Promise.resolve(response), + text: () => Promise.resolve(JSON.stringify(response)), + ok: true, + status: 200, + statusText: 'OK', + }); + vi.stubGlobal('fetch', fetchMock); + + await client.initiateCIBA({loginHint: 'user@example.com'}); + + expect((fetchMock.mock.calls[0][1].body as URLSearchParams).has('authorization_details')).toBe(false); + }); }); describe('pollCIBA()', () => { @@ -422,6 +477,102 @@ describe('ThunderIDJavaScriptClient', () => { }); }); + describe('authorization details', () => { + const AUTHORIZE = 'https://example.com/oauth2/authorize'; + const PAYMENT: AuthorizationDetail = { + actions: ['initiate'], + instructedAmount: {amount: '20', currency: 'EUR'}, + type: 'payment', + }; + const ACCOUNT: AuthorizationDetail = {type: 'account_information'}; + + class AuthorizationDetailsClient extends ThunderIDJavaScriptClient { + public buildSignInUrl(requestConfig?: ExtendedAuthorizeRequestUrlParams): Promise { + return this.getSignInUrl(requestConfig); + } + + public fetchClientCredentialsToken(): Promise { + return this.requestClientCredentialsToken(); + } + + public readUserSession(): Promise { + return this.getUserSession(); + } + + public storage(): StorageManager { + return this.getStorageManager(); + } + } + + async function initWith(overrides: Partial): Promise { + const client = new AuthorizationDetailsClient(store, {} as Crypto); + await client.initialize({ + afterSignInUrl: 'https://app.example.com/callback', + baseUrl: 'https://example.com', + clientId: 'test-client', + clientSecret: 'test-secret', + enablePKCE: false, + ...overrides, + }); + await client.storage().setOIDCProviderMetaData({...OIDC_META, authorization_endpoint: AUTHORIZE}); + await client.storage().setTemporaryDataParameter('op_config_initiated', true); + return client; + } + + const tokenRequestBody = (): URLSearchParams => vi.mocked(fetch).mock.calls[0][1]!.body as URLSearchParams; + + const authorizationDetailsOf = (url: string): unknown => + JSON.parse(new URL(url).searchParams.get('authorization_details') ?? 'null'); + + it('sends the configured authorization details JSON-encoded on the authorize request', async () => { + const client = await initWith({authorizationDetails: [PAYMENT]}); + + expect(authorizationDetailsOf(await client.buildSignInUrl())).toEqual([PAYMENT]); + }); + + it('lets the authorization details of a sign-in call take precedence over the configured ones', async () => { + const client = await initWith({authorizationDetails: [PAYMENT]}); + + const url = await client.buildSignInUrl({authorizationDetails: [ACCOUNT]}); + + expect(authorizationDetailsOf(url)).toEqual([ACCOUNT]); + expect(new URL(url).searchParams.has('authorizationDetails')).toBe(false); + }); + + it('sends no authorization details when none are configured or requested', async () => { + const client = await initWith({}); + + expect(new URL(await client.buildSignInUrl()).searchParams.has('authorization_details')).toBe(false); + }); + + it('sends the configured authorization details on a client credentials token request and returns the granted ones', async () => { + const client = await initWith({authorizationDetails: [PAYMENT], grantType: 'client_credentials'}); + mockFetchOnce({access_token: 'm2m-token', authorization_details: [PAYMENT], expires_in: '3600'}); + + const response = await client.fetchClientCredentialsToken(); + + expect(JSON.parse(tokenRequestBody().get('authorization_details') ?? 'null')).toEqual([PAYMENT]); + expect(response.authorizationDetails).toEqual([PAYMENT]); + }); + + it('sends no authorization details on a client credentials token request when none are configured', async () => { + const client = await initWith({grantType: 'client_credentials'}); + mockFetchOnce({access_token: 'm2m-token', expires_in: '3600'}); + + const response = await client.fetchClientCredentialsToken(); + + expect(tokenRequestBody().has('authorization_details')).toBe(false); + expect(response.authorizationDetails).toBeUndefined(); + }); + + it('exposes the granted authorization details on the user session', async () => { + const client = await initWith({}); + await client.storage().setSessionData({authorization_details: [ACCOUNT], scope: 'openid'} as SessionData); + + expect(await client.readUserSession()).toEqual({authorizationDetails: [ACCOUNT], scopes: ['openid']}); + }); + }); + describe('getSignOutUrl()', () => { const END_SESSION = 'https://example.com/oauth2/logout'; diff --git a/packages/javascript/src/i18n/models/i18n.ts b/packages/javascript/src/i18n/models/i18n.ts index bd400aba..53ff69dd 100644 --- a/packages/javascript/src/i18n/models/i18n.ts +++ b/packages/javascript/src/i18n/models/i18n.ts @@ -185,6 +185,20 @@ export interface I18nTranslations { 'consent.essential_claims.info': string; 'consent.optional_claims.info': string; 'consent.authorize_scope.info': string; + 'consent.authorization_details.approve': string; + 'consent.authorization_details.show_more': string; + 'consent.authorization_details.show_less': string; + 'consent.authorization_details.more_items': string; + 'consent.authorization_details.invalid_number': string; + 'consent.authorization_details.number_max': string; + 'consent.authorization_details.number_min': string; + 'consent.authorization_details.invalid_date': string; + 'consent.authorization_details.date_max': string; + 'consent.authorization_details.date_min': string; + 'consent.authorization_details.remove_item': string; + 'consent.authorization_details.add_item': string; + 'consent.authorization_details.add_back_item': string; + 'consent.authorization_details.new_item': string; /* |---------------------------------------------------------------| */ /* | Errors | */ diff --git a/packages/javascript/src/i18n/translations/en-US.ts b/packages/javascript/src/i18n/translations/en-US.ts index e7ee4b73..754587fe 100644 --- a/packages/javascript/src/i18n/translations/en-US.ts +++ b/packages/javascript/src/i18n/translations/en-US.ts @@ -182,6 +182,20 @@ const translations: I18nTranslations = { '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.', + 'consent.authorization_details.approve': 'Approve', + 'consent.authorization_details.show_more': 'Show more', + 'consent.authorization_details.show_less': 'Show less', + 'consent.authorization_details.more_items': '+{count} more', + 'consent.authorization_details.invalid_number': 'Enter a number.', + 'consent.authorization_details.number_max': 'Enter {value} or less.', + 'consent.authorization_details.number_min': 'Enter {value} or more.', + 'consent.authorization_details.invalid_date': 'Enter a date.', + 'consent.authorization_details.date_max': 'Choose {value} or earlier.', + 'consent.authorization_details.date_min': 'Choose {value} or later.', + 'consent.authorization_details.remove_item': '{item}, remove', + 'consent.authorization_details.add_item': '{item}, add', + 'consent.authorization_details.add_back_item': '{item}, add back', + 'consent.authorization_details.new_item': '{label}, add', /* |---------------------------------------------------------------| */ /* | Errors | */ diff --git a/packages/javascript/src/i18n/translations/fr-FR.ts b/packages/javascript/src/i18n/translations/fr-FR.ts index 43c94099..5e90d7bc 100644 --- a/packages/javascript/src/i18n/translations/fr-FR.ts +++ b/packages/javascript/src/i18n/translations/fr-FR.ts @@ -192,6 +192,20 @@ const translations: I18nTranslations = { /* |---------------------------------------------------------------| */ 'errors.heading': 'Erreur', + 'consent.authorization_details.approve': 'Approuver', + 'consent.authorization_details.show_more': 'Afficher plus', + 'consent.authorization_details.show_less': 'Afficher moins', + 'consent.authorization_details.more_items': '+{count} de plus', + 'consent.authorization_details.invalid_number': 'Saisissez un nombre.', + 'consent.authorization_details.number_max': 'Saisissez {value} ou moins.', + 'consent.authorization_details.number_min': 'Saisissez {value} ou plus.', + 'consent.authorization_details.invalid_date': 'Saisissez une date.', + 'consent.authorization_details.date_max': 'Choisissez le {value} ou une date antérieure.', + 'consent.authorization_details.date_min': 'Choisissez le {value} ou une date ultérieure.', + 'consent.authorization_details.remove_item': '{item}, retirer', + 'consent.authorization_details.add_item': '{item}, ajouter', + 'consent.authorization_details.add_back_item': '{item}, rajouter', + 'consent.authorization_details.new_item': '{label}, ajouter', 'errors.signin.initialization': "Une erreur est survenue lors de l'initialisation. Veuillez réessayer plus tard.", 'errors.signin.flow.failure': 'Une erreur est survenue lors du flux de connexion. Veuillez réessayer plus tard.', 'errors.signin.flow.completion.failure': diff --git a/packages/javascript/src/i18n/translations/hi-IN.ts b/packages/javascript/src/i18n/translations/hi-IN.ts index 57ae9e66..d9128d82 100644 --- a/packages/javascript/src/i18n/translations/hi-IN.ts +++ b/packages/javascript/src/i18n/translations/hi-IN.ts @@ -182,6 +182,20 @@ const translations: I18nTranslations = { 'consent.essential_claims.info': 'सेवा के सही ढंग से कार्य करने के लिए ये विशेषताएँ आवश्यक हैं।', 'consent.optional_claims.info': 'ये विशेषताएँ वैकल्पिक हैं और आपके विवेकानुसार प्रदान की जा सकती हैं।', 'consent.authorize_scope.info': 'ये अनुमतियाँ सेवा को अतिरिक्त सुविधाओं तक पहुँचने की अनुमति देती हैं।', + 'consent.authorization_details.approve': 'स्वीकृत करें', + 'consent.authorization_details.show_more': 'और दिखाएँ', + 'consent.authorization_details.show_less': 'कम दिखाएँ', + 'consent.authorization_details.more_items': '+{count} और', + 'consent.authorization_details.invalid_number': 'एक संख्या दर्ज करें।', + 'consent.authorization_details.number_max': '{value} या उससे कम दर्ज करें।', + 'consent.authorization_details.number_min': '{value} या उससे अधिक दर्ज करें।', + 'consent.authorization_details.invalid_date': 'एक तारीख दर्ज करें।', + 'consent.authorization_details.date_max': '{value} या उससे पहले की तारीख चुनें।', + 'consent.authorization_details.date_min': '{value} या उसके बाद की तारीख चुनें।', + 'consent.authorization_details.remove_item': '{item}, हटाएँ', + 'consent.authorization_details.add_item': '{item}, जोड़ें', + 'consent.authorization_details.add_back_item': '{item}, फिर से जोड़ें', + 'consent.authorization_details.new_item': '{label}, जोड़ें', /* |---------------------------------------------------------------| */ /* | Errors | */ diff --git a/packages/javascript/src/i18n/translations/ja-JP.ts b/packages/javascript/src/i18n/translations/ja-JP.ts index fa523075..f213755f 100644 --- a/packages/javascript/src/i18n/translations/ja-JP.ts +++ b/packages/javascript/src/i18n/translations/ja-JP.ts @@ -182,6 +182,20 @@ const translations: I18nTranslations = { 'consent.essential_claims.info': 'これらの属性は、サービスが正常に機能するために必要です。', 'consent.optional_claims.info': 'これらの属性は任意であり、お客様の判断で提供できます。', 'consent.authorize_scope.info': 'これらの権限により、サービスが追加機能にアクセスできるようになります。', + 'consent.authorization_details.approve': '承認', + 'consent.authorization_details.show_more': 'さらに表示', + 'consent.authorization_details.show_less': '表示を減らす', + 'consent.authorization_details.more_items': '他 {count} 件', + 'consent.authorization_details.invalid_number': '数値を入力してください。', + 'consent.authorization_details.number_max': '{value} 以下で入力してください。', + 'consent.authorization_details.number_min': '{value} 以上で入力してください。', + 'consent.authorization_details.invalid_date': '日付を入力してください。', + 'consent.authorization_details.date_max': '{value} 以前の日付を選択してください。', + 'consent.authorization_details.date_min': '{value} 以降の日付を選択してください。', + 'consent.authorization_details.remove_item': '{item}、削除', + 'consent.authorization_details.add_item': '{item}、追加', + 'consent.authorization_details.add_back_item': '{item}、再追加', + 'consent.authorization_details.new_item': '{label}、追加', /* |---------------------------------------------------------------| */ /* | Errors | */ diff --git a/packages/javascript/src/i18n/translations/pt-BR.ts b/packages/javascript/src/i18n/translations/pt-BR.ts index 30a0f1fe..8b38ecd3 100644 --- a/packages/javascript/src/i18n/translations/pt-BR.ts +++ b/packages/javascript/src/i18n/translations/pt-BR.ts @@ -182,6 +182,20 @@ const translations: I18nTranslations = { '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.', + 'consent.authorization_details.approve': 'Aprovar', + 'consent.authorization_details.show_more': 'Mostrar mais', + 'consent.authorization_details.show_less': 'Mostrar menos', + 'consent.authorization_details.more_items': '+{count} a mais', + 'consent.authorization_details.invalid_number': 'Insira um número.', + 'consent.authorization_details.number_max': 'Insira {value} ou menos.', + 'consent.authorization_details.number_min': 'Insira {value} ou mais.', + 'consent.authorization_details.invalid_date': 'Insira uma data.', + 'consent.authorization_details.date_max': 'Escolha {value} ou uma data anterior.', + 'consent.authorization_details.date_min': 'Escolha {value} ou uma data posterior.', + 'consent.authorization_details.remove_item': '{item}, remover', + 'consent.authorization_details.add_item': '{item}, adicionar', + 'consent.authorization_details.add_back_item': '{item}, adicionar novamente', + 'consent.authorization_details.new_item': '{label}, adicionar', /* |---------------------------------------------------------------| */ /* | Errors | */ diff --git a/packages/javascript/src/i18n/translations/pt-PT.ts b/packages/javascript/src/i18n/translations/pt-PT.ts index 2b7e7b73..70f5b740 100644 --- a/packages/javascript/src/i18n/translations/pt-PT.ts +++ b/packages/javascript/src/i18n/translations/pt-PT.ts @@ -182,6 +182,20 @@ const translations: I18nTranslations = { '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.', + 'consent.authorization_details.approve': 'Aprovar', + 'consent.authorization_details.show_more': 'Mostrar mais', + 'consent.authorization_details.show_less': 'Mostrar menos', + 'consent.authorization_details.more_items': '+{count} a mais', + 'consent.authorization_details.invalid_number': 'Introduza um número.', + 'consent.authorization_details.number_max': 'Introduza {value} ou menos.', + 'consent.authorization_details.number_min': 'Introduza {value} ou mais.', + 'consent.authorization_details.invalid_date': 'Introduza uma data.', + 'consent.authorization_details.date_max': 'Escolha {value} ou uma data anterior.', + 'consent.authorization_details.date_min': 'Escolha {value} ou uma data posterior.', + 'consent.authorization_details.remove_item': '{item}, remover', + 'consent.authorization_details.add_item': '{item}, adicionar', + 'consent.authorization_details.add_back_item': '{item}, adicionar novamente', + 'consent.authorization_details.new_item': '{label}, adicionar', /* |---------------------------------------------------------------| */ /* | Errors | */ diff --git a/packages/javascript/src/i18n/translations/si-LK.ts b/packages/javascript/src/i18n/translations/si-LK.ts index 1118d341..b5b82401 100644 --- a/packages/javascript/src/i18n/translations/si-LK.ts +++ b/packages/javascript/src/i18n/translations/si-LK.ts @@ -183,6 +183,20 @@ const translations: I18nTranslations = { 'consent.essential_claims.info': 'සේවාව නිසි ලෙස ක්‍රියාත්මක වීම සඳහා මෙම ගුණාංග අවශ්‍ය වේ.', 'consent.optional_claims.info': 'මෙම ගුණාංග විකල්ප වන අතර ඔබේ අභිමතය පරිදි සැපයිය හැක.', 'consent.authorize_scope.info': 'මෙම අවසරයන් මගින් සේවාවට අමතර විශේෂාංග වෙත ප්‍රවේශ වීමට ඉඩ සලසයි.', + 'consent.authorization_details.approve': 'අනුමත කරන්න', + 'consent.authorization_details.show_more': 'තවත් පෙන්වන්න', + 'consent.authorization_details.show_less': 'අඩුවෙන් පෙන්වන්න', + 'consent.authorization_details.more_items': 'තවත් {count}', + 'consent.authorization_details.invalid_number': 'අංකයක් ඇතුළත් කරන්න.', + 'consent.authorization_details.number_max': '{value} හෝ ඊට අඩු අගයක් ඇතුළත් කරන්න.', + 'consent.authorization_details.number_min': '{value} හෝ ඊට වැඩි අගයක් ඇතුළත් කරන්න.', + 'consent.authorization_details.invalid_date': 'දිනයක් ඇතුළත් කරන්න.', + 'consent.authorization_details.date_max': '{value} හෝ ඊට පෙර දිනයක් තෝරන්න.', + 'consent.authorization_details.date_min': '{value} හෝ ඊට පසු දිනයක් තෝරන්න.', + 'consent.authorization_details.remove_item': '{item}, ඉවත් කරන්න', + 'consent.authorization_details.add_item': '{item}, එක් කරන්න', + 'consent.authorization_details.add_back_item': '{item}, නැවත එක් කරන්න', + 'consent.authorization_details.new_item': '{label}, එක් කරන්න', /* |---------------------------------------------------------------| */ /* | Errors | */ diff --git a/packages/javascript/src/i18n/translations/ta-IN.ts b/packages/javascript/src/i18n/translations/ta-IN.ts index ad539b1d..60ae24eb 100644 --- a/packages/javascript/src/i18n/translations/ta-IN.ts +++ b/packages/javascript/src/i18n/translations/ta-IN.ts @@ -184,6 +184,20 @@ const translations: I18nTranslations = { 'consent.essential_claims.info': 'சேவை சரியாக செயல்பட இந்த பண்புகள் தேவை.', 'consent.optional_claims.info': 'இந்த பண்புகள் விருப்பத்தேர்வு மற்றும் உங்கள் விருப்பப்படி வழங்கப்படலாம்.', 'consent.authorize_scope.info': 'இந்த அனுமதிகள் கூடுதல் அம்சங்களை அணுக சேவைக்கு உதவுகின்றன.', + 'consent.authorization_details.approve': 'அனுமதி', + 'consent.authorization_details.show_more': 'மேலும் காட்டு', + 'consent.authorization_details.show_less': 'குறைவாகக் காட்டு', + 'consent.authorization_details.more_items': '+{count} மேலும்', + 'consent.authorization_details.invalid_number': 'ஒரு எண்ணை உள்ளிடவும்.', + 'consent.authorization_details.number_max': '{value} அல்லது அதற்குக் குறைவாக உள்ளிடவும்.', + 'consent.authorization_details.number_min': '{value} அல்லது அதற்கு மேல் உள்ளிடவும்.', + 'consent.authorization_details.invalid_date': 'ஒரு தேதியை உள்ளிடவும்.', + 'consent.authorization_details.date_max': '{value} அல்லது அதற்கு முந்தைய தேதியைத் தேர்ந்தெடுக்கவும்.', + 'consent.authorization_details.date_min': '{value} அல்லது அதற்குப் பிந்தைய தேதியைத் தேர்ந்தெடுக்கவும்.', + 'consent.authorization_details.remove_item': '{item}, அகற்று', + 'consent.authorization_details.add_item': '{item}, சேர்', + 'consent.authorization_details.add_back_item': '{item}, மீண்டும் சேர்', + 'consent.authorization_details.new_item': '{label}, சேர்', /* |---------------------------------------------------------------| */ /* | Errors | */ diff --git a/packages/javascript/src/i18n/translations/te-IN.ts b/packages/javascript/src/i18n/translations/te-IN.ts index ec41eed2..aebd2b5d 100644 --- a/packages/javascript/src/i18n/translations/te-IN.ts +++ b/packages/javascript/src/i18n/translations/te-IN.ts @@ -184,6 +184,20 @@ const translations: I18nTranslations = { 'consent.essential_claims.info': 'సేవ సరిగ్గా పనిచేయడానికి ఈ లక్షణాలు అవసరం.', 'consent.optional_claims.info': 'ఈ లక్షణాలు ఐచ్ఛికం మరియు మీ విచక్షణ మేరకు అందించవచ్చు.', 'consent.authorize_scope.info': 'ఈ అనుమతులు సేవను అదనపు ఫీచర్‌లను యాక్సెస్ చేయడానికి అనుమతిస్తాయి.', + 'consent.authorization_details.approve': 'ఆమోదించు', + 'consent.authorization_details.show_more': 'మరిన్ని చూపించు', + 'consent.authorization_details.show_less': 'తక్కువ చూపించు', + 'consent.authorization_details.more_items': '+{count} మరిన్ని', + 'consent.authorization_details.invalid_number': 'ఒక సంఖ్యను నమోదు చేయండి.', + 'consent.authorization_details.number_max': '{value} లేదా అంతకంటే తక్కువ నమోదు చేయండి.', + 'consent.authorization_details.number_min': '{value} లేదా అంతకంటే ఎక్కువ నమోదు చేయండి.', + 'consent.authorization_details.invalid_date': 'ఒక తేదీని నమోదు చేయండి.', + 'consent.authorization_details.date_max': '{value} లేదా అంతకు ముందు తేదీని ఎంచుకోండి.', + 'consent.authorization_details.date_min': '{value} లేదా ఆ తర్వాతి తేదీని ఎంచుకోండి.', + 'consent.authorization_details.remove_item': '{item}, తీసివేయి', + 'consent.authorization_details.add_item': '{item}, జోడించు', + 'consent.authorization_details.add_back_item': '{item}, మళ్లీ జోడించు', + 'consent.authorization_details.new_item': '{label}, జోడించు', /* |---------------------------------------------------------------| */ /* | Errors | */ diff --git a/packages/javascript/src/index.ts b/packages/javascript/src/index.ts index c093def9..75660aa9 100644 --- a/packages/javascript/src/index.ts +++ b/packages/javascript/src/index.ts @@ -99,6 +99,9 @@ export type { ConsentPurposeDecision, ConsentDecisions, ConsentPurposeData, + ConsentDetailRow, + ConsentDetailValueType, + ConsentDetailModification, ConsentPromptData, KeyValuePair, I18nMessage, @@ -257,6 +260,8 @@ export type { AuthorizeRequestUrlParams, KnownExtendedAuthorizeRequestUrlParams, ExtendedAuthorizeRequestUrlParams, + AuthorizeRequestParamValue, + AuthorizationDetail, } from './models/oauth-request'; export type {OIDCEndpoints} from './models/oidc-endpoints'; export type {OIDCDiscoveryApiResponse} from './models/oidc-discovery'; @@ -286,6 +291,10 @@ export {default as extractPkceStorageKeyFromState} from './utils/extractPkceStor export {default as getLatestStateParam} from './utils/getLatestStateParam'; export {default as generateFlattenedUserProfile} from './utils/generateFlattenedUserProfile'; export {default as getRedirectBasedSignUpUrl} from './utils/getRedirectBasedSignUpUrl'; +export {default as getConsentModifiedKey, CONSENT_MODIFIED_KEY_PREFIX} from './utils/getConsentModifiedKey'; +export {default as getConsentModifiedValues} from './utils/getConsentModifiedValues'; +export {default as getConsentElementDecision} from './utils/getConsentElementDecision'; +export {default as getConsentInvalidKey, CONSENT_INVALID_KEY_PREFIX} from './utils/getConsentInvalidKey'; export {default as isEmpty} from './utils/isEmpty'; export {default as isEmojiUri, EMOJI_URI_SCHEME} from './utils/isEmojiUri'; export {default as extractEmojiFromUri} from './utils/extractEmojiFromUri'; diff --git a/packages/javascript/src/models/ciba.ts b/packages/javascript/src/models/ciba.ts index 8a2040cb..d77249ac 100644 --- a/packages/javascript/src/models/ciba.ts +++ b/packages/javascript/src/models/ciba.ts @@ -1,6 +1,8 @@ // Copyright 2026 The ThunderID Authors // SPDX-License-Identifier: Apache-2.0 +import type {AuthorizationDetail} from './oauth-request'; + /** * Options for initiating a CIBA backchannel authentication request (CIBA Core 1.0 §7.1). * Exactly one of loginHint, loginHintToken, or idTokenHint must be provided. @@ -41,6 +43,12 @@ export interface CIBAInitiateOptions { * authentication context the authorization server is requested to use. */ acrValues?: string; + + /** + * RFC 9396 authorization details to request, sent JSON-encoded as the authorization_details + * parameter. Takes precedence over the authorizationDetails configuration. + */ + authorizationDetails?: AuthorizationDetail[]; } /** diff --git a/packages/javascript/src/models/config.ts b/packages/javascript/src/models/config.ts index 943984f5..4776f3e3 100644 --- a/packages/javascript/src/models/config.ts +++ b/packages/javascript/src/models/config.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import {ComponentsExtensions} from './extensions/components'; +import type {AuthorizationDetail} from './oauth-request'; import type {OAuthResponseMode} from './oauth-response'; import type {OIDCEndpoints} from './oidc-endpoints'; import {TokenEndpointAuthMethod} from './token-endpoint-auth'; @@ -74,6 +75,23 @@ export interface SilentSignInConfig { } export interface BaseConfig extends WithPreferences, WithExtensions { + /** + * Optional RFC 9396 authorization details to request, sent as the JSON-encoded `authorization_details` + * parameter on the authorization request, and on the token request under the `client_credentials` grant. + * Each entry names its `type`; the members that type defines go alongside it. + * The authorization details the server granted are returned on the token response (`authorizationDetails` + * from `signIn` on a server SDK). A browser app reads them from the `authorization_details` claim of the + * access token. + * The user consents to authorization details only on a hosted (redirect-based) sign-in, where the + * authorization server renders the consent; an app-native sign-in flow cannot carry them. + * + * @see {@link https://datatracker.ietf.org/doc/html/rfc9396} + * + * @example + * authorizationDetails: [{type: "payment_initiation", actions: ["initiate"], instructedAmount: {currency: "EUR", amount: "123.50"}}] + */ + authorizationDetails?: AuthorizationDetail[]; + /** * Whether to enable PKCE (Proof Key for Code Exchange) for the authorization request. * Defaults to `true`. Disable only if your authorization server does not support PKCE. diff --git a/packages/javascript/src/models/embedded-flow.ts b/packages/javascript/src/models/embedded-flow.ts index f6c03cca..90ce1fbf 100644 --- a/packages/javascript/src/models/embedded-flow.ts +++ b/packages/javascript/src/models/embedded-flow.ts @@ -658,7 +658,7 @@ export interface EmbeddedFlowResponseData { * * @experimental This type may change in future versions */ -export type ConsentPurposeType = 'attributes' | 'permissions'; +export type ConsentPurposeType = 'attributes' | 'permissions' | 'authorization_details'; /** * Individual consent attribute/element decision. @@ -670,6 +670,11 @@ export interface ConsentAttributeElement { approved: boolean; /** The name of the attribute being consented */ name: string; + /** + * Values the user modified on an authorization detail element before approving it, keyed by the + * dot-separated path of the field. Left out when the detail is approved as it was shown. + */ + modified?: Record; } /** @@ -681,13 +686,22 @@ export interface ConsentAttributeElement { * @experimental This interface may change in future versions */ export interface PromptElement { - /** Canonical element name (attribute name or permission string) */ + /** Canonical element name (attribute name, permission string or authorization detail identifier) */ name: string; /** * Canonical name of the rollup parent, permission-purpose only. Undefined for attribute * elements and for top-level permissions. */ parent?: string; + /** + * RFC 9396 authorization details type, present only on an authorization detail element. Such an + * element is named by the detail's identifier and is approved or denied as a whole. + */ + detailType?: string; + /** Server-rendered sentence describing an authorization detail element. */ + summary?: string; + /** Fields of an authorization detail element. A field is read-only unless it carries a modification. */ + rows?: ConsentDetailRow[]; } /** @@ -747,11 +761,59 @@ export interface ConsentPurposeData { /** Human-readable purpose name */ purposeName?: string; /** - * Discriminator selecting between attribute and permission consent semantics. + * Discriminator selecting between attribute, permission and authorization details consent + * semantics. An authorization details purpose carries one optional element per detail. */ type?: ConsentPurposeType; } +/** + * Ordering an authorization detail field with a modification is compared by: a `number`, a `date` in + * `YYYY-MM-DD` form, or a `dateTime` in RFC 3339 form. + * + * @experimental This type may change in future versions + */ +export type ConsentDetailValueType = 'number' | 'date' | 'dateTime'; + +/** + * Change a field policy allows on an authorization detail field: `none`, `decrease` or `increase` for an + * ordered value, `removeItems` or `addItems` for a list. + * + * @experimental This type may change in future versions + */ +export type ConsentDetailModification = 'none' | 'decrease' | 'increase' | 'removeItems' | 'addItems'; + +/** + * One field of an RFC 9396 authorization detail presented for consent. + * + * @experimental This interface may change in future versions + */ +export interface ConsentDetailRow { + /** Display label declared by the type, or the path when none is declared. */ + label: string; + /** + * Ordering a field with a modification is compared by: `number`, `date` or `dateTime`. Present only + * with `modification`. + */ + as?: ConsentDetailValueType; + /** + * Change the user may make to the field before approving: `decrease` or `increase` for an ordered + * value, `removeItems` or `addItems` for a list. Absent when the field is read-only. + */ + modification?: ConsentDetailModification; + /** Set to `server` when the value was supplied by the server rather than the client. */ + origin?: string; + /** + * Values the user may add to a list whose rule is `addItems`, as the type allows them. Absent when + * any value may be added. + */ + options?: unknown[]; + /** Dot-separated location of the field within the detail. */ + path: string; + /** Field value as it appears in the detail. */ + value: unknown; +} + /** * Consent prompt data structure stored in additionalData.consent_prompt. * diff --git a/packages/javascript/src/models/oauth-request.ts b/packages/javascript/src/models/oauth-request.ts index 9f50779d..070a80d7 100644 --- a/packages/javascript/src/models/oauth-request.ts +++ b/packages/javascript/src/models/oauth-request.ts @@ -4,9 +4,68 @@ export type AuthorizeRequestUrlParams = Omit; export interface KnownExtendedAuthorizeRequestUrlParams { + /** + * RFC 9396 authorization details to request for this sign-in, sent as the JSON-encoded + * `authorization_details` parameter. Takes precedence over the `authorizationDetails` configuration. + * @see {@link AuthorizationDetail} + */ + authorizationDetails?: AuthorizationDetail[]; fidp?: string; forceInit?: boolean; } +/** + * An RFC 9396 authorization details object: one fine-grained authorization requirement that a client + * requests, or that the server reports as granted in a token response. `type` names a type the server + * knows, and that type defines every other member. + * + * @see {@link https://datatracker.ietf.org/doc/html/rfc9396} + * + * @example + * {type: 'payment_initiation', actions: ['initiate'], instructedAmount: {currency: 'EUR', amount: '123.50'}} + */ +export interface AuthorizationDetail { + /** + * Kinds of actions to take at the resource. + */ + actions?: string[]; + + /** + * Kinds of data being requested from the resource. + */ + datatypes?: string[]; + + /** + * Specific resource available at the API. + */ + identifier?: string; + + /** + * Locations of the resources or resource servers the details apply to. + */ + locations?: string[]; + + /** + * Privilege levels at the resource. + */ + privileges?: string[]; + + /** + * The authorization details type, which determines how the server reads the other members. + */ + type: string; + + /** + * Members defined by the authorization details type. + */ + [member: string]: unknown; +} + +/** + * Value of an extra authorize request parameter. An object or array value, such as RFC 9396 + * `authorization_details`, is sent JSON-encoded. + */ +export type AuthorizeRequestParamValue = string | number | boolean | object; + export type ExtendedAuthorizeRequestUrlParams = KnownExtendedAuthorizeRequestUrlParams & - Record; + Record; diff --git a/packages/javascript/src/models/session.ts b/packages/javascript/src/models/session.ts index fbfc3456..6e48c3fa 100644 --- a/packages/javascript/src/models/session.ts +++ b/packages/javascript/src/models/session.ts @@ -1,8 +1,11 @@ // Copyright 2020 The ThunderID Authors // SPDX-License-Identifier: Apache-2.0 +import type {AuthorizationDetail} from './oauth-request'; + export interface SessionData { access_token: string; + authorization_details?: AuthorizationDetail[]; created_at: number; expires_in: string; id_token: string; @@ -12,5 +15,6 @@ export interface SessionData { } export interface UserSession { + authorizationDetails?: AuthorizationDetail[]; scopes: string[]; } diff --git a/packages/javascript/src/models/token.ts b/packages/javascript/src/models/token.ts index d6c5b506..a9a5761d 100644 --- a/packages/javascript/src/models/token.ts +++ b/packages/javascript/src/models/token.ts @@ -1,6 +1,8 @@ // Copyright 2020 The ThunderID Authors // SPDX-License-Identifier: Apache-2.0 +import type {AuthorizationDetail} from './oauth-request'; + /** * Represents a processed token response with standardized camelCase properties. * This interface provides a clean, normalized structure for token data after @@ -17,6 +19,12 @@ export interface TokenResponse { */ accessToken: string; + /** + * RFC 9396 authorization details the server granted to the access token. + * Present only when the server returns them, and may differ from what was requested. + */ + authorizationDetails?: AuthorizationDetail[]; + /** * Unix timestamp (in seconds) when the token was created. * Used in combination with expiresIn to determine when @@ -76,6 +84,12 @@ export interface AccessTokenApiResponse { */ access_token: string; + /** + * Raw RFC 9396 authorization details granted to the access token. + * Returned only when the server granted authorization details. + */ + authorization_details?: AuthorizationDetail[]; + /** * Server-provided creation timestamp in Unix seconds. * Used to track when the token was originally issued diff --git a/packages/javascript/src/utils/AuthenticationHelper.ts b/packages/javascript/src/utils/AuthenticationHelper.ts index b4e4acac..c1eb68d6 100644 --- a/packages/javascript/src/utils/AuthenticationHelper.ts +++ b/packages/javascript/src/utils/AuthenticationHelper.ts @@ -484,6 +484,7 @@ class AuthenticationHelper { return { accessToken: parsedResponse.access_token, + authorizationDetails: parsedResponse.authorization_details, createdAt: parsedResponse.created_at, expiresIn: parsedResponse.expires_in, idToken: parsedResponse.id_token, @@ -498,6 +499,7 @@ class AuthenticationHelper { return { accessToken: parsedResponse.access_token, + authorizationDetails: parsedResponse.authorization_details, createdAt: parsedResponse.created_at, expiresIn: parsedResponse.expires_in, idToken: parsedResponse.id_token, diff --git a/packages/javascript/src/utils/__tests__/getAuthorizeRequestUrlParams.test.ts b/packages/javascript/src/utils/__tests__/getAuthorizeRequestUrlParams.test.ts index 6faf3128..f2fbd53b 100644 --- a/packages/javascript/src/utils/__tests__/getAuthorizeRequestUrlParams.test.ts +++ b/packages/javascript/src/utils/__tests__/getAuthorizeRequestUrlParams.test.ts @@ -119,6 +119,48 @@ describe('getAuthorizeRequestUrlParams', (): void => { expect(params.get(OIDCRequestConstants.Params.STATE)).not.toBe('shouldNotAppear'); }); + it('should send authorization details JSON-encoded as authorization_details', (): void => { + const authorizationDetails = [{actions: ['initiate'], type: 'payment'}, {type: 'account_information'}]; + const params: Map = getAuthorizeRequestUrlParams( + { + authorizationDetails, + clientId: 'client123', + redirectUri: 'https://app/callback', + }, + {key: pkceKey}, + {}, + ); + + expect(params.get('authorization_details')).toBe(JSON.stringify(authorizationDetails)); + }); + + it('should not send authorization_details when none are given', (): void => { + const params: Map = getAuthorizeRequestUrlParams( + { + authorizationDetails: [], + clientId: 'client123', + redirectUri: 'https://app/callback', + }, + {key: pkceKey}, + {}, + ); + + expect(params.has('authorization_details')).toBe(false); + }); + + it('should JSON-encode an object custom param', (): void => { + const params: Map = getAuthorizeRequestUrlParams( + { + clientId: 'client123', + redirectUri: 'https://app/callback', + }, + {key: pkceKey}, + {authorization_details: [{type: 'payment'}]}, + ); + + expect(params.get('authorization_details')).toBe('[{"type":"payment"}]'); + }); + it('should generate state param using pkceKey and custom state', (): void => { const params: Map = getAuthorizeRequestUrlParams( { diff --git a/packages/javascript/src/utils/__tests__/getConsentElementDecision.test.ts b/packages/javascript/src/utils/__tests__/getConsentElementDecision.test.ts new file mode 100644 index 00000000..59235ad8 --- /dev/null +++ b/packages/javascript/src/utils/__tests__/getConsentElementDecision.test.ts @@ -0,0 +1,31 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {describe, expect, it} from 'vitest'; +import {PromptElement} from '../../models/embedded-flow'; +import getConsentElementDecision from '../getConsentElementDecision'; + +const detail: PromptElement = { + detailType: 'payment_initiation', + name: 'd-1', + rows: [{as: 'number', label: 'Amount', modification: 'decrease', path: 'amount', value: 35}], +}; +const inputs: Record = {'__consent_modified__p1__d-1__amount': '20'}; + +describe('getConsentElementDecision', (): void => { + it('should carry the modified values of an approved authorization detail', (): void => { + expect(getConsentElementDecision('p1', detail, true, inputs)).toEqual({ + approved: true, + modified: {amount: 20}, + name: 'd-1', + }); + }); + + it('should leave the modified values out of a declined detail', (): void => { + expect(getConsentElementDecision('p1', detail, false, inputs)).toEqual({approved: false, name: 'd-1'}); + }); + + it('should carry only the name and approval of any other element', (): void => { + expect(getConsentElementDecision('p1', {name: 'email'}, true, inputs)).toEqual({approved: true, name: 'email'}); + }); +}); diff --git a/packages/javascript/src/utils/__tests__/getConsentModifiedValues.test.ts b/packages/javascript/src/utils/__tests__/getConsentModifiedValues.test.ts new file mode 100644 index 00000000..324fac31 --- /dev/null +++ b/packages/javascript/src/utils/__tests__/getConsentModifiedValues.test.ts @@ -0,0 +1,95 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {describe, expect, it} from 'vitest'; +import {PromptElement} from '../../models/embedded-flow'; +import getConsentInvalidKey from '../getConsentInvalidKey'; +import getConsentModifiedKey from '../getConsentModifiedKey'; +import getConsentModifiedValues from '../getConsentModifiedValues'; + +const element: PromptElement = { + detailType: 'payment_initiation', + name: 'd-1', + rows: [ + {as: 'number', label: 'Amount', modification: 'decrease', path: 'instructedAmount.amount', value: 35}, + {label: 'Actions', modification: 'removeItems', path: 'actions', value: ['initiate', 'status']}, + {label: 'Pay to', path: 'creditorName', value: 'Merchant A'}, + ], +}; + +describe('getConsentModifiedKey', (): void => { + it('should build a key from the purpose, the detail and the field path', (): void => { + expect(getConsentModifiedKey('p1', 'd-1', 'instructedAmount.amount')).toBe( + '__consent_modified__p1__d-1__instructedAmount.amount', + ); + }); +}); + +describe('getConsentInvalidKey', (): void => { + it('should build a key from the purpose, the detail and the field path', (): void => { + expect(getConsentInvalidKey('p1', 'd-1', 'instructedAmount.amount')).toBe( + '__consent_invalid__p1__d-1__instructedAmount.amount', + ); + }); +}); + +describe('getConsentModifiedValues', (): void => { + it('should return the changed values keyed by field path', (): void => { + const inputs: Record = { + [getConsentModifiedKey('p1', 'd-1', 'actions')]: '["initiate"]', + [getConsentModifiedKey('p1', 'd-1', 'instructedAmount.amount')]: '20', + }; + + expect(getConsentModifiedValues('p1', element, inputs)).toEqual({ + actions: ['initiate'], + 'instructedAmount.amount': 20, + }); + }); + + it('should leave out a field kept at the value that was shown', (): void => { + const inputs: Record = { + [getConsentModifiedKey('p1', 'd-1', 'actions')]: '["initiate","status"]', + [getConsentModifiedKey('p1', 'd-1', 'instructedAmount.amount')]: '35', + }; + + expect(getConsentModifiedValues('p1', element, inputs)).toBeUndefined(); + }); + + it('should ignore a field that is not modification', (): void => { + const inputs: Record = {[getConsentModifiedKey('p1', 'd-1', 'creditorName')]: '"Merchant B"'}; + + expect(getConsentModifiedValues('p1', element, inputs)).toBeUndefined(); + }); + + it('should ignore values that are empty or not JSON', (): void => { + const inputs: Record = { + [getConsentModifiedKey('p1', 'd-1', 'actions')]: 'not json', + [getConsentModifiedKey('p1', 'd-1', 'instructedAmount.amount')]: '', + }; + + expect(getConsentModifiedValues('p1', element, inputs)).toBeUndefined(); + }); + + it('should ignore a field marked as holding text that cannot be submitted', (): void => { + const inputs: Record = { + [getConsentInvalidKey('p1', 'd-1', 'instructedAmount.amount')]: 'true', + [getConsentModifiedKey('p1', 'd-1', 'actions')]: '["initiate"]', + [getConsentModifiedKey('p1', 'd-1', 'instructedAmount.amount')]: '20', + }; + + expect(getConsentModifiedValues('p1', element, inputs)).toEqual({actions: ['initiate']}); + }); + + it('should read a field whose invalid marker is cleared', (): void => { + const inputs: Record = { + [getConsentInvalidKey('p1', 'd-1', 'instructedAmount.amount')]: '', + [getConsentModifiedKey('p1', 'd-1', 'instructedAmount.amount')]: '20', + }; + + expect(getConsentModifiedValues('p1', element, inputs)).toEqual({'instructedAmount.amount': 20}); + }); + + it('should return undefined for an element without rows', (): void => { + expect(getConsentModifiedValues('p1', {name: 'email'}, {})).toBeUndefined(); + }); +}); diff --git a/packages/javascript/src/utils/getAuthorizeRequestUrlParams.ts b/packages/javascript/src/utils/getAuthorizeRequestUrlParams.ts index 9d049433..5f1dfefe 100644 --- a/packages/javascript/src/utils/getAuthorizeRequestUrlParams.ts +++ b/packages/javascript/src/utils/getAuthorizeRequestUrlParams.ts @@ -4,7 +4,7 @@ import generateStateParamForRequestCorrelation from './generateStateParamForRequestCorrelation'; import OIDCRequestConstants from '../constants/OIDCRequestConstants'; import ThunderIDRuntimeError from '../errors/ThunderIDRuntimeError'; -import {ExtendedAuthorizeRequestUrlParams} from '../models/oauth-request'; +import {AuthorizeRequestParamValue, ExtendedAuthorizeRequestUrlParams} from '../models/oauth-request'; /** * Generates a map of authorization request URL parameters for OIDC authorization requests. @@ -12,9 +12,10 @@ import {ExtendedAuthorizeRequestUrlParams} from '../models/oauth-request'; * This utility ensures the `openid` scope is always included, handles both string and array forms of the `scope` parameter, * and supports PKCE and custom parameters. Throws if a code challenge is provided without a code challenge method. * - * @param options - The main options for the authorization request, including redirectUri, clientId, scope, responseMode, codeChallenge, codeChallengeMethod, prompt, and randomState (a value generated from random bytes that makes the `state` unique to this request). + * @param options - The main options for the authorization request, including redirectUri, clientId, scope, responseMode, codeChallenge, codeChallengeMethod, prompt, authorizationDetails, and randomState (a value generated from random bytes that makes the `state` unique to this request). * @param pkceOptions - PKCE options, including the PKCE key for state correlation. * @param customParams - Optional custom parameters to include in the request (excluding the `state` param, which is handled separately). + * An object or array value, such as RFC 9396 `authorization_details`, is sent JSON-encoded. * @returns A Map of key-value pairs representing the authorization request URL parameters. * * @throws {ThunderIDRuntimeError} If a code challenge is provided without a code challenge method. @@ -48,9 +49,18 @@ const getAuthorizeRequestUrlParams = ( scopes?: string; } & ExtendedAuthorizeRequestUrlParams, pkceOptions: {key: string}, - customParams: Record, + customParams: Record, ): Map => { - const {redirectUri, clientId, scopes, responseMode, codeChallenge, codeChallengeMethod, prompt} = options; + const { + authorizationDetails, + redirectUri, + clientId, + scopes, + responseMode, + codeChallenge, + codeChallengeMethod, + prompt, + } = options; const authorizeRequestParams: Map = new Map(); authorizeRequestParams.set('response_type', 'code'); @@ -86,16 +96,21 @@ const getAuthorizeRequestUrlParams = ( authorizeRequestParams.set('prompt', prompt); } + if (authorizationDetails?.length) { + authorizeRequestParams.set('authorization_details', JSON.stringify(authorizationDetails)); + } + if (customParams) { - Object.entries(customParams).forEach(([key, value]: [string, string | number | boolean]) => { + Object.entries(customParams).forEach(([key, value]: [string, AuthorizeRequestParamValue]) => { if (key !== '' && value !== '' && key !== OIDCRequestConstants.Params.STATE) { - authorizeRequestParams.set(key, value.toString()); + authorizeRequestParams.set(key, typeof value === 'object' ? JSON.stringify(value) : value.toString()); } }); } const AUTH_INSTANCE_PREFIX = 'instance_'; - const customState: string = customParams?.[OIDCRequestConstants.Params.STATE]?.toString() ?? ''; + const customState: string = + (customParams?.[OIDCRequestConstants.Params.STATE] as string | number | boolean | undefined)?.toString() ?? ''; let customStateValue: string = customState; // Keep a caller-supplied state after the instance prefix rather than dropping it: the prefix routes diff --git a/packages/javascript/src/utils/getConsentElementDecision.ts b/packages/javascript/src/utils/getConsentElementDecision.ts new file mode 100644 index 00000000..4e0caaec --- /dev/null +++ b/packages/javascript/src/utils/getConsentElementDecision.ts @@ -0,0 +1,36 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import getConsentModifiedValues from './getConsentModifiedValues'; +import {ConsentAttributeElement, PromptElement} from '../models/embedded-flow'; + +/** + * Builds the decision on one optional consent element. An approved authorization detail element carries + * the values the user modified on it; any other element carries only its name and approval. + * + * @param purposeId - The identifier of the consent purpose the element belongs to. + * @param element - The consent element. + * @param approved - Whether the user approved the element. + * @param inputs - Form values, holding the JSON text of each modified field under its modified key. + * @returns The element decision. + * + * @example + * ```typescript + * getConsentElementDecision('p1', element, true, {'__consent_modified__p1__d-1__amount': '20'}) + * // returns {approved: true, name: 'd-1', modified: {amount: 20}} + * ``` + */ +const getConsentElementDecision = ( + purposeId: string, + element: PromptElement, + approved: boolean, + inputs: Record, +): ConsentAttributeElement => { + const modified: Record | undefined = approved + ? getConsentModifiedValues(purposeId, element, inputs) + : undefined; + + return {approved, name: element.name, ...(modified ? {modified} : {})}; +}; + +export default getConsentElementDecision; diff --git a/packages/javascript/src/utils/getConsentInvalidKey.ts b/packages/javascript/src/utils/getConsentInvalidKey.ts new file mode 100644 index 00000000..60eaa7db --- /dev/null +++ b/packages/javascript/src/utils/getConsentInvalidKey.ts @@ -0,0 +1,26 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +/** + * Prefix of the form keys that mark an authorization detail field whose entered text cannot be submitted. + */ +export const CONSENT_INVALID_KEY_PREFIX = '__consent_invalid__'; + +/** + * Generates the form key that marks one field of an authorization detail as holding text that cannot be + * submitted. The stored value is `true` while the field shows such text, and empty otherwise. + * + * @param purposeId - The identifier of the consent purpose the authorization detail belongs to. + * @param detailId - The identifier of the authorization detail, its consent element name. + * @param path - Dot-separated location of the field within the detail. + * @returns A stable form key string. + * + * @example + * ```typescript + * getConsentInvalidKey('p1', 'd-1', 'instructedAmount.amount') // returns '__consent_invalid__p1__d-1__instructedAmount.amount' + * ``` + */ +const getConsentInvalidKey = (purposeId: string, detailId: string, path: string): string => + `${CONSENT_INVALID_KEY_PREFIX}${purposeId}__${detailId}__${path}`; + +export default getConsentInvalidKey; diff --git a/packages/javascript/src/utils/getConsentModifiedKey.ts b/packages/javascript/src/utils/getConsentModifiedKey.ts new file mode 100644 index 00000000..a05d9c19 --- /dev/null +++ b/packages/javascript/src/utils/getConsentModifiedKey.ts @@ -0,0 +1,26 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +/** + * Prefix of the form keys that hold the values a user modified on an authorization detail. + */ +export const CONSENT_MODIFIED_KEY_PREFIX = '__consent_modified__'; + +/** + * Generates the form key that holds the value a user chose for one field of an authorization + * detail. The stored value is the JSON text of the field's new value. + * + * @param purposeId - The identifier of the consent purpose the authorization detail belongs to. + * @param detailId - The identifier of the authorization detail, its consent element name. + * @param path - Dot-separated location of the field within the detail. + * @returns A stable form key string. + * + * @example + * ```typescript + * getConsentModifiedKey('p1', 'd-1', 'instructedAmount.amount') // returns '__consent_modified__p1__d-1__instructedAmount.amount' + * ``` + */ +const getConsentModifiedKey = (purposeId: string, detailId: string, path: string): string => + `${CONSENT_MODIFIED_KEY_PREFIX}${purposeId}__${detailId}__${path}`; + +export default getConsentModifiedKey; diff --git a/packages/javascript/src/utils/getConsentModifiedValues.ts b/packages/javascript/src/utils/getConsentModifiedValues.ts new file mode 100644 index 00000000..59839590 --- /dev/null +++ b/packages/javascript/src/utils/getConsentModifiedValues.ts @@ -0,0 +1,50 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import getConsentInvalidKey from './getConsentInvalidKey'; +import getConsentModifiedKey from './getConsentModifiedKey'; +import {ConsentDetailRow, PromptElement} from '../models/embedded-flow'; + +/** + * Collects the values a user modified on an authorization detail element, for the `modified` member + * of its consent decision. Only fields the element lets the user modify are read, and a field left at + * the value that was shown, or marked as holding text that cannot be submitted, is left out. + * + * @param purposeId - The identifier of the consent purpose the element belongs to. + * @param element - The authorization detail element. + * @param inputs - Form values, holding the JSON text of each changed field under its modified key and its + * invalid marker under its invalid key. + * @returns The changed values keyed by field path, or `undefined` when nothing was changed. + * + * @example + * ```typescript + * getConsentModifiedValues('p1', element, {'__consent_modified__p1__d-1__amount': '20'}) // returns {amount: 20} + * ``` + */ +const getConsentModifiedValues = ( + purposeId: string, + element: PromptElement, + inputs: Record, +): Record | undefined => { + const modified: Record = {}; + + (element.rows ?? []).forEach((row: ConsentDetailRow) => { + if (!row.modification) return; + if (inputs[getConsentInvalidKey(purposeId, element.name, row.path)] === 'true') return; + const raw: unknown = inputs[getConsentModifiedKey(purposeId, element.name, row.path)]; + if (typeof raw !== 'string' || raw === '') return; + + let value: unknown; + try { + value = JSON.parse(raw); + } catch { + return; + } + if (JSON.stringify(value) === JSON.stringify(row.value)) return; + modified[row.path] = value; + }); + + return Object.keys(modified).length > 0 ? modified : undefined; +}; + +export default getConsentModifiedValues; diff --git a/packages/node/src/ThunderIDNodeClient.ts b/packages/node/src/ThunderIDNodeClient.ts index dbfbdb28..181cb111 100644 --- a/packages/node/src/ThunderIDNodeClient.ts +++ b/packages/node/src/ThunderIDNodeClient.ts @@ -83,6 +83,7 @@ class ThunderIDNodeClient e const sessionData: SessionData = await sm.getSessionData(userId); return Promise.resolve({ accessToken: sessionData.access_token, + authorizationDetails: sessionData.authorization_details, createdAt: sessionData.created_at, expiresIn: sessionData.expires_in, idToken: sessionData.id_token, @@ -121,6 +122,7 @@ class ThunderIDNodeClient e await this.indexSession(userId, sessionData); return Promise.resolve({ accessToken: sessionData.access_token, + authorizationDetails: sessionData.authorization_details, createdAt: sessionData.created_at, expiresIn: sessionData.expires_in, idToken: sessionData.id_token, diff --git a/packages/node/src/__tests__/ThunderIDNodeClient.authorizationDetails.test.ts b/packages/node/src/__tests__/ThunderIDNodeClient.authorizationDetails.test.ts new file mode 100644 index 00000000..82e9bb9e --- /dev/null +++ b/packages/node/src/__tests__/ThunderIDNodeClient.authorizationDetails.test.ts @@ -0,0 +1,172 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {AuthorizationDetail} from '@thunderid/javascript'; +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {ThunderIDNodeConfig} from '../models/config'; +import MemoryCacheStore from '../stores/MemoryCacheStore'; +import ThunderIDNodeClient from '../ThunderIDNodeClient'; + +const BASE_URL = 'https://auth.example.com'; +const TOKEN_ENDPOINT = `${BASE_URL}/oauth2/token`; + +const AUTHORIZATION_DETAILS: AuthorizationDetail[] = [ + {actions: ['read'], locations: ['https://api.example.com/accounts'], type: 'account'}, +]; + +// A store of its own per test, since the default one is shared by the whole process. +class IsolatedStore extends MemoryCacheStore { + private data: Map = new Map(); + + public override setData(key: string, value: string): Promise { + this.data.set(key, value); + + return Promise.resolve(); + } + + public override getData(key: string): Promise { + return Promise.resolve(this.data.get(key) ?? '{}'); + } + + public override removeData(key: string): Promise { + this.data.delete(key); + + return Promise.resolve(); + } +} + +const encode = (part: Record): string => Buffer.from(JSON.stringify(part)).toString('base64url'); + +const ID_TOKEN = `${encode({alg: 'none'})}.${encode({sid: 'server-session-1', sub: 'user-1'})}.`; + +const jsonResponse = (body: Record): Response => + new Response(JSON.stringify(body), {headers: {'Content-Type': 'application/json'}, status: 200}); + +describe('ThunderIDNodeClient authorization details', () => { + let fetchMock: ReturnType; + + const newClient = async (config: Partial = {}): Promise => { + const client = new ThunderIDNodeClient(); + + await client.initialize( + { + afterSignInUrl: 'https://app.example.com/login', + baseUrl: BASE_URL, + clientId: 'test-client', + clientSecret: 'test-secret', + discovery: {wellKnown: {enabled: false}}, + tokenValidation: {idToken: {validate: false}}, + ...config, + } as ThunderIDNodeConfig, + new IsolatedStore(), + ); + + return client; + }; + + const tokenRequestBody = (): URLSearchParams => { + const [, init] = fetchMock.mock.calls.find(([url]: [string]) => url === TOKEN_ENDPOINT) as [string, RequestInit]; + + return init.body as URLSearchParams; + }; + + beforeEach(() => { + fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('sends the authorization details of the sign-in options on the authorize request', async () => { + const client = await newClient(); + const authURLCallback = vi.fn(); + + await client.signIn(authURLCallback, 'session-1', undefined, undefined, { + authorizationDetails: AUTHORIZATION_DETAILS, + }); + + expect(authURLCallback).toHaveBeenCalledTimes(1); + + const authorizeUrl = new URL(authURLCallback.mock.calls[0][0] as string); + + expect(authorizeUrl.origin + authorizeUrl.pathname).toBe(`${BASE_URL}/oauth2/authorize`); + expect(JSON.parse(authorizeUrl.searchParams.get('authorization_details')!)).toEqual(AUTHORIZATION_DETAILS); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('returns the granted authorization details from the token response on sign-in', async () => { + const client = await newClient(); + const authURLCallback = vi.fn(); + + await client.signIn(authURLCallback, 'session-1', undefined, undefined, { + authorizationDetails: AUTHORIZATION_DETAILS, + }); + + const state = new URL(authURLCallback.mock.calls[0][0] as string).searchParams.get('state')!; + + fetchMock.mockResolvedValueOnce( + jsonResponse({ + access_token: 'access-1', + authorization_details: AUTHORIZATION_DETAILS, + expires_in: '3600', + id_token: ID_TOKEN, + scope: 'openid', + token_type: 'Bearer', + }), + ); + + const response = await client.signIn(authURLCallback, 'session-1', 'auth-code', state); + + expect(response.accessToken).toBe('access-1'); + expect(response.authorizationDetails).toEqual(AUTHORIZATION_DETAILS); + + // An already signed-in session answers from the stored tokens, with the same grant. + const again = await client.signIn(authURLCallback, 'session-1'); + + expect(again.authorizationDetails).toEqual(AUTHORIZATION_DETAILS); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('leaves the authorization details out when the token response has none', async () => { + const client = await newClient(); + const authURLCallback = vi.fn(); + + await client.signIn(authURLCallback, 'session-1'); + + const state = new URL(authURLCallback.mock.calls[0][0] as string).searchParams.get('state')!; + + fetchMock.mockResolvedValueOnce( + jsonResponse({access_token: 'access-1', expires_in: '3600', id_token: ID_TOKEN, token_type: 'Bearer'}), + ); + + const response = await client.signIn(authURLCallback, 'session-1', 'auth-code', state); + + expect(response.accessToken).toBe('access-1'); + expect(response.authorizationDetails).toBeUndefined(); + }); + + it('sends the configured authorization details on a client credentials token request', async () => { + const client = await newClient({ + authorizationDetails: AUTHORIZATION_DETAILS, + grantType: 'client_credentials', + }); + + fetchMock.mockResolvedValueOnce( + jsonResponse({ + access_token: 'service-token', + authorization_details: AUTHORIZATION_DETAILS, + expires_in: '3600', + token_type: 'Bearer', + }), + ); + + await expect(client.getAccessToken('service')).resolves.toBe('service-token'); + + const body = tokenRequestBody(); + + expect(body.get('grant_type')).toBe('client_credentials'); + expect(JSON.parse(body.get('authorization_details')!)).toEqual(AUTHORIZATION_DETAILS); + }); +}); diff --git a/packages/nuxt/src/runtime/components/auth/SignIn.ts b/packages/nuxt/src/runtime/components/auth/SignIn.ts index 787ea55e..f6bf9fe7 100644 --- a/packages/nuxt/src/runtime/components/auth/SignIn.ts +++ b/packages/nuxt/src/runtime/components/auth/SignIn.ts @@ -3,7 +3,10 @@ import {navigateTo} from '#app'; import { + CONSENT_INVALID_KEY_PREFIX, + CONSENT_MODIFIED_KEY_PREFIX, ConsentConstants, + getConsentElementDecision, ThunderIDRuntimeError, type ConsentPurposeData, type EmbeddedFlowComponent, @@ -412,7 +415,12 @@ const SignIn: Component = defineComponent({ ...(p.essential ?? []).map((e) => ({approved: !isDeny, name: e.name})), ...(p.optional ?? []).map((e) => { const key = `__consent_opt__${p.purposeId}__${e.name}`; - return {approved: !isDeny && processedInputs[key] === 'true', name: e.name}; + return getConsentElementDecision( + p.purposeId, + e, + !isDeny && processedInputs[key] === 'true', + processedInputs, + ); }), ], purposeName: p.purposeName, @@ -421,7 +429,13 @@ const SignIn: Component = defineComponent({ processedInputs['consent_decisions'] = JSON.stringify(decisions); Object.keys(processedInputs).forEach((key: string) => { - if (key.startsWith('__consent_opt__')) delete processedInputs[key]; + if ( + key.startsWith('__consent_opt__') || + key.startsWith(CONSENT_MODIFIED_KEY_PREFIX) || + key.startsWith(CONSENT_INVALID_KEY_PREFIX) + ) { + delete processedInputs[key]; + } }); } catch { // Ignore consent construction failures diff --git a/packages/nuxt/tests/unit/signin-consent-decisions.test.ts b/packages/nuxt/tests/unit/signin-consent-decisions.test.ts new file mode 100644 index 00000000..68b33907 --- /dev/null +++ b/packages/nuxt/tests/unit/signin-consent-decisions.test.ts @@ -0,0 +1,172 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +/* eslint-disable @typescript-eslint/typedef, @typescript-eslint/no-explicit-any */ + +import {describe, it, expect, vi, beforeEach} from 'vitest'; +import {nextTick, ref} from 'vue'; + +// ── Module mocks ────────────────────────────────────────────────────────────── + +const signIn = vi.fn(); +const consentPrompt = { + purposes: [ + { + essential: [{name: 'email'}], + optional: [{name: 'phone'}], + purposeId: 'p1', + purposeName: 'Profile', + type: 'attributes', + }, + { + essential: [], + optional: [ + { + detailType: 'payment_initiation', + name: 'd-1', + rows: [{as: 'number', label: 'Amount', modification: 'decrease', path: 'instructedAmount.amount', value: 35}], + }, + {detailType: 'payment_initiation', name: 'd-2', rows: []}, + ], + purposeId: 'authorization_details:app1', + purposeName: 'authorization_details:app1', + type: 'authorization_details', + }, + ], +}; +const components = [ + {eventType: 'SUBMIT', id: 'allow', type: 'ACTION', variant: 'PRIMARY'}, + {eventType: 'SUBMIT', id: 'deny', type: 'ACTION', variant: 'SECONDARY'}, +]; + +// The Vue layer is stubbed; the consent helpers come from @thunderid/browser, so the test runs the helpers SignIn +// really uses. +vi.mock('@thunderid/vue', () => ({ + BaseSignIn: {name: 'BaseSignIn'}, + extractErrorMessage: vi.fn((error: unknown) => String(error)), + handlePasskeyAuthentication: vi.fn(), + handlePasskeyRegistration: vi.fn(), + initiateOAuthRedirect: vi.fn(), + normalizeFlowResponse: vi.fn(() => ({additionalData: {consentPrompt}, components, executionId: 'exec-1'})), + useFlowMeta: vi.fn(() => ({meta: ref(null)})), + useI18n: vi.fn(() => ({t: (key: string) => key})), + useOAuthCallback: vi.fn(), +})); + +vi.mock('#imports', () => ({ + useThunderID: vi.fn(() => ({ + afterSignInUrl: undefined, + applicationId: 'app1', + getStorageManager: () => undefined, + isInitialized: ref(true), + isLoading: ref(false), + scopes: undefined, + signIn, + vendor: 'thunderid', + })), +})); + +// ── Imports (after mocks) ───────────────────────────────────────────────────── + +import SignIn, {type SignInRenderProps} from '../../src/runtime/components/auth/SignIn'; + +/** + * Sets the SignIn container up on the consent step and returns its render props. + */ +const setUpOnConsentStep = async (): Promise => { + let renderProps: SignInRenderProps | undefined; + const render = (SignIn as any).setup( + {className: '', size: 'medium', variant: 'outlined'}, + { + attrs: {}, + emit: vi.fn(), + slots: { + default: (props: SignInRenderProps) => { + renderProps = props; + return []; + }, + }, + }, + ); + await nextTick(); + await Promise.resolve(); + render(); + + return renderProps!; +}; + +const submittedInputs = (): Record => signIn.mock.calls[1][0].inputs as Record; +const submittedDecisions = (): any => JSON.parse(submittedInputs()['consent_decisions']); + +describe('SignIn consent decisions', () => { + beforeEach(() => { + signIn.mockReset(); + signIn.mockResolvedValueOnce({executionId: 'exec-1', flowStatus: 'INCOMPLETE'}); + signIn.mockResolvedValueOnce({executionId: 'exec-1', flowStatus: 'INCOMPLETE'}); + }); + + it('approves only the authorization details the user approved and sends their modified values', async () => { + const {onSubmit} = await setUpOnConsentStep(); + + await onSubmit({ + action: 'allow', + inputs: { + '__consent_invalid__authorization_details:app1__d-1__instructedAmount.amount': '', + '__consent_modified__authorization_details:app1__d-1__instructedAmount.amount': '20', + '__consent_opt__authorization_details:app1__d-1': 'true', + __consent_opt__p1__phone: 'true', + }, + }); + + expect(Object.keys(submittedInputs())).toEqual(['consent_decisions']); + expect(submittedDecisions()).toEqual({ + approved: true, + purposes: [ + { + approved: true, + elements: [ + {approved: true, name: 'email'}, + {approved: true, name: 'phone'}, + ], + purposeName: 'Profile', + }, + { + approved: true, + elements: [ + {approved: true, modified: {'instructedAmount.amount': 20}, name: 'd-1'}, + {approved: false, name: 'd-2'}, + ], + purposeName: 'authorization_details:app1', + }, + ], + }); + }); + + it('does not approve an authorization detail by default', async () => { + const {onSubmit} = await setUpOnConsentStep(); + + await onSubmit({ + action: 'allow', + inputs: {'__consent_modified__authorization_details:app1__d-1__instructedAmount.amount': '20'}, + }); + + expect(submittedDecisions().purposes[1].elements).toEqual([ + {approved: false, name: 'd-1'}, + {approved: false, name: 'd-2'}, + ]); + }); + + it('denies everything on the deny action', async () => { + const {onSubmit} = await setUpOnConsentStep(); + + await onSubmit({action: 'deny', inputs: {'__consent_opt__authorization_details:app1__d-1': 'true'}}); + + expect(submittedDecisions().approved).toBe(false); + expect(submittedDecisions().reason).toBe('user_denied'); + expect(submittedDecisions().purposes[1].elements).toEqual([ + {approved: false, name: 'd-1'}, + {approved: false, name: 'd-2'}, + ]); + expect(Object.keys(submittedInputs())).toEqual(['consent_decisions']); + }); +}); diff --git a/packages/react/src/components/adapters/Consent.tsx b/packages/react/src/components/adapters/Consent.tsx index a188239b..f5af2b0b 100644 --- a/packages/react/src/components/adapters/Consent.tsx +++ b/packages/react/src/components/adapters/Consent.tsx @@ -2,7 +2,8 @@ // SPDX-License-Identifier: Apache-2.0 import {type ConsentPurposeData, PromptElement} from '@thunderid/browser'; -import {type ChangeEvent, FC, ReactNode} from 'react'; +import {type ChangeEvent, FC, ReactNode, useEffect} from 'react'; +import ConsentAuthorizationDetail from './ConsentAuthorizationDetail'; import ConsentCheckboxList, {getConsentOptionalKey} from './ConsentCheckboxList'; import Typography from '../primitives/Typography/Typography'; import Toggle from '../primitives/Toggle/Toggle'; @@ -10,6 +11,7 @@ import {Info} from '../primitives/Icons'; import Tooltip from '../primitives/Tooltip/Tooltip'; import {UseTranslation} from '../../hooks/useTranslation'; import {css} from '../../styles/emotion'; +import isConsentApprovalBlocked from '../../utils/isConsentApprovalBlocked'; /** * Backward-compatible consent purpose type exported by @thunderid/react. @@ -24,6 +26,12 @@ export type ConsentPurpose = ConsentPurposeData; export interface ConsentRenderProps { /** Current form values - used to read optional checkbox state. */ formValues: Record; + /** + * Whether the consent cannot be approved yet, because an authorization detail the user has not declined + * shows a modified value that cannot be submitted. Disable the approve action while it holds; denying + * stays possible. + */ + isApprovalBlocked: boolean; /** Callback invoked when a user toggles an optional attribute. */ onInputChange: (name: string, value: string) => void; /** The resolved list of consent purposes parsed from `consentData`. */ @@ -142,6 +150,31 @@ const Consent: FC = ({consentData, formValues, onInputChange, chil }); }; + // An authorization detail is the transaction the user is asked to allow, so it starts approved. The value + // is seeded into the form so the submitted decision matches the screen. + useEffect(() => { + let detailPurposes: ConsentPurposeData[] = []; + try { + const parsed: ConsentPurposeData[] | {purposes: ConsentPurposeData[]} | undefined = + typeof consentData === 'string' + ? (JSON.parse(consentData) as ConsentPurposeData[] | {purposes: ConsentPurposeData[]}) + : consentData; + detailPurposes = (Array.isArray(parsed) ? parsed : (parsed?.purposes ?? [])).filter( + (purpose: ConsentPurposeData) => purpose.type === 'authorization_details', + ); + } catch { + return; + } + detailPurposes.forEach((purpose: ConsentPurposeData) => { + (purpose.optional ?? []).forEach((element: PromptElement) => { + const key: string = getConsentOptionalKey(purpose.purposeId, element.name); + if (formValues[key] === undefined) { + onInputChange(key, 'true'); + } + }); + }); + }, [consentData, formValues, onInputChange]); + if (!consentData) return null; let purposes: ConsentPurposeData[] = []; @@ -159,7 +192,16 @@ const Consent: FC = ({consentData, formValues, onInputChange, chil if (purposes.length === 0) return null; if (children) { - return <>{children({formValues, onInputChange, purposes: purposes})}; + return ( + <> + {children({ + formValues, + isApprovalBlocked: isConsentApprovalBlocked(purposes, formValues), + onInputChange, + purposes, + })} + + ); } return ( @@ -174,7 +216,19 @@ const Consent: FC = ({consentData, formValues, onInputChange, chil {purpose.description} */} - {purpose.essential && purpose.essential.length > 0 && ( + {purpose.type === 'authorization_details' && + (purpose.optional ?? []).map((element: PromptElement) => ( + + ))} + + {purpose.type !== 'authorization_details' && purpose.essential && purpose.essential.length > 0 && (
@@ -196,7 +250,7 @@ const Consent: FC = ({consentData, formValues, onInputChange, chil
)} - {purpose.optional && purpose.optional.length > 0 && ( + {purpose.type !== 'authorization_details' && purpose.optional && purpose.optional.length > 0 && (
diff --git a/packages/react/src/components/adapters/ConsentAuthorizationDetail.styles.ts b/packages/react/src/components/adapters/ConsentAuthorizationDetail.styles.ts new file mode 100644 index 00000000..8e779a68 --- /dev/null +++ b/packages/react/src/components/adapters/ConsentAuthorizationDetail.styles.ts @@ -0,0 +1,45 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {Theme} from '@thunderid/browser'; +import {useMemo} from 'react'; +import {css} from '../../styles/emotion'; + +/** + * Creates styles for the ConsentAuthorizationDetail component. + * @param theme - The theme object containing design tokens + * @param colorScheme - The current color scheme (used for memoization) + * @returns Object containing CSS class names for component styling + */ +const useStyles = (theme: Theme, colorScheme: string): Record => + useMemo( + () => ({ + card: css({marginTop: '0.5rem'}), + label: css({flex: '1 1 auto', lineHeight: 1.45, minWidth: 0, overflowWrap: 'anywhere'}), + // A short field sits beside its label and drops below it when the two do not fit; a long one is + // stacked, the label above the value, so neither squeezes the other. + row: css({ + alignItems: 'center', + display: 'flex', + flexWrap: 'wrap', + gap: '4px 1rem', + justifyContent: 'space-between', + }), + rows: css({display: 'flex', flexDirection: 'column', gap: '8px', margin: '0.5rem 0'}), + stackedRow: css({display: 'flex', flexDirection: 'column', gap: '4px'}), + stackedValue: css({display: 'flex', maxWidth: '100%', minWidth: 0}), + value: css({ + alignItems: 'center', + display: 'flex', + gap: '6px', + justifyContent: 'flex-end', + marginLeft: 'auto', + maxWidth: '100%', + minWidth: 0, + textAlign: 'right', + }), + }), + [theme, colorScheme], + ); + +export default useStyles; diff --git a/packages/react/src/components/adapters/ConsentAuthorizationDetail.tsx b/packages/react/src/components/adapters/ConsentAuthorizationDetail.tsx new file mode 100644 index 00000000..cef4cc6d --- /dev/null +++ b/packages/react/src/components/adapters/ConsentAuthorizationDetail.tsx @@ -0,0 +1,172 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {bem, type ConsentDetailRow, type PromptElement, withVendorCSSClassPrefix} from '@thunderid/browser'; +import {type ChangeEvent, FC, ReactNode} from 'react'; +import useStyles from './ConsentAuthorizationDetail.styles'; +import {getConsentOptionalKey} from './ConsentCheckboxList'; +import ConsentDetailField from './ConsentDetailField'; +import ConsentDetailText from './ConsentDetailText'; +import resolveConsentText from './resolveConsentText'; +import useTheme from '../../contexts/Theme/useTheme'; +import {UseTranslation} from '../../hooks/useTranslation'; +import {cx} from '../../styles/emotion'; +import getConsentDetailLayout, {CONSENT_DETAIL_DISPLAY_LIMITS} from '../../utils/getConsentDetailLayout'; +import Toggle from '../primitives/Toggle/Toggle'; +import Typography from '../primitives/Typography/Typography'; + +/** + * Render props exposed by ConsentAuthorizationDetail when using the render-prop pattern. + */ +export interface ConsentAuthorizationDetailRenderProps { + /** RFC 9396 authorization details type of the detail. */ + detailType?: string; + /** The detail's consent element. */ + element: PromptElement; + /** Call this when the user approves or declines the detail as a whole. */ + handleChange: (checked: boolean) => void; + /** + * Whether the detail is approved. A detail is the transaction the user is asked to allow, so it counts as + * approved until the user declines it. + */ + isApproved: boolean; + /** Fields of the detail; render each with `ConsentDetailField` or `useConsentDetailField`. */ + rows: ConsentDetailRow[]; + /** Server-rendered sentence describing the detail, or its type or identifier when it has none. */ + summary: string; +} + +/** + * Props for the ConsentAuthorizationDetail component. + */ +export interface ConsentAuthorizationDetailProps { + /** + * Render-props callback. When provided, the default detail UI is replaced with whatever JSX the + * callback returns. + * + * @example + * ```tsx + * + * {({summary, rows, isApproved, handleChange}) => ( + *
+ *

{summary}

+ * {rows.map(row => ( + * + * ))} + * handleChange(e.target.checked)} /> + *
+ * )} + *
+ * ``` + */ + children?: (props: ConsentAuthorizationDetailRenderProps) => ReactNode; + /** The authorization detail element of the consent purpose. */ + element: PromptElement; + /** Current form values, holding the approval of the detail and the values the user modified. */ + formValues: Record; + /** Callback invoked with a consent form key and its new value. */ + onInputChange: (name: string, value: string) => void; + /** The identifier of the authorization details purpose the detail belongs to. */ + purposeId: string; + /** translation data */ + t?: UseTranslation['t']; +} + +/** + * Renders one RFC 9396 authorization detail element: the server-rendered summary, the detail's fields, + * which are read-only unless the user may modify them, and a single toggle that approves or declines the + * detail as a whole. The summary and labels come from the server and are not translated again. + * Authorization details reach the consent step only on a sign-in started from an authorization request, so + * this renders in an authorization server UI (hosted login); an app-native sign-in flow cannot carry them. + */ +const ConsentAuthorizationDetail: FC = ({ + children = undefined, + element, + formValues, + onInputChange, + purposeId, + t = undefined, +}: ConsentAuthorizationDetailProps) => { + const {theme, colorScheme}: ReturnType = useTheme(); + const styles: Record = useStyles(theme, colorScheme); + + const key: string = getConsentOptionalKey(purposeId, element.name); + const isApproved: boolean = formValues[key] !== 'false'; + const rows: ConsentDetailRow[] = element.rows ?? []; + const summary: string = element.summary ?? element.detailType ?? element.name; + + const handleChange = (checked: boolean): void => { + onInputChange(key, checked ? 'true' : 'false'); + }; + + if (children) { + return <>{children({detailType: element.detailType, element, handleChange, isApproved, rows, summary})}; + } + + return ( +
+ + {rows.length > 0 && ( +
+ {rows.map((row: ConsentDetailRow) => { + const stacked: boolean = getConsentDetailLayout(row) === 'stacked'; + return ( +
+ + {row.label || row.path} + +
+ +
+
+ ); + })} +
+ )} + ): void => handleChange(e.target.checked)} + /> +
+ ); +}; + +export default ConsentAuthorizationDetail; diff --git a/packages/react/src/components/adapters/ConsentDetailField.styles.ts b/packages/react/src/components/adapters/ConsentDetailField.styles.ts new file mode 100644 index 00000000..83b5d19e --- /dev/null +++ b/packages/react/src/components/adapters/ConsentDetailField.styles.ts @@ -0,0 +1,76 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {Theme} from '@thunderid/browser'; +import {useMemo} from 'react'; +import {css} from '../../styles/emotion'; + +/** + * Creates styles for the ConsentDetailField component. + * @param theme - The theme object containing design tokens + * @param colorScheme - The current color scheme (used for memoization) + * @param align - Side the value is aligned to: `end` beside its label, `start` when stacked below it + * @returns Object containing CSS class names for component styling + */ +const useStyles = (theme: Theme, colorScheme: string, align: 'start' | 'end'): Record => + useMemo(() => { + const chip: string = css({ + alignItems: 'center', + borderRadius: '9999px', + boxSizing: 'border-box', + cursor: 'pointer', + display: 'inline-flex', + font: 'inherit', + fontSize: '0.8125rem', + gap: '6px', + maxWidth: '100%', + minWidth: 0, + padding: '2px 10px', + '&:disabled': {cursor: 'default'}, + }); + + return { + // A chip keeps to one line; a long item is cut short with an ellipsis and shown in full on hover. + chipLabel: css({minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap'}), + chips: css({ + alignItems: 'center', + display: 'flex', + flexWrap: 'wrap', + gap: '6px', + justifyContent: align === 'end' ? 'flex-end' : 'flex-start', + maxWidth: '100%', + minWidth: 0, + // Stacked below its label, the list takes the row's width and wraps within it; a chip's one-line + // label must not widen the screen it sits in. + ...(align === 'start' && {contain: 'inline-size', width: '100%'}), + }), + input: css({maxWidth: '10rem'}), + keptChip: css(chip, { + background: 'transparent', + border: `1px solid ${theme.vars.colors.primary.main}`, + color: theme.vars.colors.primary.main, + }), + listText: css({ + alignItems: 'baseline', + display: 'flex', + flexWrap: 'wrap', + gap: '2px 6px', + justifyContent: align === 'end' ? 'flex-end' : 'flex-start', + maxWidth: '100%', + minWidth: 0, + }), + offeredChip: css(chip, { + background: 'transparent', + border: `1px dashed ${theme.vars.colors.border}`, + color: theme.vars.colors.text.secondary, + }), + removedChip: css(chip, { + background: 'transparent', + border: `1px solid ${theme.vars.colors.border}`, + color: theme.vars.colors.text.secondary, + textDecoration: 'line-through', + }), + }; + }, [theme, colorScheme, align]); + +export default useStyles; diff --git a/packages/react/src/components/adapters/ConsentDetailField.tsx b/packages/react/src/components/adapters/ConsentDetailField.tsx new file mode 100644 index 00000000..22b1bb13 --- /dev/null +++ b/packages/react/src/components/adapters/ConsentDetailField.tsx @@ -0,0 +1,274 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {bem, type ConsentDetailRow, withVendorCSSClassPrefix} from '@thunderid/browser'; +import {type ChangeEvent, FC, type KeyboardEvent, ReactElement, ReactNode} from 'react'; +import useStyles from './ConsentDetailField.styles'; +import ConsentDetailText, {ConsentDetailToggle} from './ConsentDetailText'; +import resolveConsentText from './resolveConsentText'; +import useTheme from '../../contexts/Theme/useTheme'; +import useConsentDetailField, { + type ConsentDetailListItem, + type UseConsentDetailFieldResult, +} from '../../hooks/useConsentDetailField'; +import {UseTranslation} from '../../hooks/useTranslation'; +import {cx} from '../../styles/emotion'; +import formatConsentDetailValue from '../../utils/formatConsentDetailValue'; +import {CONSENT_DETAIL_DISPLAY_LIMITS} from '../../utils/getConsentDetailLayout'; +import isConsentDetailPlainList from '../../utils/isConsentDetailPlainList'; +import isConsentDetailStructured from '../../utils/isConsentDetailStructured'; +import TextField from '../primitives/TextField/TextField'; + +/** + * Props for the ConsentDetailField component. + */ +export interface ConsentDetailFieldProps { + /** Side the value is aligned to: `end` beside its label, `start` when stacked below it. Defaults to `end`. */ + align?: 'start' | 'end'; + /** + * Render-props callback. When provided, the default field UI is replaced with whatever it returns, given + * the field's headless state from `useConsentDetailField`. + */ + children?: (field: UseConsentDetailFieldResult) => ReactNode; + /** Current form values, holding the JSON text of each field the user changed. */ + formValues: Record; + /** Callback invoked with a consent form key and its new value. */ + onInputChange: (name: string, value: string) => void; + /** The identifier of the authorization detail the field belongs to. */ + detailId: string; + /** The identifier of the consent purpose the authorization detail belongs to. */ + purposeId: string; + /** The field to render. */ + row: ConsentDetailRow; + /** translation data */ + t?: UseTranslation['t']; +} + +/** + * Renders one field of an authorization detail. A field the user may modify before + * approving is editable, bounded by the value that was shown: a number or a date can only move in + * the allowed direction, and items can only be removed from, or added to, a list as its rule allows. Every other field is read-only. + * A number or a date the user enters outside its bound, or that is not a value at all, is shown with an error and + * is not submitted: the field's modification is cleared and the field is marked invalid, which blocks approval + * until the user corrects it. The server checks whatever is submitted. + */ +const ConsentDetailField: FC = ({ + align = 'end', + children = undefined, + formValues, + onInputChange, + detailId, + purposeId, + row, + t = undefined, +}: ConsentDetailFieldProps): ReactElement => { + const field: UseConsentDetailFieldResult = useConsentDetailField({ + detailId, + formValues, + onInputChange, + purposeId, + row, + t, + }); + const {inputId, label, listDisplay} = field; + const {theme, colorScheme}: ReturnType = useTheme(); + const styles: Record = useStyles(theme, colorScheme, align); + const fieldClass: string = withVendorCSSClassPrefix(bem('consent-detail-field')); + const collapse = (items: ReactNode[]): ReactNode[] => items.slice(0, listDisplay.visibleCount); + const listToggle: ReactNode = listDisplay.isCollapsible && ( + + ); + const chipClass = (modifier: 'kept' | 'removed' | 'offered'): string => + cx( + withVendorCSSClassPrefix(bem('consent-detail-field', 'chip')), + withVendorCSSClassPrefix(bem('consent-detail-field', 'chip', modifier)), + styles[`${modifier}Chip`], + ); + const chipLabelClass: string = cx( + withVendorCSSClassPrefix(bem('consent-detail-field', 'chip-label')), + styles['chipLabel'], + ); + const chipsClass: string = cx( + fieldClass, + withVendorCSSClassPrefix(bem('consent-detail-field', 'chips')), + styles['chips'], + ); + const inputClass: string = cx( + fieldClass, + withVendorCSSClassPrefix(bem('consent-detail-field', 'input')), + styles['input'], + ); + const valueClass: string = cx(fieldClass, withVendorCSSClassPrefix(bem('consent-detail-field', 'value'))); + + if (children) { + return <>{children(field)}; + } + + if (field.kind === 'number' || field.kind === 'date') { + return ( + ): void => field.handleChange(e.target.value)} + onBlur={field.handleBlur} + /> + ); + } + + if (field.kind === 'removeItems') { + // A kept item can be removed, except the last one: to grant none of them the user declines the + // detail instead. A removed item stays visible so the user can add it back. + return ( +
+ {collapse( + field.items.map(({selected, toggleable, value}: ConsentDetailListItem, index: number) => ( + + )), + )} + {listToggle} +
+ ); + } + + if (field.kind === 'addItems') { + // The items shown cannot be removed, since removing one would grant more. Items the user adds can be + // taken out again before approving. + return ( +
+ {collapse( + field.items.map(({origin, value}: ConsentDetailListItem, index: number) => { + if (origin === 'shown') { + return ( + + {String(value)} + + ); + } + return ( + + ); + }), + )} + {listToggle} + {field.isTypedItemAllowed && ( + ): void => field.handleChange(e.target.value)} + onKeyDown={(e: KeyboardEvent): void => { + if (e.key === 'Enter') { + e.preventDefault(); + field.handleAdd(e.currentTarget.value); + } + }} + /> + )} +
+ ); + } + + const textAlign: 'left' | 'right' = align === 'end' ? 'right' : 'left'; + + // A long list of plain values shows its first items, the rest behind a toggle. + if (isConsentDetailPlainList(row.value) && listDisplay.isCollapsible) { + const items: (string | number | boolean)[] = row.value.slice(0, listDisplay.visibleCount); + return ( +
+ + {listToggle} +
+ ); + } + + // Structured JSON reads as an indented block rather than one long line. + if (isConsentDetailStructured(row.value)) { + return ( + + ); + } + + return ( + + ); +}; + +export default ConsentDetailField; diff --git a/packages/react/src/components/adapters/ConsentDetailText.styles.ts b/packages/react/src/components/adapters/ConsentDetailText.styles.ts new file mode 100644 index 00000000..b4c652d1 --- /dev/null +++ b/packages/react/src/components/adapters/ConsentDetailText.styles.ts @@ -0,0 +1,88 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {Theme} from '@thunderid/browser'; +import {useMemo} from 'react'; +import {css} from '../../styles/emotion'; + +/** + * Creates styles for the ConsentDetailToggle component. + * @param theme - The theme object containing design tokens + * @param colorScheme - The current color scheme (used for memoization) + * @returns Object containing CSS class names for component styling + */ +export const useToggleStyles = (theme: Theme, colorScheme: string): Record => + useMemo( + () => ({ + toggle: css({ + background: 'transparent', + border: 'none', + borderRadius: '4px', + color: theme.vars.colors.primary.main, + cursor: 'pointer', + font: 'inherit', + fontSize: '0.8125rem', + padding: '2px 4px', + '&:hover': {textDecoration: 'underline'}, + '&:focus-visible': {outline: `2px solid ${theme.vars.colors.primary.main}`, outlineOffset: '1px'}, + }), + }), + [theme, colorScheme], + ); + +/** + * Creates styles for the ConsentDetailText component. + * @param theme - The theme object containing design tokens + * @param colorScheme - The current color scheme (used for memoization) + * @param align - Alignment of the text + * @param monospace - Whether the text is shown as a preformatted monospace block + * @param maxLines - Number of lines the text is clamped to + * @returns Object containing CSS class names for component styling + */ +const useStyles = ( + theme: Theme, + colorScheme: string, + align: 'left' | 'right', + monospace: boolean, + maxLines: number, +): Record => + useMemo( + () => ({ + clamp: css({ + display: '-webkit-box', + overflow: 'hidden', + WebkitBoxOrient: 'vertical', + WebkitLineClamp: maxLines, + }), + container: css({ + alignItems: align === 'right' ? 'flex-end' : 'flex-start', + display: 'flex', + flexDirection: 'column', + gap: '2px', + maxWidth: '100%', + minWidth: 0, + }), + monospace: css({ + background: theme.vars.colors.action.hover, + border: `1px solid ${theme.vars.colors.border}`, + borderRadius: '4px', + boxSizing: 'border-box', + fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace', + fontSize: '0.75rem', + padding: '6px 8px', + width: '100%', + }), + text: css({ + lineHeight: 1.45, + margin: 0, + maxWidth: '100%', + overflowWrap: 'anywhere', + textAlign: align, + whiteSpace: monospace ? 'pre-wrap' : 'normal', + wordBreak: 'normal', + }), + }), + [theme, colorScheme, align, monospace, maxLines], + ); + +export default useStyles; diff --git a/packages/react/src/components/adapters/ConsentDetailText.tsx b/packages/react/src/components/adapters/ConsentDetailText.tsx new file mode 100644 index 00000000..c795ed56 --- /dev/null +++ b/packages/react/src/components/adapters/ConsentDetailText.tsx @@ -0,0 +1,133 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {bem, withVendorCSSClassPrefix} from '@thunderid/browser'; +import {FC, ReactElement, useState} from 'react'; +import useStyles, {useToggleStyles} from './ConsentDetailText.styles'; +import resolveConsentText from './resolveConsentText'; +import useTheme from '../../contexts/Theme/useTheme'; +import {UseTranslation} from '../../hooks/useTranslation'; +import {cx} from '../../styles/emotion'; +import isConsentDetailTextClamped from '../../utils/isConsentDetailTextClamped'; +import Typography from '../primitives/Typography/Typography'; + +/** + * Props for the ConsentDetailToggle component. + */ +export interface ConsentDetailToggleProps { + /** Id of the element the toggle shows more or less of. */ + controls?: string; + /** Whether everything is shown. */ + expanded: boolean; + /** Number of items hidden while collapsed. When given, the collapsed label counts them. */ + hiddenCount?: number; + /** Callback invoked with the next expanded state. */ + onToggle: (expanded: boolean) => void; + /** translation data */ + t?: UseTranslation['t']; +} + +/** + * Button that expands or collapses long authorization detail content. + */ +export const ConsentDetailToggle: FC = ({ + controls = undefined, + expanded, + hiddenCount = undefined, + onToggle, + t = undefined, +}: ConsentDetailToggleProps): ReactElement => { + const {theme, colorScheme}: ReturnType = useTheme(); + const styles: Record = useToggleStyles(theme, colorScheme); + + let label: string = resolveConsentText(t, 'consent.authorization_details.show_less'); + if (!expanded) { + label = + hiddenCount === undefined + ? resolveConsentText(t, 'consent.authorization_details.show_more') + : resolveConsentText(t, 'consent.authorization_details.more_items', {count: hiddenCount}); + } + + return ( + + ); +}; + +/** + * Props for the ConsentDetailText component. + */ +export interface ConsentDetailTextProps { + /** Alignment of the text. */ + align?: 'left' | 'right'; + /** Class name applied to the text. */ + className?: string; + /** Weight of the text. */ + fontWeight?: 'normal' | 'medium' | 'semibold' | 'bold'; + /** Id of the text element. */ + id?: string; + /** Number of characters above which the text is clamped. */ + maxChars: number; + /** Number of lines the text is clamped to. */ + maxLines: number; + /** Shows the text as a preformatted monospace block, for structured JSON. */ + monospace?: boolean; + /** translation data */ + t?: UseTranslation['t']; + /** Text to show. */ + text: string; + /** Typography variant of the text. */ + variant?: 'body2' | 'subtitle2'; +} + +/** + * Shows authorization detail text that wraps anywhere, so a long unbroken token such as an IBAN or a + * URL never overflows. Text longer than its limit is clamped behind a toggle. + */ +const ConsentDetailText: FC = ({ + align = 'left', + className = undefined, + fontWeight = undefined, + id = undefined, + maxChars, + maxLines, + monospace = false, + t = undefined, + text, + variant = 'body2', +}: ConsentDetailTextProps): ReactElement => { + const [expanded, setExpanded] = useState(false); + const {theme, colorScheme}: ReturnType = useTheme(); + const styles: Record = useStyles(theme, colorScheme, align, monospace, maxLines); + const long: boolean = isConsentDetailTextClamped(text, maxChars, maxLines); + + return ( +
+ + {text} + + {long && } +
+ ); +}; + +export default ConsentDetailText; diff --git a/packages/react/src/components/adapters/__tests__/Consent.test.tsx b/packages/react/src/components/adapters/__tests__/Consent.test.tsx new file mode 100644 index 00000000..936eb0df --- /dev/null +++ b/packages/react/src/components/adapters/__tests__/Consent.test.tsx @@ -0,0 +1,127 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {render} from '@testing-library/react'; +import {createTheme} from '@thunderid/browser'; +import {describe, expect, it, vi} from 'vitest'; +import ThemeContext, {type ThemeContextValue} from '../../../contexts/Theme/ThemeContext'; +import Consent from '../Consent'; + +const themeContextValue: ThemeContextValue = { + colorScheme: 'light', + direction: 'ltr', + theme: createTheme(), + toggleTheme: vi.fn(), +}; + +const iban = 'DE89370400440532013000DE89370400440532013000DE89370400440532013000'; + +const renderConsent = (summary: string, rows: object[]): HTMLElement => + render( + +
+ +
+
, + ).container; + +describe('Consent authorization details', () => { + it('clamps a long summary behind a toggle that controls it', () => { + const summary = `Pay ${'a very long description '.repeat(10)}`.trim(); + const container: HTMLElement = renderConsent(summary, []); + const toggle: HTMLButtonElement = container.querySelector('button[aria-expanded]')!; + + expect( + container.querySelector('#consent_authz_detail_authorization_details\\:app-1_d-1_summary')!.textContent, + ).toBe(summary); + expect(toggle.getAttribute('aria-controls')).toBe('consent_authz_detail_authorization_details:app-1_d-1_summary'); + expect(toggle.textContent).toBe('Show more'); + }); + + it('keeps long content inside a narrow screen and stacks a long field below its label', () => { + const container: HTMLElement = renderConsent(`Pay to ${iban}`, [ + {label: 'Pay to', path: 'creditorName', value: 'Merchant A'}, + {label: 'Account', path: 'account', value: `${iban}X`}, + {label: 'Accounts', modification: 'removeItems', path: 'accounts', value: [iban, 'x']}, + {label: 'Schedule', path: 'schedule', value: [{date: '2026-11-01', memo: iban}]}, + ]); + const root = container.firstElementChild as HTMLElement; + const top = (text: string): number => + Array.from(container.querySelectorAll('p, h6, span, div')) + .find((element: Element) => element.textContent === text && element.children.length === 0)! + .getBoundingClientRect().top; + + expect(root.scrollWidth).toBeLessThanOrEqual(280); + expect(top('Merchant A')).toBe(top('Pay to')); + expect(top(`${iban}X`)).toBeGreaterThan(top('Account')); + }); + + it('tells a render-prop child when approval is blocked by an invalid field', () => { + const purposes = [ + { + essential: [], + optional: [ + { + detailType: 'payment_initiation', + name: 'd-1', + rows: [{label: 'Amount', modification: 'decrease', path: 'amount', value: 35}], + }, + ], + purposeId: 'authorization_details:app-1', + purposeName: 'authorization_details:app-1', + type: 'authorization_details', + } as never, + ]; + const seen: boolean[] = []; + const renderWith = (formValues: Record): void => { + render( + + {({isApprovalBlocked}) => { + seen.push(isApprovalBlocked); + return null; + }} + , + ); + }; + + renderWith({'__consent_invalid__authorization_details:app-1__d-1__amount': 'true'}); + renderWith({'__consent_invalid__authorization_details:app-1__d-1__amount': ''}); + + expect(seen).toEqual([true, false]); + }); + + it('reads the prompt from the JSON text the server sends', () => { + const onInputChange = vi.fn(); + render( + + + , + ); + + expect(onInputChange).toHaveBeenCalledWith('__consent_opt__authorization_details:app-1__d-1', 'true'); + }); +}); diff --git a/packages/react/src/components/adapters/__tests__/ConsentAuthorizationDetail.test.tsx b/packages/react/src/components/adapters/__tests__/ConsentAuthorizationDetail.test.tsx new file mode 100644 index 00000000..c52cad27 --- /dev/null +++ b/packages/react/src/components/adapters/__tests__/ConsentAuthorizationDetail.test.tsx @@ -0,0 +1,111 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {fireEvent, render} from '@testing-library/react'; +import {createTheme, type PromptElement} from '@thunderid/browser'; +import {describe, expect, it, vi} from 'vitest'; +import ThemeContext, {type ThemeContextValue} from '../../../contexts/Theme/ThemeContext'; +import ConsentAuthorizationDetail, {type ConsentAuthorizationDetailRenderProps} from '../ConsentAuthorizationDetail'; + +const themeContextValue: ThemeContextValue = { + colorScheme: 'light', + direction: 'ltr', + theme: createTheme(), + toggleTheme: vi.fn(), +}; + +const PURPOSE_ID = 'authorization_details:app-1'; +const APPROVAL_KEY = '__consent_opt__authorization_details:app-1__d-1'; + +const payment: PromptElement = { + detailType: 'payment_initiation', + name: 'd-1', + rows: [ + {as: 'number', label: 'Amount', modification: 'decrease', path: 'amount', value: 35}, + {label: 'Pay to', path: 'creditorName', value: 'Merchant A'}, + ], + summary: 'Pay 35 EUR to Merchant A', +}; + +const renderDetail = ( + element: PromptElement, + formValues: Record, + children?: (props: ConsentAuthorizationDetailRenderProps) => null, +): {container: HTMLElement; onInputChange: ReturnType} => { + const onInputChange = vi.fn(); + const {container} = render( + + + {children} + + , + ); + + return {container, onInputChange}; +}; + +describe('ConsentAuthorizationDetail', () => { + it('hands a render-prop child the detail and its approval', () => { + const child = vi.fn<(props: ConsentAuthorizationDetailRenderProps) => null>(() => null); + const {onInputChange} = renderDetail(payment, {}, child); + const props: ConsentAuthorizationDetailRenderProps = child.mock.calls[0][0]; + + expect(props).toMatchObject({ + detailType: 'payment_initiation', + element: payment, + isApproved: true, + rows: payment.rows, + summary: 'Pay 35 EUR to Merchant A', + }); + + props.handleChange(false); + expect(onInputChange).toHaveBeenCalledWith(APPROVAL_KEY, 'false'); + }); + + it('counts a detail as approved until the user declines it', () => { + const child = vi.fn<(props: ConsentAuthorizationDetailRenderProps) => null>(() => null); + renderDetail(payment, {[APPROVAL_KEY]: 'false'}, child); + + expect(child.mock.calls[0][0].isApproved).toBe(false); + }); + + it('falls back to the detail type, then the identifier, when there is no summary', () => { + const child = vi.fn<(props: ConsentAuthorizationDetailRenderProps) => null>(() => null); + renderDetail({detailType: 'payment_initiation', name: 'd-1'}, {}, child); + renderDetail({name: 'd-1'}, {}, child); + + expect(child.mock.calls[0][0].summary).toBe('payment_initiation'); + expect(child.mock.calls[1][0].summary).toBe('d-1'); + expect(child.mock.calls[1][0].rows).toEqual([]); + }); + + it('renders the summary, the fields and an approve toggle by default', () => { + const {container, onInputChange} = renderDetail(payment, {}); + const toggle = document.getElementById(`consent_opt_${PURPOSE_ID}_d-1`) as HTMLInputElement; + + expect( + container.querySelector('#consent_authz_detail_authorization_details\\:app-1_d-1_summary')!.textContent, + ).toBe('Pay 35 EUR to Merchant A'); + expect(container.querySelector('#consent_modify_authorization_details\\:app-1_d-1_amount')).not.toBeNull(); + expect(container.textContent).toContain('Merchant A'); + expect(container.textContent).toContain('Approve'); + expect(toggle.checked).toBe(true); + + fireEvent.click(toggle); + expect(onInputChange).toHaveBeenCalledWith(APPROVAL_KEY, 'false'); + }); + + it('marks the detail and its rows with vendor classes', () => { + const {container} = renderDetail(payment, {}); + + expect(container.querySelector('.thunderid-consent-authorization-detail')).not.toBeNull(); + expect(container.querySelector('.thunderid-consent-authorization-detail__summary')).not.toBeNull(); + expect(container.querySelectorAll('.thunderid-consent-authorization-detail__row')).toHaveLength(2); + expect(container.querySelectorAll('.thunderid-consent-authorization-detail__label')).toHaveLength(2); + }); +}); diff --git a/packages/react/src/components/adapters/__tests__/ConsentDetailField.test.tsx b/packages/react/src/components/adapters/__tests__/ConsentDetailField.test.tsx new file mode 100644 index 00000000..bea7e6d5 --- /dev/null +++ b/packages/react/src/components/adapters/__tests__/ConsentDetailField.test.tsx @@ -0,0 +1,453 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {fireEvent, render} from '@testing-library/react'; +import {type ConsentDetailRow, createTheme} from '@thunderid/browser'; +import {type ReactElement, useState} from 'react'; +import {describe, expect, it, vi} from 'vitest'; +import ThemeContext, {type ThemeContextValue} from '../../../contexts/Theme/ThemeContext'; +import {type UseConsentDetailFieldResult} from '../../../hooks/useConsentDetailField'; +import ConsentDetailField from '../ConsentDetailField'; + +const themeContextValue: ThemeContextValue = { + colorScheme: 'light', + direction: 'ltr', + theme: createTheme(), + toggleTheme: vi.fn(), +}; + +const renderField = ( + row: ConsentDetailRow, + formValues: Record = {}, + t?: (key: string, params?: Record) => string, +): {container: HTMLElement; onInputChange: ReturnType} => { + const onInputChange = vi.fn(); + const {container} = render( + + + , + ); + + return {container, onInputChange}; +}; + +/** + * Renders a field whose form values live in state, as they do in a sign-in form, and records every change. + */ +const renderStatefulField = ( + row: ConsentDetailRow, +): {container: HTMLElement; input: HTMLInputElement; values: () => Record} => { + const latest: Record = {}; + const Harness = (): ReactElement => { + const [formValues, setFormValues] = useState>({}); + return ( + { + latest[name] = value; + setFormValues((prev: Record) => ({...prev, [name]: value})); + }} + /> + ); + }; + const {container} = render( + + + , + ); + + return {container, input: container.querySelector('input')!, values: (): Record => latest}; +}; + +const AMOUNT_KEY = '__consent_modified__p1__d-1__amount'; +const AMOUNT_INVALID_KEY = '__consent_invalid__p1__d-1__amount'; + +describe('ConsentDetailField', () => { + const amount: ConsentDetailRow = {as: 'number', label: 'Amount', modification: 'decrease', path: 'amount', value: 35}; + const actions: ConsentDetailRow = { + label: 'Actions', + modification: 'removeItems', + path: 'actions', + value: ['initiate', 'status'], + }; + + it('shows a field the user may not modify as text', () => { + const {container} = renderField({label: 'Pay to', path: 'creditorName', value: 'Merchant A'}); + + expect(container.querySelector('input')).toBeNull(); + expect(container.textContent).toBe('Merchant A'); + }); + + it('reports a lower number and marks the field valid', () => { + const {container, onInputChange} = renderField(amount); + const input = container.querySelector('input')!; + + fireEvent.change(input, {target: {value: '20'}}); + + expect(input.getAttribute('max')).toBe('35'); + expect(onInputChange.mock.calls).toEqual([ + [AMOUNT_INVALID_KEY, ''], + [AMOUNT_KEY, '20'], + ]); + }); + + describe('a number the user may decrease', () => { + it('does not submit a partial value when the typed number goes past the bound', () => { + const {container, input, values} = renderStatefulField(amount); + + fireEvent.change(input, {target: {value: '5'}}); + expect(values()[AMOUNT_KEY]).toBe('5'); + + fireEvent.change(input, {target: {value: '50'}}); + + expect(input.value).toBe('50'); + expect(input.getAttribute('aria-invalid')).toBe('true'); + expect(container.textContent).toContain('Enter 35 or less.'); + expect(values()[AMOUNT_KEY]).toBe(''); + expect(values()[AMOUNT_INVALID_KEY]).toBe('true'); + }); + + it('submits a typed number within the bound', () => { + const {container, input, values} = renderStatefulField(amount); + + fireEvent.change(input, {target: {value: '2'}}); + fireEvent.change(input, {target: {value: '20'}}); + + expect(input.value).toBe('20'); + expect(input.getAttribute('aria-invalid')).toBe('false'); + expect(container.textContent).toBe(''); + expect(values()[AMOUNT_KEY]).toBe('20'); + expect(values()[AMOUNT_INVALID_KEY]).toBe(''); + }); + + it('shows an error for a pasted number past the bound and does not submit it', () => { + const {container, input, values} = renderStatefulField(amount); + + fireEvent.change(input, {target: {value: '50'}}); + + expect(input.value).toBe('50'); + expect(container.textContent).toContain('Enter 35 or less.'); + expect(values()[AMOUNT_KEY]).toBe(''); + expect(values()[AMOUNT_INVALID_KEY]).toBe('true'); + }); + + it('keeps a cleared field empty with an error, and returns it to the shown value on blur', () => { + const {container, input, values} = renderStatefulField(amount); + + fireEvent.change(input, {target: {value: '20'}}); + fireEvent.change(input, {target: {value: ''}}); + + expect(input.value).toBe(''); + expect(container.textContent).toContain('Enter a number.'); + expect(values()[AMOUNT_KEY]).toBe(''); + expect(values()[AMOUNT_INVALID_KEY]).toBe('true'); + + fireEvent.blur(input); + + expect(input.value).toBe('35'); + expect(container.textContent).toBe(''); + expect(values()[AMOUNT_KEY]).toBe(''); + expect(values()[AMOUNT_INVALID_KEY]).toBe(''); + }); + + it('keeps an out-of-bound value and its error on blur', () => { + const {container, input, values} = renderStatefulField(amount); + + fireEvent.change(input, {target: {value: '50'}}); + fireEvent.blur(input); + + expect(input.value).toBe('50'); + expect(container.textContent).toContain('Enter 35 or less.'); + expect(values()[AMOUNT_INVALID_KEY]).toBe('true'); + }); + + it('clears the error once the user corrects the value', () => { + const {container, input, values} = renderStatefulField(amount); + + fireEvent.change(input, {target: {value: '50'}}); + fireEvent.change(input, {target: {value: '30'}}); + fireEvent.blur(input); + + expect(input.value).toBe('30'); + expect(container.textContent).toBe(''); + expect(values()[AMOUNT_KEY]).toBe('30'); + expect(values()[AMOUNT_INVALID_KEY]).toBe(''); + }); + + it('translates the error', () => { + const t = vi.fn((key: string, params?: Record): string => + key === 'consent.authorization_details.number_max' ? `max ${params?.['value']}` : key, + ); + const {container} = renderField(amount, {}, t); + + fireEvent.change(container.querySelector('input')!, {target: {value: '50'}}); + + expect(container.textContent).toContain('max 35'); + }); + }); + + it('bounds a number the user may increase from below', () => { + const {container, input, values} = renderStatefulField({...amount, modification: 'increase'}); + + fireEvent.change(input, {target: {value: '30'}}); + + expect(input.getAttribute('min')).toBe('35'); + expect(container.textContent).toContain('Enter 35 or more.'); + expect(values()[AMOUNT_KEY]).toBe(''); + expect(values()[AMOUNT_INVALID_KEY]).toBe('true'); + }); + + it('shows the value the user already chose', () => { + const {container} = renderField(amount, {'__consent_modified__p1__d-1__amount': '20'}); + + expect(container.querySelector('input')!.value).toBe('20'); + }); + + it('hands the headless field state to a render-prop child', () => { + const {container} = render( + + + {(field: UseConsentDetailFieldResult) => ( + + {field.text} + + )} + + , + ); + const output = container.querySelector('#custom')!; + + expect(output.dataset['kind']).toBe('number'); + expect(output.textContent).toBe('35'); + }); + + it('removes an item, adds it back, and keeps the last one', () => { + const {container, onInputChange} = renderField(actions); + const chips = container.querySelectorAll('button'); + + fireEvent.click(chips[1]); + expect(onInputChange).toHaveBeenCalledWith('__consent_modified__p1__d-1__actions', '["initiate"]'); + + const single = renderField(actions, {'__consent_modified__p1__d-1__actions': '["initiate"]'}); + const remaining = single.container.querySelectorAll('button'); + + expect(remaining[0].disabled).toBe(true); + expect(remaining[1].getAttribute('aria-pressed')).toBe('false'); + fireEvent.click(remaining[1]); + expect(single.onInputChange).toHaveBeenCalledWith('__consent_modified__p1__d-1__actions', '["initiate","status"]'); + }); + + it('labels list chips and the typed-item input from the default bundle', () => { + const removable = renderField(actions, {'__consent_modified__p1__d-1__actions': '["initiate"]'}); + const offered = renderField({ + label: 'Excluded', + modification: 'addItems', + options: ['crypto'], + path: 'excluded', + value: ['gambling'], + }); + const typed = renderField({label: 'Blocked', modification: 'addItems', path: 'blocked', value: ['x']}); + + expect(removable.container.querySelector('#consent_modify_p1_d-1_actions_0')!.getAttribute('aria-label')).toBe( + 'initiate, remove', + ); + expect(removable.container.querySelector('#consent_modify_p1_d-1_actions_1')!.getAttribute('aria-label')).toBe( + 'status, add back', + ); + expect( + offered.container.querySelector('#consent_modify_p1_d-1_excluded_offered_crypto')!.getAttribute('aria-label'), + ).toBe('crypto, add'); + expect(typed.container.querySelector('#consent_modify_p1_d-1_blocked_new')!.getAttribute('aria-label')).toBe( + 'Blocked, add', + ); + }); + + it('labels list chips through the given translator', () => { + const t = vi.fn((key: string, params?: Record): string => `${key}:${params?.['item']}`); + const {container} = renderField(actions, {}, t); + + expect(container.querySelector('#consent_modify_p1_d-1_actions_0')!.getAttribute('aria-label')).toBe( + 'consent.authorization_details.remove_item:initiate', + ); + }); + + it('marks the field and its chips with vendor classes', () => { + const {container} = renderField(actions, {'__consent_modified__p1__d-1__actions': '["initiate"]'}); + + expect( + container.querySelector('.thunderid-consent-detail-field.thunderid-consent-detail-field__chips'), + ).not.toBeNull(); + expect(container.querySelectorAll('.thunderid-consent-detail-field__chip--kept')).toHaveLength(1); + expect(container.querySelectorAll('.thunderid-consent-detail-field__chip--removed')).toHaveLength(1); + }); + + it('bounds a date by the value that was shown', () => { + const {container, onInputChange} = renderField({ + as: 'date', + label: 'To', + modification: 'decrease', + path: 'toDate', + value: '2026-09-30', + }); + const input = container.querySelector('input')!; + + fireEvent.change(input, {target: {value: '2026-08-31'}}); + + expect(input.getAttribute('max')).toBe('2026-09-30'); + expect(onInputChange.mock.calls).toEqual([ + ['__consent_invalid__p1__d-1__toDate', ''], + ['__consent_modified__p1__d-1__toDate', '"2026-08-31"'], + ]); + }); + + it('shows an error for a date past the bound and does not submit it', () => { + const {container, input, values} = renderStatefulField({ + as: 'date', + label: 'To', + modification: 'decrease', + path: 'toDate', + value: '2026-09-30', + }); + + fireEvent.change(input, {target: {value: '2026-08-31'}}); + fireEvent.change(input, {target: {value: '2026-10-15'}}); + + expect(input.value).toBe('2026-10-15'); + expect(container.textContent).toContain('Choose 2026-09-30 or earlier.'); + expect(values()['__consent_modified__p1__d-1__toDate']).toBe(''); + expect(values()['__consent_invalid__p1__d-1__toDate']).toBe('true'); + }); + + it('offers the allowed values to add, keeps the shown items, and takes an added item out again', () => { + const excluded: ConsentDetailRow = { + label: 'Excluded', + modification: 'addItems', + options: ['gambling', 'crypto', 'travel'], + path: 'excluded', + value: ['gambling'], + }; + const {container, onInputChange} = renderField(excluded); + + expect(container.querySelector('#consent_modify_p1_d-1_excluded_0')!.tagName).toBe('SPAN'); + expect(container.querySelector('#consent_modify_p1_d-1_excluded_offered_gambling')).toBeNull(); + fireEvent.click(container.querySelector('#consent_modify_p1_d-1_excluded_offered_crypto')!); + expect(onInputChange).toHaveBeenCalledWith('__consent_modified__p1__d-1__excluded', '["gambling","crypto"]'); + + const withAdded = renderField(excluded, {'__consent_modified__p1__d-1__excluded': '["gambling","crypto"]'}); + expect(withAdded.container.querySelector('#consent_modify_p1_d-1_excluded_offered_crypto')).toBeNull(); + fireEvent.click(withAdded.container.querySelector('#consent_modify_p1_d-1_excluded_added_crypto')!); + expect(withAdded.onInputChange).toHaveBeenCalledWith('__consent_modified__p1__d-1__excluded', '["gambling"]'); + }); + + it('adds a typed value when the list allows any value', () => { + const {container, onInputChange} = renderField({ + label: 'Blocked', + modification: 'addItems', + path: 'blocked', + value: ['x'], + }); + const input = container.querySelector('#consent_modify_p1_d-1_blocked_new')!; + + fireEvent.change(input, {target: {value: ' y '}}); + fireEvent.keyDown(input, {key: 'Enter'}); + fireEvent.change(input, {target: {value: 'x'}}); + fireEvent.keyDown(input, {key: 'Enter'}); + + expect(onInputChange).toHaveBeenCalledTimes(1); + expect(onInputChange).toHaveBeenCalledWith('__consent_modified__p1__d-1__blocked', '["x","y"]'); + }); + + describe('large content', () => { + const items = (count: number): string[] => + Array.from({length: count}, (_: unknown, index: number) => `item-${index}`); + const toggleOf = (container: HTMLElement): HTMLButtonElement => + container.querySelector('button[aria-expanded]')!; + + it('clamps a long value behind a toggle labelled from the default bundle', () => { + const note: string = 'word '.repeat(60).trim(); + const {container} = renderField({label: 'Note', path: 'note', value: note}); + + expect(container.textContent).toContain(note); + expect(toggleOf(container).textContent).toBe('Show more'); + expect(toggleOf(container).getAttribute('aria-expanded')).toBe('false'); + + fireEvent.click(toggleOf(container)); + expect(toggleOf(container).textContent).toBe('Show less'); + expect(toggleOf(container).getAttribute('aria-expanded')).toBe('true'); + }); + + it('labels the toggle through the given translator', () => { + const t = vi.fn( + (key: string, params?: Record): string => `${key}:${params?.['count'] ?? ''}`, + ); + const {container} = renderField({label: 'Regions', path: 'regions', value: items(10)}, {}, t); + + expect(toggleOf(container).textContent).toBe('consent.authorization_details.more_items:2'); + }); + + it('shows the first items of a long read-only list and the rest on request', () => { + const {container} = renderField({label: 'Regions', path: 'regions', value: items(20)}); + + expect(container.textContent).toContain(items(8).join(', ')); + expect(container.textContent).not.toContain('item-8'); + expect(toggleOf(container).textContent).toBe('+12 more'); + + fireEvent.click(toggleOf(container)); + expect(container.textContent).toContain(items(20).join(', ')); + }); + + it('collapses a long list of removable chips and keeps the hidden ones reachable', () => { + const {container, onInputChange} = renderField({ + label: 'Stores', + modification: 'removeItems', + path: 'stores', + value: items(12), + }); + + expect(container.querySelector('#consent_modify_p1_d-1_stores_7')).not.toBeNull(); + expect(container.querySelector('#consent_modify_p1_d-1_stores_8')).toBeNull(); + + fireEvent.click(toggleOf(container)); + fireEvent.click(container.querySelector('#consent_modify_p1_d-1_stores_11')!); + expect(onInputChange).toHaveBeenCalledWith('__consent_modified__p1__d-1__stores', JSON.stringify(items(11))); + }); + + it('cuts a long chip short and keeps its full value as a tooltip', () => { + const iban = 'DE89370400440532013000DE89370400440532013000DE89370400440532013000'; + const {container} = render( + +
+ +
+
, + ); + const chip = container.querySelector('#consent_modify_p1_d-1_accounts_0')!; + + expect(chip.getAttribute('title')).toBe(iban); + expect(chip.getBoundingClientRect().width).toBeLessThanOrEqual(200); + }); + + it('shows structured JSON as an indented block', () => { + const {container} = renderField({label: 'Account', path: 'account', value: {iban: 'DE02'}}); + + expect(container.textContent).toBe('{\n "iban": "DE02"\n}'); + }); + }); +}); diff --git a/packages/react/src/components/adapters/resolveConsentText.ts b/packages/react/src/components/adapters/resolveConsentText.ts new file mode 100644 index 00000000..ce2287e3 --- /dev/null +++ b/packages/react/src/components/adapters/resolveConsentText.ts @@ -0,0 +1,36 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import { + getDefaultI18nBundles, + I18nBundle, + substituteTranslationParams, + TranslationBundleConstants, +} from '@thunderid/browser'; +import {UseTranslation} from '../../hooks/useTranslation'; + +/** + * Resolves a consent screen string through the given translator, falling back to the SDK's default + * bundle when there is no translator or it has no entry for the key. + * + * @param t - Translator, when the screen has one. + * @param key - Translation key. + * @param params - Values to substitute into the text. + * @returns The resolved text. + */ +const resolveConsentText = ( + t: UseTranslation['t'] | undefined, + key: string, + params?: Record, +): string => { + const translated: string | undefined = t?.(key, params); + if (translated && translated !== key) { + return translated; + } + const bundle: I18nBundle | undefined = getDefaultI18nBundles()[TranslationBundleConstants.FALLBACK_LOCALE]; + const fallback: string | undefined = bundle?.translations[key as keyof I18nBundle['translations']]; + + return substituteTranslationParams(fallback ?? key, params); +}; + +export default resolveConsentText; diff --git a/packages/react/src/components/presentation/auth/AcceptInvite/BaseAcceptInvite.tsx b/packages/react/src/components/presentation/auth/AcceptInvite/BaseAcceptInvite.tsx index ae5a453d..a11a79a1 100644 --- a/packages/react/src/components/presentation/auth/AcceptInvite/BaseAcceptInvite.tsx +++ b/packages/react/src/components/presentation/auth/AcceptInvite/BaseAcceptInvite.tsx @@ -8,6 +8,9 @@ import { FlowMetadataResponse, Preferences, buildValidatorFromRules, + CONSENT_INVALID_KEY_PREFIX, + CONSENT_MODIFIED_KEY_PREFIX, + getConsentElementDecision, } from '@thunderid/browser'; import {FC, ReactElement, ReactNode, useCallback, useContext, useEffect, useRef, useState} from 'react'; import useStyles from './BaseAcceptInvite.styles'; @@ -563,14 +566,22 @@ const BaseAcceptInvite: FC = ({ ...(p.essential || []).map((e: any) => ({approved: !isDeny, name: e.name})), ...(p.optional || []).map((e: any) => { const key = `__consent_opt__${p.purposeId}__${e.name}`; - return {approved: !isDeny && inputs[key] === 'true', name: e.name}; + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-member-access + return getConsentElementDecision(p.purposeId, e, !isDeny && inputs[key] === 'true', inputs); }), ], })), }; inputs.consent_decisions = JSON.stringify(decisions); Object.keys(inputs).forEach((k: string) => { - if (k.startsWith('__consent_opt__')) delete inputs[k]; + if ( + k.startsWith('__consent_opt__') || + k.startsWith(CONSENT_MODIFIED_KEY_PREFIX) || + k.startsWith(CONSENT_INVALID_KEY_PREFIX) + ) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + delete inputs[k]; + } }); } catch { // Leave inputs as-is if consent building fails diff --git a/packages/react/src/components/presentation/auth/AcceptInvite/__tests__/BaseAcceptInvite.test.tsx b/packages/react/src/components/presentation/auth/AcceptInvite/__tests__/BaseAcceptInvite.test.tsx new file mode 100644 index 00000000..1a281b7a --- /dev/null +++ b/packages/react/src/components/presentation/auth/AcceptInvite/__tests__/BaseAcceptInvite.test.tsx @@ -0,0 +1,138 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {cleanup, fireEvent, render, waitFor} from '@testing-library/react'; +import {type ConsentDecisions} from '@thunderid/browser'; +import {afterEach, describe, expect, it, vi} from 'vitest'; +import I18nProvider from '../../../../../contexts/I18n/I18nProvider'; +import ThemeProvider from '../../../../../contexts/Theme/ThemeProvider'; +import ThunderIDContext, {ThunderIDContextProps} from '../../../../../contexts/ThunderID/ThunderIDContext'; +import Consent from '../../../../adapters/Consent'; +import BaseAcceptInvite, {type BaseAcceptInviteRenderProps} from '../BaseAcceptInvite'; + +const consentPrompt = [ + { + essential: [], + optional: [ + { + detailType: 'payment_initiation', + name: 'd-1', + rows: [{as: 'number', label: 'Amount', modification: 'decrease', path: 'instructedAmount.amount', value: 35}], + summary: 'Pay 35 EUR', + }, + ], + purposeId: 'authorization_details:app1', + purposeName: 'authorization_details:app1', + type: 'authorization_details', + }, +]; + +const allow = {eventType: 'SUBMIT', id: 'allow', type: 'ACTION', variant: 'PRIMARY'}; +const deny = {eventType: 'SUBMIT', id: 'deny', type: 'ACTION', variant: 'SECONDARY'}; + +const consentStep = { + data: { + additionalData: {consentPrompt: JSON.stringify(consentPrompt)}, + meta: {components: [{id: 'consent', type: 'CONSENT'}, allow, deny]}, + }, + executionId: 'exec-1', + flowStatus: 'INCOMPLETE', + type: 'VIEW', +}; + +const thunderIDContext: ThunderIDContextProps = { + getStorageManager: vi.fn(() => Promise.resolve(undefined)), + isInitialized: true, + meta: null, + vendor: 'thunderid', +} as unknown as ThunderIDContextProps; + +/** + * Renders the accept invite flow on its consent step, with the SDK consent UI and an allow and a deny button + * that submit through the container. + */ +const renderOnConsentStep = async (): Promise<{container: HTMLElement; onSubmit: ReturnType}> => { + const onSubmit = vi.fn().mockResolvedValueOnce(consentStep).mockResolvedValueOnce(consentStep); + const {container} = render( + + + + + {({additionalData, handleInputChange, handleSubmit, values}: BaseAcceptInviteRenderProps) => ( + <> + + + + + )} + + + + , + ); + await waitFor(() => + expect( + container.querySelector('#consent_modify_authorization_details\\:app1_d-1_instructedAmount\\.amount'), + ).not.toBeNull(), + ); + + return {container, onSubmit}; +}; + +const submittedInputs = (onSubmit: ReturnType): Record => + (onSubmit.mock.calls[1][0] as {inputs: Record}).inputs; + +describe('BaseAcceptInvite consent decisions', () => { + afterEach(() => { + cleanup(); + sessionStorage.clear(); + }); + + it('sends the values the user modified on an approved authorization detail', async () => { + const {container, onSubmit} = await renderOnConsentStep(); + + fireEvent.change( + container.querySelector('#consent_modify_authorization_details\\:app1_d-1_instructedAmount\\.amount')!, + { + target: {value: '20'}, + }, + ); + fireEvent.click(container.querySelector('#allow')!); + await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(2)); + + const inputs: Record = submittedInputs(onSubmit); + const decisions: ConsentDecisions = JSON.parse(inputs['consent_decisions']) as ConsentDecisions; + + expect(decisions.approved).toBe(true); + expect(decisions.purposes[0].elements).toEqual([ + {approved: true, modified: {'instructedAmount.amount': 20}, name: 'd-1'}, + ]); + expect(Object.keys(inputs).filter((key: string) => key.startsWith('__consent_'))).toEqual([]); + }); + + it('denies the detail without its modified values on the deny action', async () => { + const {container, onSubmit} = await renderOnConsentStep(); + + fireEvent.change( + container.querySelector('#consent_modify_authorization_details\\:app1_d-1_instructedAmount\\.amount')!, + { + target: {value: '20'}, + }, + ); + fireEvent.click(container.querySelector('#deny')!); + await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(2)); + + const decisions: ConsentDecisions = JSON.parse(submittedInputs(onSubmit)['consent_decisions']) as ConsentDecisions; + + expect(decisions).toMatchObject({approved: false, reason: 'user_denied'}); + expect(decisions.purposes[0].elements).toEqual([{approved: false, name: 'd-1'}]); + }); +}); diff --git a/packages/react/src/components/presentation/auth/AuthOptionFactory.tsx b/packages/react/src/components/presentation/auth/AuthOptionFactory.tsx index a75f72ef..2f64af9c 100644 --- a/packages/react/src/components/presentation/auth/AuthOptionFactory.tsx +++ b/packages/react/src/components/presentation/auth/AuthOptionFactory.tsx @@ -26,6 +26,7 @@ import { FetchAuthFlows, FetchUserTypes, FetchUsers, + getConsentElementDecision, } from '@thunderid/browser'; import DOMPurify from 'dompurify'; import {ChangeEvent, cloneElement, CSSProperties, FormEvent, ReactElement} from 'react'; @@ -36,6 +37,7 @@ import { } from '../../../contexts/ComponentRenderer/ComponentRendererContext'; import {UseTranslation} from '../../../hooks/useTranslation'; import {css, cx} from '../../../styles/emotion'; +import isConsentApprovalBlocked from '../../../utils/isConsentApprovalBlocked'; import Consent from '../../adapters/Consent'; import {getConsentOptionalKey} from '../../adapters/ConsentCheckboxList'; import FacebookButton from '../../adapters/FacebookButton'; @@ -435,6 +437,15 @@ const createAuthComponentFromFlow = ( }; const buttonType: HTMLButtonElement['type'] = buttonTypeEnum[eventType.toUpperCase()] ?? 'submit'; + // Approval waits while an approved authorization detail shows a modified value that cannot be submitted. + const hasInvalidConsentApproval: boolean = + !shouldSkipValidation && + componentVariant.toLowerCase() === 'primary' && + isConsentApprovalBlocked( + (options.additionalData?.['consentPrompt'] as ConsentPromptData | undefined)?.purposes ?? [], + formValues, + ); + const handleClick = (): any => { if (options.onSubmit) { const formData: Record = {}; @@ -461,10 +472,13 @@ const createAuthComponentFromFlow = ( // Permission purposes carry no essential elements, so the server sends null here ...(p.essential ?? []).map((e): ConsentAttributeElement => ({approved: !isDeny, name: e.name})), ...(p.optional ?? []).map( - (e): ConsentAttributeElement => ({ - approved: !isDeny && formValues[getConsentOptionalKey(p.purposeId, e.name)] === 'true', - name: e.name, - }), + (e): ConsentAttributeElement => + getConsentElementDecision( + p.purposeId, + e, + !isDeny && formValues[getConsentOptionalKey(p.purposeId, e.name)] === 'true', + formValues, + ), ), ], purposeName: p.purposeName!, @@ -534,6 +548,7 @@ const createAuthComponentFromFlow = ( disabled={ isLoading || (!isFormValid && !shouldSkipValidation) || + hasInvalidConsentApproval || options.isTimeoutDisabled || (component as any).config?.disabled } diff --git a/packages/react/src/components/presentation/auth/SignIn/SignIn.tsx b/packages/react/src/components/presentation/auth/SignIn/SignIn.tsx index 15f86091..7a972a54 100644 --- a/packages/react/src/components/presentation/auth/SignIn/SignIn.tsx +++ b/packages/react/src/components/presentation/auth/SignIn/SignIn.tsx @@ -15,6 +15,9 @@ import { FlowMetadataResponse, Preferences, logger, + CONSENT_INVALID_KEY_PREFIX, + CONSENT_MODIFIED_KEY_PREFIX, + getConsentElementDecision, } from '@thunderid/browser'; import {FC, ReactElement, useState, useEffect, useRef, ReactNode} from 'react'; // eslint-disable-next-line import/no-named-as-default @@ -793,10 +796,9 @@ const SignIn: FC = ({ })), ...(p.optional || []).map((e: any) => { const key = `__consent_opt__${p.purposeId}__${e.name}`; - return { - approved: !isDeny && processedInputs[key] === 'true', - name: e.name, - }; + const approved: boolean = !isDeny && processedInputs[key] === 'true'; + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-member-access + return getConsentElementDecision(p.purposeId, e, approved, processedInputs); }), ], purposeName: p.purposeName, @@ -806,7 +808,11 @@ const SignIn: FC = ({ // Cleanup temporary consent tracking fields from inputs Object.keys(processedInputs).forEach((key: string) => { - if (key.startsWith('__consent_opt__')) { + if ( + key.startsWith('__consent_opt__') || + key.startsWith(CONSENT_MODIFIED_KEY_PREFIX) || + key.startsWith(CONSENT_INVALID_KEY_PREFIX) + ) { delete processedInputs[key]; } }); diff --git a/packages/react/src/components/presentation/auth/SignIn/__tests__/SignIn.test.tsx b/packages/react/src/components/presentation/auth/SignIn/__tests__/SignIn.test.tsx index 22f761c1..89f6e1a2 100644 --- a/packages/react/src/components/presentation/auth/SignIn/__tests__/SignIn.test.tsx +++ b/packages/react/src/components/presentation/auth/SignIn/__tests__/SignIn.test.tsx @@ -1,8 +1,8 @@ // Copyright 2026 The ThunderID Authors // SPDX-License-Identifier: Apache-2.0 -import {cleanup, render, waitFor} from '@testing-library/react'; -import {ThunderIDRuntimeError} from '@thunderid/browser'; +import {cleanup, fireEvent, render, waitFor} from '@testing-library/react'; +import {type ConsentDecisions, ThunderIDRuntimeError} from '@thunderid/browser'; import {afterEach, beforeEach, describe, expect, it, vi, Mock} from 'vitest'; import I18nProvider from '../../../../../contexts/I18n/I18nProvider'; import ThemeProvider from '../../../../../contexts/Theme/ThemeProvider'; @@ -74,3 +74,121 @@ describe('SignIn without an application', () => { expect(unhandled).toEqual([]); }); }); + +const consentPrompt = [ + { + essential: [], + optional: [ + { + detailType: 'payment_initiation', + name: 'd-1', + rows: [ + {as: 'number', label: 'Amount', modification: 'decrease', path: 'instructedAmount.amount', value: 35}, + {label: 'Pay to', path: 'creditorName', value: 'Merchant A'}, + ], + summary: 'Pay 35 EUR to Merchant A', + }, + ], + purposeId: 'authorization_details:app1', + purposeName: 'authorization_details:app1', + type: 'authorization_details', + }, +]; + +const consentStep = { + data: { + additionalData: {consentPrompt: JSON.stringify(consentPrompt)}, + meta: { + components: [ + {id: 'consent', type: 'CONSENT'}, + {eventType: 'SUBMIT', id: 'allow', label: 'Allow', type: 'ACTION', variant: 'PRIMARY'}, + {eventType: 'SUBMIT', id: 'deny', label: 'Deny', type: 'ACTION', variant: 'SECONDARY'}, + ], + }, + }, + executionId: 'exec-1', + flowStatus: 'INCOMPLETE', + type: 'VIEW', +}; + +const renderOnConsentStep = async (): Promise<{container: HTMLElement; signIn: ReturnType}> => { + const signIn = vi.fn().mockResolvedValueOnce(consentStep).mockResolvedValueOnce(consentStep); + const thunderIDContext: ThunderIDContextProps = { + applicationId: 'app1', + getStorageManager: vi.fn(() => Promise.resolve(undefined)), + isInitialized: true, + isLoading: false, + meta: null, + signIn, + vendor: 'thunderid', + } as unknown as ThunderIDContextProps; + + const {container} = render( + + + + + + + , + ); + await waitFor(() => + expect( + container.querySelector('#consent_modify_authorization_details\\:app1_d-1_instructedAmount\\.amount'), + ).not.toBeNull(), + ); + + return {container, signIn}; +}; + +const submittedInputs = (signIn: ReturnType): Record => + (signIn.mock.calls[1][0] as {inputs: Record}).inputs; + +describe('SignIn consent decisions', () => { + afterEach(() => { + cleanup(); + sessionStorage.clear(); + }); + + it('sends the values the user modified on an approved authorization detail', async () => { + const {container, signIn} = await renderOnConsentStep(); + + fireEvent.change( + container.querySelector('#consent_modify_authorization_details\\:app1_d-1_instructedAmount\\.amount')!, + { + target: {value: '20'}, + }, + ); + fireEvent.click(container.querySelector('#allow')!); + await waitFor(() => expect(signIn).toHaveBeenCalledTimes(2)); + + const inputs: Record = submittedInputs(signIn); + const decisions: ConsentDecisions = JSON.parse(inputs['consent_decisions']) as ConsentDecisions; + + expect(decisions.approved).toBe(true); + expect(decisions.purposes[0].elements).toEqual([ + {approved: true, modified: {'instructedAmount.amount': 20}, name: 'd-1'}, + ]); + expect(Object.keys(inputs)).toEqual(['consent_decisions']); + }); + + it('sends no modified values when the user denies', async () => { + const {container, signIn} = await renderOnConsentStep(); + + fireEvent.change( + container.querySelector('#consent_modify_authorization_details\\:app1_d-1_instructedAmount\\.amount')!, + { + target: {value: '20'}, + }, + ); + fireEvent.click(container.querySelector('#deny')!); + await waitFor(() => expect(signIn).toHaveBeenCalledTimes(2)); + + const inputs: Record = submittedInputs(signIn); + const decisions: ConsentDecisions = JSON.parse(inputs['consent_decisions']) as ConsentDecisions; + + expect(decisions).toMatchObject({approved: false, reason: 'user_denied'}); + expect(decisions.purposes[0].elements).toEqual([{approved: false, name: 'd-1'}]); + expect(Object.keys(inputs)).toEqual(['consent_decisions']); + }); +}); diff --git a/packages/react/src/components/presentation/auth/__tests__/AuthOptionFactory.test.tsx b/packages/react/src/components/presentation/auth/__tests__/AuthOptionFactory.test.tsx index 8fe93f36..c976038d 100644 --- a/packages/react/src/components/presentation/auth/__tests__/AuthOptionFactory.test.tsx +++ b/packages/react/src/components/presentation/auth/__tests__/AuthOptionFactory.test.tsx @@ -11,6 +11,7 @@ import { EmbeddedFlowEventType, SelectPage, } from '@thunderid/browser'; +import {type ReactElement, useState} from 'react'; import {afterEach, describe, expect, it, vi} from 'vitest'; import I18nProvider from '../../../../contexts/I18n/I18nProvider'; import ThemeContext, {ThemeContextValue} from '../../../../contexts/Theme/ThemeContext'; @@ -405,6 +406,186 @@ describe('AuthOptionFactory consent decisions', () => { expect(purpose.elements).toHaveLength(1); expect(purpose.elements[0]).toEqual({approved: true, name: 'system'}); }); + + const paymentElement = (name: string, amount: number) => ({ + detailType: 'payment_initiation', + name, + rows: [ + {as: 'number', label: 'Amount', modification: 'decrease', path: 'instructedAmount.amount', value: amount}, + {label: 'Pay to', path: 'creditorName', value: 'Merchant A'}, + ], + }); + + const detailPurpose = { + essential: [], + optional: [paymentElement('d-1', 35), paymentElement('d-2', 12)], + purposeId: 'authorization_details:app1', + purposeName: 'authorization_details:app1', + type: 'authorization_details', + }; + + it('sends the values the user modified on each approved authorization detail', () => { + const decisions = submitConsent('PRIMARY', [detailPurpose], { + '__consent_modified__authorization_details:app1__d-1__instructedAmount.amount': '20', + '__consent_opt__authorization_details:app1__d-1': 'true', + '__consent_opt__authorization_details:app1__d-2': 'true', + }); + + expect(decisions.purposes[0].purposeName).toBe('authorization_details:app1'); + expect(decisions.purposes[0].elements).toEqual([ + {approved: true, name: 'd-1', modified: {'instructedAmount.amount': 20}}, + {approved: true, name: 'd-2'}, + ]); + }); + + it('sends no modified values for a detail approved as shown or declined', () => { + const asShown = submitConsent('PRIMARY', [detailPurpose], { + '__consent_opt__authorization_details:app1__d-1': 'true', + }); + const declined = submitConsent('PRIMARY', [detailPurpose], { + '__consent_modified__authorization_details:app1__d-1__instructedAmount.amount': '20', + '__consent_opt__authorization_details:app1__d-1': 'false', + }); + + expect(asShown.purposes[0].elements[0]).toEqual({approved: true, name: 'd-1'}); + expect(declined.purposes[0].elements[0]).toEqual({approved: false, name: 'd-1'}); + }); + + const renderConsentAction = ( + variant: string, + formValues: Record, + ): {button: HTMLButtonElement; onSubmit: ReturnType} => { + const onSubmit = vi.fn(); + const elements = renderSignInComponents( + [consentAction(variant)], + formValues, + {}, + {}, + false, + true, + () => undefined, + () => undefined, + {additionalData: {consentPrompt: {purposes: [detailPurpose]}}, onSubmit}, + ); + const {container} = render( + +
{elements}
+
, + ); + + return {button: container.querySelector('button')!, onSubmit}; + }; + + const invalidAmount: Record = { + '__consent_invalid__authorization_details:app1__d-1__instructedAmount.amount': 'true', + '__consent_opt__authorization_details:app1__d-1': 'true', + '__consent_opt__authorization_details:app1__d-2': 'true', + }; + + it('blocks approval while an approved detail shows a value that cannot be submitted', () => { + const {button, onSubmit} = renderConsentAction('PRIMARY', invalidAmount); + + expect(button.disabled).toBe(true); + button.click(); + expect(onSubmit).not.toHaveBeenCalled(); + }); + + it('allows approval once the field is valid again, or when its detail is declined', () => { + const corrected = renderConsentAction('PRIMARY', { + ...invalidAmount, + '__consent_invalid__authorization_details:app1__d-1__instructedAmount.amount': '', + }); + const declined = renderConsentAction('PRIMARY', { + ...invalidAmount, + '__consent_opt__authorization_details:app1__d-1': 'false', + }); + + expect(corrected.button.disabled).toBe(false); + expect(declined.button.disabled).toBe(false); + }); + + it('never approves a value other than the one the consent screen shows', () => { + const onSubmit = vi.fn(); + const Form = (): ReactElement => { + const [formValues, setFormValues] = useState>({}); + const onInputChange = (name: string, value: string): void => + setFormValues((prev: Record) => ({...prev, [name]: value})); + + return ( +
+ {renderSignInComponents( + [ + {id: 'consent', type: EmbeddedFlowComponentType.Consent} as unknown as EmbeddedFlowComponent, + consentAction('PRIMARY'), + ], + formValues, + {}, + {}, + false, + true, + () => undefined, + onInputChange, + {additionalData: {consentPrompt: {purposes: [detailPurpose]}}, onSubmit}, + )} +
+ ); + }; + const {container} = render( + +
+ , + ); + const amountInput: HTMLInputElement = container.querySelector( + '#consent_modify_authorization_details\\:app1_d-1_instructedAmount\\.amount', + )!; + const allow: HTMLButtonElement = container.querySelector('#action_PRIMARY')!; + + fireEvent.change(amountInput, {target: {value: '5'}}); + fireEvent.change(amountInput, {target: {value: '50'}}); + expect(amountInput.value).toBe('50'); + expect(allow.disabled).toBe(true); + + fireEvent.change(amountInput, {target: {value: '20'}}); + expect(allow.disabled).toBe(false); + allow.click(); + + const decisions = JSON.parse( + (onSubmit.mock.calls[0][1] as Record)['consent_decisions'], + ) as ConsentDecisions; + expect(decisions.purposes[0].elements[0]).toEqual({ + approved: true, + modified: {'instructedAmount.amount': 20}, + name: 'd-1', + }); + }); + + it('lets the user deny while a field is invalid', () => { + const {button, onSubmit} = renderConsentAction('SECONDARY', invalidAmount); + + expect(button.disabled).toBe(false); + button.click(); + const submitted = onSubmit.mock.calls[0][1] as Record; + + expect((JSON.parse(submitted['consent_decisions']) as ConsentDecisions).approved).toBe(false); + }); + + it('passes the consent form keys on with the decisions, so the container reads the same choices', () => { + const formValues: Record = { + '__consent_modified__authorization_details:app1__d-1__instructedAmount.amount': '20', + '__consent_opt__authorization_details:app1__d-1': 'true', + }; + const {button, onSubmit} = renderConsentAction('PRIMARY', formValues); + + button.click(); + const submitted = onSubmit.mock.calls[0][1] as Record; + + expect(submitted).toMatchObject(formValues); + expect((JSON.parse(submitted['consent_decisions']) as ConsentDecisions).purposes[0].elements[0]).toEqual({ + approved: true, + modified: {'instructedAmount.amount': 20}, + name: 'd-1', + }); + }); }); describe('AuthOptionFactory key-value list', () => { diff --git a/packages/react/src/hooks/__tests__/useConsentDetailField.test.tsx b/packages/react/src/hooks/__tests__/useConsentDetailField.test.tsx new file mode 100644 index 00000000..abb80c62 --- /dev/null +++ b/packages/react/src/hooks/__tests__/useConsentDetailField.test.tsx @@ -0,0 +1,326 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {act, renderHook} from '@testing-library/react'; +import {type ConsentDetailRow} from '@thunderid/browser'; +import {useState} from 'react'; +import {describe, expect, it} from 'vitest'; +import isConsentApprovalBlocked from '../../utils/isConsentApprovalBlocked'; +import useConsentDetailField, {type UseConsentDetailFieldResult} from '../useConsentDetailField'; + +const amount: ConsentDetailRow = {as: 'number', label: 'Amount', modification: 'decrease', path: 'amount', value: 35}; +const toDate: ConsentDetailRow = {as: 'date', label: 'To', modification: 'decrease', path: 'to', value: '2026-09-30'}; +const actions: ConsentDetailRow = { + label: 'Actions', + modification: 'removeItems', + path: 'actions', + value: ['initiate', 'status'], +}; +const excluded: ConsentDetailRow = { + label: 'Excluded', + modification: 'addItems', + options: ['gambling', 'crypto'], + path: 'excluded', + value: ['gambling'], +}; + +const limit: ConsentDetailRow = {as: 'number', label: 'Limit', modification: 'increase', path: 'limit', value: 10}; +const fromDate: ConsentDetailRow = { + as: 'date', + label: 'From', + modification: 'increase', + path: 'from', + value: '2026-09-01', +}; +const blocked: ConsentDetailRow = {label: 'Blocked', modification: 'addItems', path: 'blocked', value: ['x']}; + +const AMOUNT_KEY = '__consent_modified__authorization_details:app1__d-1__amount'; +const AMOUNT_INVALID_KEY = '__consent_invalid__authorization_details:app1__d-1__amount'; + +/** + * Runs the hook against form values kept in state, as a sign-in form keeps them. + */ +const renderField = ( + row: ConsentDetailRow, + initialFormValues: Record = {}, +): {current: () => UseConsentDetailFieldResult & {formValues: Record}} => { + const {result} = renderHook(() => { + const [formValues, setFormValues] = useState>(initialFormValues); + const field: UseConsentDetailFieldResult = useConsentDetailField({ + detailId: 'd-1', + formValues, + onInputChange: (name: string, value: string): void => + setFormValues((prev: Record) => ({...prev, [name]: value})), + purposeId: 'authorization_details:app1', + row, + }); + return {...field, formValues}; + }); + + return {current: () => result.current}; +}; + +describe('useConsentDetailField', () => { + it('does not keep a partial value once the typed number goes past the bound', () => { + const field = renderField(amount); + + act(() => field.current().handleChange('5')); + expect(field.current().formValues[AMOUNT_KEY]).toBe('5'); + act(() => field.current().handleChange('50')); + + expect(field.current().text).toBe('50'); + expect(field.current().isValid).toBe(false); + expect(field.current().error).toEqual({ + key: 'consent.authorization_details.number_max', + message: 'Enter 35 or less.', + params: {value: '35'}, + }); + expect(field.current().formValues[AMOUNT_KEY]).toBe(''); + expect(field.current().formValues[AMOUNT_INVALID_KEY]).toBe('true'); + }); + + it('writes a typed number within the bound', () => { + const field = renderField(amount); + + act(() => field.current().handleChange('20')); + + expect(field.current().isValid).toBe(true); + expect(field.current().error).toBeUndefined(); + expect(field.current().formValues[AMOUNT_KEY]).toBe('20'); + expect(field.current().formValues[AMOUNT_INVALID_KEY]).toBe(''); + expect(field.current().max).toBe('35'); + }); + + it('marks a pasted out-of-range number invalid and blocks approval', () => { + const field = renderField(amount); + const purposes = [ + { + essential: [], + optional: [{detailType: 'payment_initiation', name: 'd-1', rows: [amount]}], + purposeId: 'authorization_details:app1', + type: 'authorization_details' as const, + }, + ]; + + act(() => field.current().handleChange('50')); + expect(isConsentApprovalBlocked(purposes, field.current().formValues)).toBe(true); + + act(() => field.current().handleChange('30')); + expect(isConsentApprovalBlocked(purposes, field.current().formValues)).toBe(false); + }); + + it('returns a cleared field to the shown value on blur', () => { + const field = renderField(amount); + + act(() => field.current().handleChange('20')); + act(() => field.current().handleChange('')); + expect(field.current().isValid).toBe(false); + act(() => field.current().handleBlur()); + + expect(field.current().text).toBe('35'); + expect(field.current().isValid).toBe(true); + expect(field.current().formValues[AMOUNT_KEY]).toBe(''); + expect(field.current().formValues[AMOUNT_INVALID_KEY]).toBe(''); + }); + + it('bounds a date by the value that was shown', () => { + const field = renderField(toDate); + + act(() => field.current().handleChange('2026-10-15')); + expect(field.current().error?.message).toBe('Choose 2026-09-30 or earlier.'); + expect(field.current().formValues['__consent_modified__authorization_details:app1__d-1__to']).toBe(''); + + act(() => field.current().handleChange('2026-08-31')); + expect(field.current().isValid).toBe(true); + expect(field.current().formValues['__consent_modified__authorization_details:app1__d-1__to']).toBe('"2026-08-31"'); + }); + + it('toggles list items within what the rule allows', () => { + const field = renderField(actions); + + expect(field.current().kind).toBe('removeItems'); + act(() => field.current().handleToggle('status')); + expect(field.current().isSelected('status')).toBe(false); + expect(field.current().isToggleable('initiate')).toBe(false); + act(() => field.current().handleToggle('initiate')); + expect(field.current().formValues['__consent_modified__authorization_details:app1__d-1__actions']).toBe( + '["initiate"]', + ); + act(() => field.current().handleToggle('status')); + expect(field.current().isSelected('status')).toBe(true); + }); + + it('adds an offered item and takes it out again, keeping the shown items', () => { + const field = renderField(excluded); + + expect(field.current().isToggleable('gambling')).toBe(false); + act(() => field.current().handleToggle('crypto')); + expect(field.current().isSelected('crypto')).toBe(true); + expect(field.current().formValues['__consent_modified__authorization_details:app1__d-1__excluded']).toBe( + '["gambling","crypto"]', + ); + act(() => field.current().handleToggle('crypto')); + expect(field.current().formValues['__consent_modified__authorization_details:app1__d-1__excluded']).toBe( + '["gambling"]', + ); + }); + + it('tells how much of a long list to show and expands it on request', () => { + const regions = renderField({ + label: 'Regions', + path: 'regions', + value: Array.from({length: 10}, (_: unknown, index: number) => `region-${index}`), + }); + + expect(regions.current().isPlainList).toBe(true); + expect(regions.current().listDisplay).toEqual({hiddenCount: 2, isCollapsible: true, visibleCount: 8}); + act(() => regions.current().handleListToggle(true)); + expect(regions.current().isListExpanded).toBe(true); + expect(regions.current().listDisplay).toEqual({hiddenCount: 0, isCollapsible: true, visibleCount: 10}); + }); + + it('hides nothing of a short list or a field that is not a list', () => { + expect(renderField(excluded).current().listDisplay).toEqual({ + hiddenCount: 0, + isCollapsible: false, + visibleCount: 2, + }); + expect(renderField(amount).current().isPlainList).toBe(false); + expect(renderField(amount).current().listDisplay.isCollapsible).toBe(false); + }); + + it('tells the input each modifiable field takes', () => { + expect(renderField(amount).current().kind).toBe('number'); + expect(renderField(toDate).current().kind).toBe('date'); + expect(renderField(actions).current().kind).toBe('removeItems'); + expect(renderField(excluded).current().kind).toBe('addItems'); + }); + + it('leaves a read-only field, a date and time, and a one-item removable list without input', () => { + expect(renderField({label: 'Pay to', path: 'creditor', value: 'Merchant A'}).current().kind).toBeUndefined(); + expect( + renderField({ + as: 'dateTime', + label: 'At', + modification: 'decrease', + path: 'at', + value: '2026-09-30T10:00:00Z', + }).current().kind, + ).toBeUndefined(); + expect(renderField({...actions, value: ['initiate']}).current().kind).toBeUndefined(); + }); + + it('shows the chosen value, or the shown value when there is none', () => { + expect(renderField(amount, {[AMOUNT_KEY]: '20'}).current().text).toBe('20'); + expect(renderField(amount, {[AMOUNT_KEY]: ''}).current().text).toBe('35'); + expect(renderField(amount, {[AMOUNT_KEY]: 'not json'}).current().text).toBe('35'); + expect( + renderField(toDate, {'__consent_modified__authorization_details:app1__d-1__to': '"2026-08-31"'}).current().text, + ).toBe('2026-08-31'); + }); + + it('accepts a number within the bound, the shown value included', () => { + const decreasable = renderField(amount); + ['20', '35', '20.5'].forEach((text: string) => { + act(() => decreasable.current().handleChange(text)); + expect(decreasable.current().error).toBeUndefined(); + }); + + const increasable = renderField(limit); + act(() => increasable.current().handleChange('11')); + expect(increasable.current().error).toBeUndefined(); + }); + + it('rejects a number past the bound in the direction the field may not move', () => { + const decreasable = renderField(amount); + act(() => decreasable.current().handleChange('50')); + expect(decreasable.current().error?.key).toBe('consent.authorization_details.number_max'); + + const increasable = renderField(limit); + act(() => increasable.current().handleChange('9')); + expect(increasable.current().error?.key).toBe('consent.authorization_details.number_min'); + }); + + it('rejects text that is not a number', () => { + const field = renderField(amount); + ['', ' ', 'abc', 'Infinity'].forEach((text: string) => { + act(() => field.current().handleChange(text)); + expect(field.current().error?.key).toBe('consent.authorization_details.invalid_number'); + }); + }); + + it('accepts a date within the bound and rejects one past it', () => { + const decreasable = renderField(toDate); + act(() => decreasable.current().handleChange('2026-08-31')); + expect(decreasable.current().error).toBeUndefined(); + act(() => decreasable.current().handleChange('2026-10-01')); + expect(decreasable.current().error?.key).toBe('consent.authorization_details.date_max'); + expect(decreasable.current().error?.params).toEqual({value: '2026-09-30'}); + + const increasable = renderField(fromDate); + act(() => increasable.current().handleChange('2026-09-15')); + expect(increasable.current().error).toBeUndefined(); + act(() => increasable.current().handleChange('2026-08-31')); + expect(increasable.current().error?.key).toBe('consent.authorization_details.date_min'); + }); + + it('rejects text that is not a date', () => { + const field = renderField(toDate); + ['', '2026-9-1', '2026-13-45'].forEach((text: string) => { + act(() => field.current().handleChange(text)); + expect(field.current().error?.key).toBe('consent.authorization_details.invalid_date'); + }); + }); + + it('removes an item, adds it back, and keeps the last one', () => { + const field = renderField(actions); + const actionsKey = '__consent_modified__authorization_details:app1__d-1__actions'; + + act(() => field.current().handleToggle('status')); + expect(field.current().formValues[actionsKey]).toBe('["initiate"]'); + expect(field.current().items).toEqual([ + {origin: 'shown', selected: true, toggleable: false, value: 'initiate'}, + {origin: 'shown', selected: false, toggleable: true, value: 'status'}, + ]); + act(() => field.current().handleToggle('initiate')); + expect(field.current().formValues[actionsKey]).toBe('["initiate"]'); + act(() => field.current().handleToggle('status')); + expect(field.current().formValues[actionsKey]).toBe('["initiate","status"]'); + }); + + it('keeps shown items, adds an offered one, and lists it as added', () => { + const field = renderField(excluded); + const excludedKey = '__consent_modified__authorization_details:app1__d-1__excluded'; + + act(() => field.current().handleToggle('gambling')); + expect(field.current().formValues[excludedKey]).toBeUndefined(); + act(() => field.current().handleToggle('crypto')); + expect(field.current().items).toEqual([ + {origin: 'shown', selected: true, toggleable: false, value: 'gambling'}, + {origin: 'added', selected: true, toggleable: true, value: 'crypto'}, + ]); + }); + + it('adds a typed value once, as a number when the list holds numbers', () => { + const typed = renderField(blocked); + let added = false; + act(() => { + added = typed.current().handleAdd(' y '); + }); + expect(added).toBe(true); + expect(typed.current().formValues['__consent_modified__authorization_details:app1__d-1__blocked']).toBe( + '["x","y"]', + ); + expect(renderField(blocked).current().handleAdd('x')).toBe(false); + expect(renderField(blocked).current().handleAdd(' ')).toBe(false); + expect(renderField(excluded).current().handleAdd('travel')).toBe(false); + + const codes = renderField({label: 'Codes', modification: 'addItems', path: 'codes', value: [1]}); + expect(codes.current().handleAdd('two')).toBe(false); + act(() => { + added = codes.current().handleAdd('2'); + }); + expect(added).toBe(true); + expect(codes.current().formValues['__consent_modified__authorization_details:app1__d-1__codes']).toBe('[1,2]'); + }); +}); diff --git a/packages/react/src/hooks/useConsentDetailField.ts b/packages/react/src/hooks/useConsentDetailField.ts new file mode 100644 index 00000000..ec9799cb --- /dev/null +++ b/packages/react/src/hooks/useConsentDetailField.ts @@ -0,0 +1,564 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {type ConsentDetailRow, getConsentInvalidKey, getConsentModifiedKey} from '@thunderid/browser'; +import {useState} from 'react'; +import {UseTranslation} from './useTranslation'; +import resolveConsentText from '../components/adapters/resolveConsentText'; +import getConsentDetailListDisplay, {type ConsentDetailListDisplay} from '../utils/getConsentDetailListDisplay'; +import isConsentDetailPlainList from '../utils/isConsentDetailPlainList'; + +/** + * How the user may change one field of an authorization detail on the consent screen: type a lower or + * higher `number` or `date`, or toggle items of a list whose rule is `removeItems` or `addItems`. + * + * @experimental This type may change in future versions + */ +export type ConsentDetailInputKind = 'number' | 'date' | 'removeItems' | 'addItems'; + +/** + * Error shown for text a user entered into an authorization detail field, as a translation key and the + * values to substitute into its text. + * + * @experimental This interface may change in future versions + */ +export interface ConsentDetailInputError { + /** Translation key of the message. */ + key: string; + /** Values to substitute into the message: `value` is the value that was shown. */ + params: Record; +} + +/** + * Outcome of the text a user entered into a `number` or `date` authorization detail field. + * + * @experimental This interface may change in future versions + */ +interface ConsentDetailInputResult { + /** Why the text cannot be submitted, absent when it can. */ + error?: ConsentDetailInputError; + /** + * Form values to write, keyed by form key: the field's modified value, cleared when the text cannot be + * submitted, and its invalid marker. + */ + formValues: Record; + /** Whether the text may be submitted. */ + isValid: boolean; +} + +/** + * One item of a list field of an authorization detail, as the consent screen offers it. + * + * @experimental This interface may change in future versions + */ +export interface ConsentDetailListItem { + /** + * Where the item comes from: `shown` in the requested detail, `added` by the user, or `offered` by the + * type for the user to add. + */ + origin: 'shown' | 'added' | 'offered'; + /** Whether the item is part of the value that would be granted. */ + selected: boolean; + /** Whether the user may toggle the item. */ + toggleable: boolean; + /** The item. */ + value: string | number | boolean; +} + +const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/; + +type PlainItem = string | number | boolean; + +/** + * Reports whether a value the user entered for an authorization detail field stays within the value + * that was shown, in the direction the field may move. + * + * @param modification - `decrease` or `increase`. + * @param candidate - Value the user entered. + * @param shown - Value that was shown. + * @returns Whether the candidate is allowed. + * + * @example + * ```typescript + * isWithinConsentBound('decrease', 20, 35) // returns true + * isWithinConsentBound('decrease', 40, 35) // returns false + * ``` + */ +const isWithinConsentBound = (modification: string, candidate: T, shown: T): boolean => + modification === 'decrease' ? candidate <= shown : candidate >= shown; + +/** + * Checks the text a user entered for an authorization detail field that may be decreased or increased: a + * number, or a date in `YYYY-MM-DD` form. The text must be a value of the field's kind and stay within the + * value that was shown, in the direction the field may move. + * + * @param row - The field, carrying the shown value and its modification. + * @param text - The text the user entered. + * @returns The error to show, or `undefined` when the text may be submitted. + * + * @example + * ```typescript + * getConsentDetailInputError({label: 'Amount', modification: 'decrease', path: 'amount', value: 35}, '50') + * // returns {key: 'consent.authorization_details.number_max', params: {value: '35'}} + * ``` + */ +const getConsentDetailInputError = (row: ConsentDetailRow, text: string): ConsentDetailInputError | undefined => { + const modification: string = row.modification ?? ''; + const params: Record = {value: String(row.value)}; + + if (typeof row.value === 'number') { + const entered = Number(text); + if (text.trim() === '' || !Number.isFinite(entered)) { + return {key: 'consent.authorization_details.invalid_number', params}; + } + if (!isWithinConsentBound(modification, entered, row.value)) { + return { + key: + modification === 'decrease' + ? 'consent.authorization_details.number_max' + : 'consent.authorization_details.number_min', + params, + }; + } + + return undefined; + } + + if (!DATE_PATTERN.test(text) || Number.isNaN(Date.parse(text))) { + return {key: 'consent.authorization_details.invalid_date', params}; + } + if (!isWithinConsentBound(modification, text, String(row.value))) { + return { + key: + modification === 'decrease' + ? 'consent.authorization_details.date_max' + : 'consent.authorization_details.date_min', + params, + }; + } + + return undefined; +}; + +/** + * Tells how the user may change one field of an authorization detail on the consent screen. A number may + * be lowered or raised, a date in `YYYY-MM-DD` form moved earlier or later, and items of a plain list + * removed (when it has more than one) or added. A date and time is never changed by the user. + * + * @param row - The field. + * @returns The kind of input the field takes, or `undefined` when the field is read-only. + * + * @example + * ```typescript + * getConsentDetailInputKind({label: 'Amount', modification: 'decrease', path: 'amount', value: 35}) // returns 'number' + * ``` + */ +const getConsentDetailInputKind = (row: ConsentDetailRow): ConsentDetailInputKind | undefined => { + const isOrdered: boolean = row.modification === 'decrease' || row.modification === 'increase'; + + if (isOrdered && typeof row.value === 'number') return 'number'; + if (isOrdered && row.as === 'date' && typeof row.value === 'string' && DATE_PATTERN.test(row.value)) return 'date'; + if (row.modification === 'removeItems' && isConsentDetailPlainList(row.value) && row.value.length > 1) + return 'removeItems'; + if (row.modification === 'addItems' && isConsentDetailPlainList(row.value)) return 'addItems'; + + return undefined; +}; + +/** + * Gives the text a `number` or `date` authorization detail field shows when the user is not typing into it: + * the value the user chose, or the value that was shown when the user has not changed it. + * + * @param purposeId - The identifier of the consent purpose the authorization detail belongs to. + * @param detailId - The identifier of the authorization detail, its consent element name. + * @param row - The field. + * @param formValues - Form values, holding the JSON text of each changed field under its modified key. + * @returns The text to show. + * + * @example + * ```typescript + * getConsentDetailInputText('p1', 'd-1', amountRow, {'__consent_modified__p1__d-1__amount': '20'}) // returns '20' + * ``` + */ +const getConsentDetailInputText = ( + purposeId: string, + detailId: string, + row: ConsentDetailRow, + formValues: Record, +): string => { + const raw: unknown = formValues[getConsentModifiedKey(purposeId, detailId, row.path)]; + if (typeof raw === 'string' && raw !== '') { + try { + const value: unknown = JSON.parse(raw); + if (typeof value === 'number' || typeof value === 'string') return String(value); + } catch { + // Not a value the field wrote; show the value that was shown instead. + } + } + + return String(row.value); +}; + +/** + * Turns the text a user entered into a `number` or `date` authorization detail field into the form values + * to write. Text within the field's bound becomes the field's modified value. Any other text clears the + * modified value, so a value entered earlier is never submitted in its place, and marks the field invalid, + * which blocks approval until the user corrects it. + * + * @param purposeId - The identifier of the consent purpose the authorization detail belongs to. + * @param detailId - The identifier of the authorization detail, its consent element name. + * @param row - The field, carrying the shown value and its modification. + * @param text - The text the user entered. + * @returns The error, if any, and the form values to write. + * + * @example + * ```typescript + * parseConsentDetailInput('p1', 'd-1', amountRow, '20') + * // returns {isValid: true, formValues: {'__consent_modified__p1__d-1__amount': '20', '__consent_invalid__p1__d-1__amount': ''}} + * ``` + */ +const parseConsentDetailInput = ( + purposeId: string, + detailId: string, + row: ConsentDetailRow, + text: string, +): ConsentDetailInputResult => { + const modifiedKey: string = getConsentModifiedKey(purposeId, detailId, row.path); + const invalidKey: string = getConsentInvalidKey(purposeId, detailId, row.path); + const error: ConsentDetailInputError | undefined = getConsentDetailInputError(row, text); + + if (error) { + return {error, formValues: {[invalidKey]: 'true', [modifiedKey]: ''}, isValid: false}; + } + + const value: number | string = typeof row.value === 'number' ? Number(text) : text; + + return {formValues: {[invalidKey]: '', [modifiedKey]: JSON.stringify(value)}, isValid: true}; +}; + +/** + * Gives the form values that return an authorization detail field to the value that was shown: no + * modified value and no invalid marker. A field the user left empty is reset this way when it loses focus. + * + * @param purposeId - The identifier of the consent purpose the authorization detail belongs to. + * @param detailId - The identifier of the authorization detail, its consent element name. + * @param path - Dot-separated location of the field within the detail. + * @returns The form values to write, keyed by form key. + * + * @example + * ```typescript + * resetConsentDetailInput('p1', 'd-1', 'amount') + * // returns {'__consent_modified__p1__d-1__amount': '', '__consent_invalid__p1__d-1__amount': ''} + * ``` + */ +const resetConsentDetailInput = (purposeId: string, detailId: string, path: string): Record => ({ + [getConsentInvalidKey(purposeId, detailId, path)]: '', + [getConsentModifiedKey(purposeId, detailId, path)]: '', +}); + +/** + * Lists the items of a `removeItems` or `addItems` authorization detail field as the consent screen offers + * them. For `removeItems`, every shown item is listed, selected while kept; a kept item can be removed + * unless it is the last one, and a removed item can be added back. For `addItems`, the shown items are + * selected and fixed, the items the user added are selected and can be taken out again, and the values the + * type allows are offered unselected. + * + * @param purposeId - The identifier of the consent purpose the authorization detail belongs to. + * @param detailId - The identifier of the authorization detail, its consent element name. + * @param row - The list field. + * @param formValues - Form values, holding the JSON text of each changed field under its modified key. + * @returns The items, in display order. + * + * @example + * ```typescript + * getConsentDetailListItems('p1', 'd-1', {label: 'Actions', modification: 'removeItems', path: 'actions', value: ['a', 'b']}, {}) + * // returns [{origin: 'shown', selected: true, toggleable: true, value: 'a'}, {origin: 'shown', selected: true, toggleable: true, value: 'b'}] + * ``` + */ +const getConsentDetailListItems = ( + purposeId: string, + detailId: string, + row: ConsentDetailRow, + formValues: Record, +): ConsentDetailListItem[] => { + const shown: PlainItem[] = isConsentDetailPlainList(row.value) ? row.value : []; + let current: PlainItem[] = shown; + const raw: unknown = formValues[getConsentModifiedKey(purposeId, detailId, row.path)]; + if (typeof raw === 'string' && raw !== '') { + try { + const parsed: unknown = JSON.parse(raw); + if (isConsentDetailPlainList(parsed)) current = parsed; + } catch { + // Not a list the field wrote; the shown list stands. + } + } + + if (row.modification === 'removeItems') { + const keptCount: number = shown.filter((item: PlainItem) => current.includes(item)).length; + return shown.map((value: PlainItem) => { + const selected: boolean = current.includes(value); + return {origin: 'shown', selected, toggleable: !selected || keptCount > 1, value}; + }); + } + + const added: PlainItem[] = current.filter((item: PlainItem) => !shown.includes(item)); + const offered: PlainItem[] = (isConsentDetailPlainList(row.options) ? row.options : []).filter( + (option: PlainItem) => !shown.includes(option) && !added.includes(option), + ); + + return [ + ...shown.map( + (value: PlainItem): ConsentDetailListItem => ({origin: 'shown', selected: true, toggleable: false, value}), + ), + ...added.map( + (value: PlainItem): ConsentDetailListItem => ({origin: 'added', selected: true, toggleable: true, value}), + ), + ...offered.map( + (value: PlainItem): ConsentDetailListItem => ({origin: 'offered', selected: false, toggleable: true, value}), + ), + ]; +}; + +/** + * Toggles one item of a `removeItems` or `addItems` authorization detail field: a selected item is taken + * out and an unselected one put in, within what the field's rule allows. + * + * @param purposeId - The identifier of the consent purpose the authorization detail belongs to. + * @param detailId - The identifier of the authorization detail, its consent element name. + * @param row - The list field. + * @param formValues - Form values, holding the JSON text of each changed field under its modified key. + * @param item - The item to toggle. + * @returns The form values to write, keyed by form key, or `undefined` when the item cannot be toggled. + * + * @example + * ```typescript + * toggleConsentDetailListItem('p1', 'd-1', actionsRow, {}, 'status') // returns {'__consent_modified__p1__d-1__actions': '["initiate"]'} + * ``` + */ +const toggleConsentDetailListItem = ( + purposeId: string, + detailId: string, + row: ConsentDetailRow, + formValues: Record, + item: string | number | boolean, +): Record | undefined => { + const items: ConsentDetailListItem[] = getConsentDetailListItems(purposeId, detailId, row, formValues); + const target: ConsentDetailListItem | undefined = items.find((entry: ConsentDetailListItem) => entry.value === item); + if (!target?.toggleable) return undefined; + + const next: (string | number | boolean)[] = items + .filter((entry: ConsentDetailListItem) => (entry === target ? !entry.selected : entry.selected)) + .map((entry: ConsentDetailListItem) => entry.value); + + return {[getConsentModifiedKey(purposeId, detailId, row.path)]: JSON.stringify(next)}; +}; + +/** + * Adds a typed value to an `addItems` authorization detail field whose type allows any value. The text is + * trimmed, and read as a number when every shown item is a number. + * + * @param purposeId - The identifier of the consent purpose the authorization detail belongs to. + * @param detailId - The identifier of the authorization detail, its consent element name. + * @param row - The list field. + * @param formValues - Form values, holding the JSON text of each changed field under its modified key. + * @param text - The text the user typed. + * @returns The form values to write, keyed by form key, or `undefined` when the text is empty, not a + * number where one is needed, already listed, or the field does not take typed values. + * + * @example + * ```typescript + * addConsentDetailListItem('p1', 'd-1', blockedRow, {}, ' y ') // returns {'__consent_modified__p1__d-1__blocked': '["x","y"]'} + * ``` + */ +const addConsentDetailListItem = ( + purposeId: string, + detailId: string, + row: ConsentDetailRow, + formValues: Record, + text: string, +): Record | undefined => { + if (row.modification !== 'addItems' || row.options !== undefined || !Array.isArray(row.value)) return undefined; + const trimmed: string = text.trim(); + if (!trimmed) return undefined; + + const numeric: boolean = row.value.length > 0 && row.value.every((item: unknown) => typeof item === 'number'); + const value: string | number = numeric ? Number(trimmed) : trimmed; + const selected: (string | number | boolean)[] = getConsentDetailListItems(purposeId, detailId, row, formValues) + .filter((entry: ConsentDetailListItem) => entry.selected) + .map((entry: ConsentDetailListItem) => entry.value); + if ((numeric && !Number.isFinite(value)) || selected.includes(value)) return undefined; + + return {[getConsentModifiedKey(purposeId, detailId, row.path)]: JSON.stringify([...selected, value])}; +}; + +/** + * Props for the useConsentDetailField hook. + */ +export interface UseConsentDetailFieldProps { + /** The identifier of the consent purpose the authorization detail belongs to. */ + purposeId: string; + /** The identifier of the authorization detail the field belongs to, its consent element name. */ + detailId: string; + /** Current consent form values. */ + formValues: Record; + /** Callback that writes one consent form value. */ + onInputChange: (name: string, value: string) => void; + /** The field. */ + row: ConsentDetailRow; + /** Translator for the error message; the SDK's default bundle is used without one. */ + t?: UseTranslation['t']; +} + +/** + * Error of a field, with its message resolved. + */ +export interface ConsentDetailFieldError extends ConsentDetailInputError { + /** Translated message. */ + message: string; +} + +/** + * State and handlers of one authorization detail field, for drawing it in any UI. + */ +export interface UseConsentDetailFieldResult { + /** Why the text shown cannot be submitted, absent when it can. */ + error?: ConsentDetailFieldError; + /** Adds a typed item to an `addItems` list that takes typed values; returns whether it was added. */ + handleAdd: (text: string) => boolean; + /** Call when the field loses focus: an empty field returns to the value that was shown. */ + handleBlur: () => void; + /** Call with the text the user entered into a `number` or `date` field, or the item typed into a list. */ + handleChange: (text: string) => void; + /** Expands or collapses the field's list. */ + handleListToggle: (expanded: boolean) => void; + /** Toggles an item of a list field, within what its rule allows. */ + handleToggle: (item: string | number | boolean) => void; + /** Identifier for the field's input element. */ + inputId: string; + /** Whether the user expanded the field's list past `CONSENT_DETAIL_DISPLAY_LIMITS.listItems`. */ + isListExpanded: boolean; + /** Whether the field's value is a list of plain values, shown as a list rather than as structured JSON. */ + isPlainList: boolean; + /** Whether an item of a list field is part of the value that would be granted. */ + isSelected: (item: string | number | boolean) => boolean; + /** Whether the user may toggle an item of a list field. */ + isToggleable: (item: string | number | boolean) => boolean; + /** Whether the field takes typed items: an `addItems` list whose type offers no fixed values. */ + isTypedItemAllowed: boolean; + /** Whether the field's text may be submitted. Always true for a list or a read-only field. */ + isValid: boolean; + /** Items of a list field, in display order; empty for any other field. */ + items: ConsentDetailListItem[]; + /** How the user may change the field, or `undefined` when it is read-only. */ + kind?: ConsentDetailInputKind; + /** Label of the field. */ + label: string; + /** + * How many items of the field's list to show and hide: the items of a list the user may change, or the + * values of a read-only plain list. Nothing is hidden for any other field. + */ + listDisplay: ConsentDetailListDisplay; + /** Upper bound of a `number` or `date` field that may be decreased. */ + max?: string; + /** Lower bound of a `number` or `date` field that may be increased. */ + min?: string; + /** Text to show: the typed text of a `number` or `date` field, or the item being typed into a list. */ + text: string; +} + +/** + * Headless state of one authorization detail field on the consent screen. A `number` or `date` field keeps + * the text the user typed, writes it to the decision only while it stays within the field's bound, and + * otherwise clears the field's modification and marks the field invalid, which blocks approval (see + * `isConsentApprovalBlocked`). A list field exposes its items and toggles them as its rule allows. + * + * @param props - The field and the consent form state. + * @returns The field's state and handlers. + * + * @example + * ```tsx + * const field = useConsentDetailField({purposeId, detailId, row, formValues, onInputChange}); + * field.handleChange(e.target.value)} onBlur={field.handleBlur} /> + * ``` + */ +const useConsentDetailField = ({ + detailId, + formValues, + onInputChange, + purposeId, + row, + t, +}: UseConsentDetailFieldProps): UseConsentDetailFieldResult => { + // The text the user typed, kept as typed even when it cannot be submitted. + const [draft, setDraft] = useState(undefined); + const [isListExpanded, setListExpanded] = useState(false); + const kind: ConsentDetailInputKind | undefined = getConsentDetailInputKind(row); + const isScalar: boolean = kind === 'number' || kind === 'date'; + const isList: boolean = kind === 'removeItems' || kind === 'addItems'; + const items: ConsentDetailListItem[] = isList ? getConsentDetailListItems(purposeId, detailId, row, formValues) : []; + const isPlainList: boolean = isConsentDetailPlainList(row.value); + let listCount = 0; + if (isList) { + listCount = items.length; + } else if (isConsentDetailPlainList(row.value)) { + listCount = row.value.length; + } + const find = (item: string | number | boolean): ConsentDetailListItem | undefined => + items.find((entry: ConsentDetailListItem) => entry.value === item); + + const write = (values: Record | undefined): void => { + Object.entries(values ?? {}).forEach(([name, value]: [string, string]) => onInputChange(name, value)); + }; + + const inputError: ConsentDetailInputError | undefined = + isScalar && draft !== undefined ? getConsentDetailInputError(row, draft) : undefined; + + return { + error: inputError && {...inputError, message: resolveConsentText(t, inputError.key, inputError.params)}, + handleAdd: (text: string): boolean => { + const values: Record | undefined = addConsentDetailListItem( + purposeId, + detailId, + row, + formValues, + text, + ); + write(values); + if (values) setDraft(undefined); + return Boolean(values); + }, + handleBlur: (): void => { + if (!isScalar || draft === undefined) return; + if (draft.trim() === '') { + write(resetConsentDetailInput(purposeId, detailId, row.path)); + setDraft(undefined); + } else if (!getConsentDetailInputError(row, draft)) { + setDraft(undefined); + } + }, + handleChange: (text: string): void => { + setDraft(text); + if (isScalar) write(parseConsentDetailInput(purposeId, detailId, row, text).formValues); + }, + handleListToggle: setListExpanded, + handleToggle: (item: string | number | boolean): void => { + write(toggleConsentDetailListItem(purposeId, detailId, row, formValues, item)); + }, + inputId: `consent_modify_${purposeId}_${detailId}_${row.path}`, + isListExpanded, + isPlainList, + isSelected: (item: string | number | boolean): boolean => Boolean(find(item)?.selected), + isToggleable: (item: string | number | boolean): boolean => Boolean(find(item)?.toggleable), + isTypedItemAllowed: kind === 'addItems' && row.options === undefined, + isValid: !inputError, + items, + kind, + label: row.label || row.path, + listDisplay: getConsentDetailListDisplay(listCount, isListExpanded), + max: isScalar && row.modification === 'decrease' ? String(row.value) : undefined, + min: isScalar && row.modification === 'increase' ? String(row.value) : undefined, + text: draft ?? (isScalar ? getConsentDetailInputText(purposeId, detailId, row, formValues) : ''), + }; +}; + +export default useConsentDetailField; diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 673d83e6..8cd9fde9 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -154,6 +154,33 @@ export * from './components/adapters/FlowTimer'; export {default as ConsentCheckboxList} from './components/adapters/ConsentCheckboxList'; export * from './components/adapters/ConsentCheckboxList'; +export {default as ConsentAuthorizationDetail} from './components/adapters/ConsentAuthorizationDetail'; +export type { + ConsentAuthorizationDetailProps, + ConsentAuthorizationDetailRenderProps, +} from './components/adapters/ConsentAuthorizationDetail'; + +export {default as ConsentDetailField} from './components/adapters/ConsentDetailField'; +export type {ConsentDetailFieldProps} from './components/adapters/ConsentDetailField'; + +export {default as isConsentApprovalBlocked} from './utils/isConsentApprovalBlocked'; + +export {default as formatConsentDetailValue} from './utils/formatConsentDetailValue'; +export {default as getConsentDetailLayout, CONSENT_DETAIL_DISPLAY_LIMITS} from './utils/getConsentDetailLayout'; +export {default as isConsentDetailStructured} from './utils/isConsentDetailStructured'; +export {default as isConsentDetailTextClamped} from './utils/isConsentDetailTextClamped'; +export type {ConsentDetailListDisplay} from './utils/getConsentDetailListDisplay'; + +export {default as useConsentDetailField} from './hooks/useConsentDetailField'; +export type { + ConsentDetailFieldError, + ConsentDetailInputError, + ConsentDetailInputKind, + ConsentDetailListItem, + UseConsentDetailFieldProps, + UseConsentDetailFieldResult, +} from './hooks/useConsentDetailField'; + export {default as Consent} from './components/adapters/Consent'; export * from './components/adapters/Consent'; @@ -373,6 +400,11 @@ export { type SelectPage, type SelectPageRequest, type UserSelectOptionMapping, + type ConsentDetailRow, + type ConsentDetailValueType, + type ConsentDetailModification, + getConsentModifiedKey, + CONSENT_MODIFIED_KEY_PREFIX, } from '@thunderid/browser'; export { diff --git a/packages/react/src/utils/__tests__/formatConsentDetailValue.test.ts b/packages/react/src/utils/__tests__/formatConsentDetailValue.test.ts new file mode 100644 index 00000000..29fe5685 --- /dev/null +++ b/packages/react/src/utils/__tests__/formatConsentDetailValue.test.ts @@ -0,0 +1,18 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {describe, expect, it} from 'vitest'; +import formatConsentDetailValue from '../formatConsentDetailValue'; + +describe('formatConsentDetailValue', (): void => { + it('should format plain values and lists', (): void => { + expect(formatConsentDetailValue('Merchant A')).toBe('Merchant A'); + expect(formatConsentDetailValue(['initiate', 'status'])).toBe('initiate, status'); + expect(formatConsentDetailValue(null)).toBe(''); + }); + + it('should format structured JSON compactly unless an indent is given', (): void => { + expect(formatConsentDetailValue({iban: 'DE02'})).toBe('{"iban":"DE02"}'); + expect(formatConsentDetailValue({iban: 'DE02'}, 2)).toBe('{\n "iban": "DE02"\n}'); + }); +}); diff --git a/packages/react/src/utils/__tests__/getConsentDetailLayout.test.ts b/packages/react/src/utils/__tests__/getConsentDetailLayout.test.ts new file mode 100644 index 00000000..10da0103 --- /dev/null +++ b/packages/react/src/utils/__tests__/getConsentDetailLayout.test.ts @@ -0,0 +1,47 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {describe, expect, it} from 'vitest'; +import getConsentDetailLayout from '../getConsentDetailLayout'; + +describe('getConsentDetailLayout', (): void => { + it('should keep a short field beside its label', (): void => { + expect(getConsentDetailLayout({label: 'Pay to', path: 'creditorName', value: 'Merchant A'})).toBe('inline'); + expect(getConsentDetailLayout({label: 'Amount', modification: 'decrease', path: 'amount', value: 35})).toBe( + 'inline', + ); + expect(getConsentDetailLayout({label: 'Actions', path: 'actions', value: ['initiate', 'status']})).toBe('inline'); + }); + + it('should stack a field with a long label or a long value', (): void => { + expect( + getConsentDetailLayout({label: 'Beneficiary bank correspondent routing reference', path: 'ref', value: 'R'}), + ).toBe('stacked'); + expect( + getConsentDetailLayout({label: 'Account', path: 'iban', value: 'DE89370400440532013000DE893704004405'}), + ).toBe('stacked'); + }); + + it('should stack a long list and count the values a list offers to add', (): void => { + expect( + getConsentDetailLayout({ + label: 'Regions', + path: 'regions', + value: ['Region-01', 'Region-02', 'Region-03', 'Region-04'], + }), + ).toBe('stacked'); + expect( + getConsentDetailLayout({ + label: 'Excluded', + modification: 'addItems', + options: ['casino', 'crypto', 'lottery', 'betting', 'forex'], + path: 'excluded', + value: ['casino'], + }), + ).toBe('stacked'); + }); + + it('should stack structured JSON', (): void => { + expect(getConsentDetailLayout({label: 'Account', path: 'account', value: {iban: 'DE02'}})).toBe('stacked'); + }); +}); diff --git a/packages/react/src/utils/__tests__/getConsentDetailListDisplay.test.ts b/packages/react/src/utils/__tests__/getConsentDetailListDisplay.test.ts new file mode 100644 index 00000000..6cce01a4 --- /dev/null +++ b/packages/react/src/utils/__tests__/getConsentDetailListDisplay.test.ts @@ -0,0 +1,31 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {describe, expect, it} from 'vitest'; +import {CONSENT_DETAIL_DISPLAY_LIMITS} from '../getConsentDetailLayout'; +import getConsentDetailListDisplay from '../getConsentDetailListDisplay'; + +describe('getConsentDetailListDisplay', (): void => { + const {listItems} = CONSENT_DETAIL_DISPLAY_LIMITS; + + it('should show every item of a list that fits', (): void => { + expect(getConsentDetailListDisplay(listItems, false)).toEqual({ + hiddenCount: 0, + isCollapsible: false, + visibleCount: listItems, + }); + }); + + it('should hide the items past the limit until the list is expanded', (): void => { + expect(getConsentDetailListDisplay(listItems + 2, false)).toEqual({ + hiddenCount: 2, + isCollapsible: true, + visibleCount: listItems, + }); + expect(getConsentDetailListDisplay(listItems + 2, true)).toEqual({ + hiddenCount: 0, + isCollapsible: true, + visibleCount: listItems + 2, + }); + }); +}); diff --git a/packages/react/src/utils/__tests__/isConsentApprovalBlocked.test.ts b/packages/react/src/utils/__tests__/isConsentApprovalBlocked.test.ts new file mode 100644 index 00000000..33998fa3 --- /dev/null +++ b/packages/react/src/utils/__tests__/isConsentApprovalBlocked.test.ts @@ -0,0 +1,94 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {type ConsentPurposeData, getConsentInvalidKey} from '@thunderid/browser'; +import {describe, expect, it} from 'vitest'; +import {getConsentOptionalKey} from '../../components/adapters/ConsentCheckboxList'; +import isConsentApprovalBlocked from '../isConsentApprovalBlocked'; + +const PURPOSE_ID = 'authorization_details:app1'; +const AMOUNT_INVALID_KEY = `__consent_invalid__${PURPOSE_ID}__d-1__amount`; + +describe('isConsentApprovalBlocked', (): void => { + const purposes: ConsentPurposeData[] = [ + { + essential: [], + optional: [ + { + detailType: 'payment_initiation', + name: 'd-1', + rows: [{as: 'number', label: 'Amount', modification: 'decrease', path: 'amount', value: 35}], + }, + ], + purposeId: PURPOSE_ID, + type: 'authorization_details', + }, + ]; + const approvedKey: string = getConsentOptionalKey(PURPOSE_ID, 'd-1'); + + it('should block approval while an approved detail has an invalid field', (): void => { + expect(isConsentApprovalBlocked(purposes, {[AMOUNT_INVALID_KEY]: 'true', [approvedKey]: 'true'})).toBe(true); + expect(isConsentApprovalBlocked(purposes, {[AMOUNT_INVALID_KEY]: 'true'})).toBe(true); + }); + + it('should allow approval once the field is valid or its detail is declined', (): void => { + expect(isConsentApprovalBlocked(purposes, {[AMOUNT_INVALID_KEY]: '', [approvedKey]: 'true'})).toBe(false); + expect(isConsentApprovalBlocked(purposes, {[AMOUNT_INVALID_KEY]: 'true', [approvedKey]: 'false'})).toBe(false); + }); + + describe('for one authorization detail element', (): void => { + const detailPurposes: ConsentPurposeData[] = [ + { + essential: [], + optional: [ + { + detailType: 'payment_initiation', + name: 'd-1', + rows: [ + {as: 'number', label: 'Amount', modification: 'decrease', path: 'instructedAmount.amount', value: 35}, + {label: 'Pay to', path: 'creditorName', value: 'Merchant A'}, + ], + }, + {name: 'email'}, + ], + purposeId: 'p1', + type: 'authorization_details', + }, + ]; + + it('should block on a modifiable field marked invalid', (): void => { + expect( + isConsentApprovalBlocked(detailPurposes, { + [getConsentInvalidKey('p1', 'd-1', 'instructedAmount.amount')]: 'true', + }), + ).toBe(true); + }); + + it('should not block on a field whose marker was cleared, or a field of another detail or purpose', (): void => { + expect( + isConsentApprovalBlocked(detailPurposes, { + [getConsentInvalidKey('p1', 'd-1', 'instructedAmount.amount')]: '', + }), + ).toBe(false); + expect( + isConsentApprovalBlocked(detailPurposes, { + [getConsentInvalidKey('p1', 'd-2', 'instructedAmount.amount')]: 'true', + }), + ).toBe(false); + expect( + isConsentApprovalBlocked(detailPurposes, { + [getConsentInvalidKey('p2', 'd-1', 'instructedAmount.amount')]: 'true', + }), + ).toBe(false); + }); + + it('should ignore a read-only field and an element without fields', (): void => { + expect( + isConsentApprovalBlocked(detailPurposes, {[getConsentInvalidKey('p1', 'd-1', 'creditorName')]: 'true'}), + ).toBe(false); + expect(isConsentApprovalBlocked(detailPurposes, {[getConsentInvalidKey('p1', 'email', 'email')]: 'true'})).toBe( + false, + ); + }); + }); +}); diff --git a/packages/react/src/utils/__tests__/isConsentDetailPlainList.test.ts b/packages/react/src/utils/__tests__/isConsentDetailPlainList.test.ts new file mode 100644 index 00000000..705804b0 --- /dev/null +++ b/packages/react/src/utils/__tests__/isConsentDetailPlainList.test.ts @@ -0,0 +1,14 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {describe, expect, it} from 'vitest'; +import isConsentDetailPlainList from '../isConsentDetailPlainList'; + +describe('isConsentDetailPlainList', (): void => { + it('should tell a list of plain values from any other value', (): void => { + expect(isConsentDetailPlainList(['a', 1, true])).toBe(true); + expect(isConsentDetailPlainList([])).toBe(true); + expect(isConsentDetailPlainList([{date: '2026-11-01'}])).toBe(false); + expect(isConsentDetailPlainList('a')).toBe(false); + }); +}); diff --git a/packages/react/src/utils/__tests__/isConsentDetailStructured.test.ts b/packages/react/src/utils/__tests__/isConsentDetailStructured.test.ts new file mode 100644 index 00000000..21567d9c --- /dev/null +++ b/packages/react/src/utils/__tests__/isConsentDetailStructured.test.ts @@ -0,0 +1,15 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {describe, expect, it} from 'vitest'; +import isConsentDetailStructured from '../isConsentDetailStructured'; + +describe('isConsentDetailStructured', (): void => { + it('should tell structured JSON from plain values', (): void => { + expect(isConsentDetailStructured({iban: 'DE02'})).toBe(true); + expect(isConsentDetailStructured([{date: '2026-11-01'}])).toBe(true); + expect(isConsentDetailStructured(['a', 'b'])).toBe(false); + expect(isConsentDetailStructured(null)).toBe(false); + expect(isConsentDetailStructured('text')).toBe(false); + }); +}); diff --git a/packages/react/src/utils/__tests__/isConsentDetailTextClamped.test.ts b/packages/react/src/utils/__tests__/isConsentDetailTextClamped.test.ts new file mode 100644 index 00000000..04824042 --- /dev/null +++ b/packages/react/src/utils/__tests__/isConsentDetailTextClamped.test.ts @@ -0,0 +1,14 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {describe, expect, it} from 'vitest'; +import isConsentDetailTextClamped from '../isConsentDetailTextClamped'; + +describe('isConsentDetailTextClamped', (): void => { + it('should clamp text longer than its character or line limit', (): void => { + expect(isConsentDetailTextClamped('abcd', 4, 1)).toBe(false); + expect(isConsentDetailTextClamped('abcde', 4, 1)).toBe(true); + expect(isConsentDetailTextClamped('a\nb', 10, 1)).toBe(true); + expect(isConsentDetailTextClamped('a\nb', 10, 2)).toBe(false); + }); +}); diff --git a/packages/react/src/utils/formatConsentDetailValue.ts b/packages/react/src/utils/formatConsentDetailValue.ts new file mode 100644 index 00000000..c41a3641 --- /dev/null +++ b/packages/react/src/utils/formatConsentDetailValue.ts @@ -0,0 +1,26 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +/** + * Formats an authorization detail field value for display. Strings are shown as-is, a list of + * plain values comma-separated, and any other JSON value as JSON, compact unless `indent` is given. + * + * @param value - Field value. + * @param indent - Spaces to indent structured JSON by, so it reads as a block rather than one long line. + * @returns Display text. + * + * @example + * ```typescript + * formatConsentDetailValue(['initiate', 'status']) // returns 'initiate, status' + * ``` + */ +const formatConsentDetailValue = (value: unknown, indent?: number): string => { + if (value === null || value === undefined) return ''; + if (typeof value === 'string') return value; + if (Array.isArray(value) && value.every((item: unknown) => typeof item !== 'object' || item === null)) { + return value.map((item: unknown) => String(item)).join(', '); + } + return JSON.stringify(value, null, indent); +}; + +export default formatConsentDetailValue; diff --git a/packages/react/src/utils/getConsentDetailLayout.ts b/packages/react/src/utils/getConsentDetailLayout.ts new file mode 100644 index 00000000..963a2a95 --- /dev/null +++ b/packages/react/src/utils/getConsentDetailLayout.ts @@ -0,0 +1,61 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {type ConsentDetailRow} from '@thunderid/browser'; +import formatConsentDetailValue from './formatConsentDetailValue'; +import isConsentDetailStructured from './isConsentDetailStructured'; + +/** + * Limits a consent screen applies to authorization detail content so that a long or large value never + * crowds the layout. Text longer than a `*Chars` limit is clamped to its `*Lines` limit behind a toggle, + * and a list longer than `listItems` shows that many items behind a toggle. + */ +export const CONSENT_DETAIL_DISPLAY_LIMITS: Readonly<{ + inlineLabelChars: number; + inlineValueChars: number; + listItems: number; + structuredLines: number; + summaryChars: number; + summaryLines: number; + valueChars: number; + valueLines: number; +}> = Object.freeze({ + inlineLabelChars: 32, + inlineValueChars: 32, + listItems: 8, + structuredLines: 8, + summaryChars: 160, + summaryLines: 3, + valueChars: 160, + valueLines: 4, +}); + +/** + * Chooses how a consent screen lays out one authorization detail field. A short label and a short value + * sit side by side; a long label, a long value, a long list or a structured value is stacked, the label + * above the value, so neither squeezes the other. + * + * @param row - Field to lay out. + * @returns `inline` or `stacked`. + * + * @example + * ```typescript + * getConsentDetailLayout({label: 'Pay to', path: 'creditorName', value: 'Merchant A'}) // returns 'inline' + * ``` + */ +const getConsentDetailLayout = (row: ConsentDetailRow): 'inline' | 'stacked' => { + const label: string = row.label || row.path; + if (label.length > CONSENT_DETAIL_DISPLAY_LIMITS.inlineLabelChars || isConsentDetailStructured(row.value)) { + return 'stacked'; + } + + const items: unknown[] = Array.isArray(row.value) ? [...(row.value as unknown[])] : [row.value]; + if (row.modification === 'addItems' && Array.isArray(row.options)) { + items.push(...row.options); + } + const text: string = formatConsentDetailValue(items); + + return text.length > CONSENT_DETAIL_DISPLAY_LIMITS.inlineValueChars ? 'stacked' : 'inline'; +}; + +export default getConsentDetailLayout; diff --git a/packages/react/src/utils/getConsentDetailListDisplay.ts b/packages/react/src/utils/getConsentDetailListDisplay.ts new file mode 100644 index 00000000..a3e55f1b --- /dev/null +++ b/packages/react/src/utils/getConsentDetailListDisplay.ts @@ -0,0 +1,41 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {CONSENT_DETAIL_DISPLAY_LIMITS} from './getConsentDetailLayout'; + +/** + * How a consent screen shows a list of authorization detail items, given the + * `CONSENT_DETAIL_DISPLAY_LIMITS.listItems` limit. + * + * @experimental This interface may change in future versions + */ +export interface ConsentDetailListDisplay { + /** Number of items hidden behind the toggle; `0` while expanded or when the list fits. */ + hiddenCount: number; + /** Whether the list is longer than the limit, so it is shown behind a toggle. */ + isCollapsible: boolean; + /** Number of items to show. */ + visibleCount: number; +} + +/** + * Tells how many items of an authorization detail list a consent screen shows. A list longer than + * `CONSENT_DETAIL_DISPLAY_LIMITS.listItems` shows that many items while collapsed, the rest behind a toggle. + * + * @param itemCount - Number of items in the list. + * @param expanded - Whether the user expanded the list. + * @returns The items to show and hide. + * + * @example + * ```typescript + * getConsentDetailListDisplay(10, false) // returns {hiddenCount: 2, isCollapsible: true, visibleCount: 8} + * ``` + */ +const getConsentDetailListDisplay = (itemCount: number, expanded: boolean): ConsentDetailListDisplay => { + const isCollapsible: boolean = itemCount > CONSENT_DETAIL_DISPLAY_LIMITS.listItems; + const visibleCount: number = isCollapsible && !expanded ? CONSENT_DETAIL_DISPLAY_LIMITS.listItems : itemCount; + + return {hiddenCount: itemCount - visibleCount, isCollapsible, visibleCount}; +}; + +export default getConsentDetailListDisplay; diff --git a/packages/react/src/utils/isConsentApprovalBlocked.ts b/packages/react/src/utils/isConsentApprovalBlocked.ts new file mode 100644 index 00000000..f54d60bb --- /dev/null +++ b/packages/react/src/utils/isConsentApprovalBlocked.ts @@ -0,0 +1,59 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import { + type ConsentDetailRow, + type ConsentPurposeData, + getConsentInvalidKey, + type PromptElement, +} from '@thunderid/browser'; +import {getConsentOptionalKey} from '../components/adapters/ConsentCheckboxList'; + +/** + * Reports whether any field the user may modify on an authorization detail element shows text that cannot + * be submitted. The element must not be approved while this holds, since its decision would not carry what + * the user sees. + * + * @param purposeId - The identifier of the consent purpose the element belongs to. + * @param element - The authorization detail element. + * @param inputs - Form values, holding the invalid marker of each field under its invalid key. + * @returns Whether a field of the element is invalid. + * + * @example + * ```typescript + * hasInvalidConsentModification('p1', element, {'__consent_invalid__p1__d-1__amount': 'true'}) // returns true + * ``` + */ +const hasInvalidConsentModification = ( + purposeId: string, + element: PromptElement, + inputs: Record, +): boolean => + (element.rows ?? []).some( + (row: ConsentDetailRow) => + Boolean(row.modification) && inputs[getConsentInvalidKey(purposeId, element.name, row.path)] === 'true', + ); + +/** + * Reports whether the consent cannot be approved yet: an authorization detail the user has not declined + * shows, in a field the user may modify, text that cannot be submitted. Denying stays possible. + * + * @param purposes - The consent purposes on the screen. + * @param formValues - Form values, holding each element's approval and each field's invalid marker. + * @returns Whether approval must wait until the user corrects the field. + * + * @example + * ```typescript + * isConsentApprovalBlocked(purposes, {'__consent_invalid__p1__d-1__amount': 'true'}) // returns true + * ``` + */ +const isConsentApprovalBlocked = (purposes: ConsentPurposeData[], formValues: Record): boolean => + purposes.some((purpose: ConsentPurposeData) => + (purpose.optional ?? []).some( + (element: PromptElement) => + formValues[getConsentOptionalKey(purpose.purposeId, element.name)] !== 'false' && + hasInvalidConsentModification(purpose.purposeId, element, formValues), + ), + ); + +export default isConsentApprovalBlocked; diff --git a/packages/react/src/utils/isConsentDetailPlainList.ts b/packages/react/src/utils/isConsentDetailPlainList.ts new file mode 100644 index 00000000..06918be9 --- /dev/null +++ b/packages/react/src/utils/isConsentDetailPlainList.ts @@ -0,0 +1,19 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +/** + * Whether a field value is a list of plain values, strings, numbers or booleans, which a consent screen shows + * as a list rather than as structured JSON. + * + * @param value - Field value. + * @returns `true` when the value is a list of plain values. + * + * @example + * ```typescript + * isConsentDetailPlainList(['initiate', 'status']) // returns true + * ``` + */ +const isConsentDetailPlainList = (value: unknown): value is (string | number | boolean)[] => + Array.isArray(value) && value.every((item: unknown) => ['string', 'number', 'boolean'].includes(typeof item)); + +export default isConsentDetailPlainList; diff --git a/packages/react/src/utils/isConsentDetailStructured.ts b/packages/react/src/utils/isConsentDetailStructured.ts new file mode 100644 index 00000000..9be3061c --- /dev/null +++ b/packages/react/src/utils/isConsentDetailStructured.ts @@ -0,0 +1,15 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +/** + * Whether a field value is structured JSON, an object or a list holding objects, rather than a plain + * value or a list of plain values. + * + * @param value - Field value. + * @returns `true` when the value is shown as a formatted JSON block. + */ +const isConsentDetailStructured = (value: unknown): boolean => + (typeof value === 'object' && value !== null && !Array.isArray(value)) || + (Array.isArray(value) && value.some((item: unknown) => typeof item === 'object' && item !== null)); + +export default isConsentDetailStructured; diff --git a/packages/react/src/utils/isConsentDetailTextClamped.ts b/packages/react/src/utils/isConsentDetailTextClamped.ts new file mode 100644 index 00000000..603c0561 --- /dev/null +++ b/packages/react/src/utils/isConsentDetailTextClamped.ts @@ -0,0 +1,24 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +/** + * Whether authorization detail text is long enough to be clamped behind a toggle: longer than `maxChars`, or + * spanning more than `maxLines` lines. + * + * @param text - Text to show. + * @param maxChars - Number of characters above which the text is clamped, such as + * `CONSENT_DETAIL_DISPLAY_LIMITS.valueChars`. + * @param maxLines - Number of lines above which the text is clamped, such as + * `CONSENT_DETAIL_DISPLAY_LIMITS.valueLines`. + * @returns `true` when the text is clamped while collapsed. + * + * @example + * ```typescript + * isConsentDetailTextClamped('Merchant A', CONSENT_DETAIL_DISPLAY_LIMITS.valueChars, CONSENT_DETAIL_DISPLAY_LIMITS.valueLines) + * // returns false + * ``` + */ +const isConsentDetailTextClamped = (text: string, maxChars: number, maxLines: number): boolean => + text.length > maxChars || text.split('\n').length > maxLines; + +export default isConsentDetailTextClamped; diff --git a/packages/vue/src/__tests__/client/authorization-details.test.ts b/packages/vue/src/__tests__/client/authorization-details.test.ts new file mode 100644 index 00000000..80694453 --- /dev/null +++ b/packages/vue/src/__tests__/client/authorization-details.test.ts @@ -0,0 +1,23 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ThunderIDBrowserClient} from '@thunderid/browser'; +import {afterEach, describe, expect, it, vi} from 'vitest'; +import ThunderIDVueClient from '../../ThunderIDVueClient'; + +describe('ThunderIDVueClient redirect sign-in', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('passes RFC 9396 authorization details to the authorize request options', async () => { + const signIn = vi.spyOn(ThunderIDBrowserClient.prototype, 'signIn').mockResolvedValue(undefined); + const authorizationDetails: Record[] = [ + {instructedAmount: {amount: '35', currency: 'EUR'}, type: 'payment_initiation'}, + ]; + + await new ThunderIDVueClient().signIn({authorization_details: authorizationDetails}); + + expect(signIn).toHaveBeenCalledWith({authorization_details: authorizationDetails}); + }); +}); diff --git a/packages/vue/src/__tests__/components/consent-decisions.test.ts b/packages/vue/src/__tests__/components/consent-decisions.test.ts new file mode 100644 index 00000000..9cfcfdef --- /dev/null +++ b/packages/vue/src/__tests__/components/consent-decisions.test.ts @@ -0,0 +1,181 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import { + type ConsentDecisions, + type EmbeddedFlowComponent, + EmbeddedFlowComponentType, + EmbeddedFlowEventType, +} from '@thunderid/browser'; +import {flushPromises, mount, VueWrapper} from '@vue/test-utils'; +import {describe, expect, it, vi, type Mock} from 'vitest'; +import {defineComponent, h, ref, type VNode} from 'vue'; +import {renderSignInComponents} from '../../components/auth/sign-in/AuthOptionFactory'; +import SignIn from '../../components/auth/sign-in/SignIn'; +import {FLOW_META_KEY, I18N_KEY, THUNDERID_KEY} from '../../keys'; + +const PURPOSE_ID = 'authorization_details:app1'; +const MODIFIED_KEY = `__consent_modified__${PURPOSE_ID}__d-1__instructedAmount.amount`; +const INVALID_KEY = `__consent_invalid__${PURPOSE_ID}__d-1__instructedAmount.amount`; +const APPROVED_KEY = `__consent_opt__${PURPOSE_ID}__d-1`; + +const consentAction = (variant: string): EmbeddedFlowComponent => + ({ + eventType: EmbeddedFlowEventType.Submit, + id: `action_${variant}`, + label: variant, + type: EmbeddedFlowComponentType.Action, + variant, + }) as unknown as EmbeddedFlowComponent; + +const detailPurpose: Record = { + essential: [], + optional: [ + { + detailType: 'payment_initiation', + name: 'd-1', + rows: [{as: 'number', label: 'Amount', modification: 'decrease', path: 'instructedAmount.amount', value: 35}], + }, + ], + purposeId: PURPOSE_ID, + purposeName: PURPOSE_ID, + type: 'authorization_details', +}; + +const attributePurpose: Record = { + essential: [{name: 'email'}], + optional: [{name: 'phone'}], + purposeId: 'p1', + purposeName: 'Profile', + type: 'attributes', +}; + +describe('AuthOptionFactory consent decisions', () => { + const submit = (variant: string, formValues: Record): Record => { + const onSubmit: Mock = vi.fn(); + const elements: VNode[] = renderSignInComponents( + [consentAction(variant)], + formValues, + {}, + {}, + false, + true, + () => undefined, + () => undefined, + {additionalData: {consentPrompt: {purposes: [detailPurpose]}}, onSubmit}, + ); + const wrapper: VueWrapper = mount(defineComponent({render: (): VNode => h('div', elements)})); + (wrapper.find('button').element as HTMLButtonElement).click(); + + expect(onSubmit).toHaveBeenCalledTimes(1); + return onSubmit.mock.calls[0][1] as Record; + }; + + it('carries the modified values of an approved authorization detail', () => { + const submitted: Record = submit('PRIMARY', { + [`consent_${PURPOSE_ID}_d-1`]: 'true', + [MODIFIED_KEY]: '20', + }); + const decisions: ConsentDecisions = JSON.parse(submitted['consent_decisions']) as ConsentDecisions; + + expect(decisions.purposes[0].elements).toEqual([ + {approved: true, modified: {'instructedAmount.amount': 20}, name: 'd-1'}, + ]); + }); + + it('leaves an authorization detail unapproved, without modified values, when the form holds no approval', () => { + const submitted: Record = submit('PRIMARY', {[MODIFIED_KEY]: '20'}); + const decisions: ConsentDecisions = JSON.parse(submitted['consent_decisions']) as ConsentDecisions; + + expect(decisions.purposes[0].elements).toEqual([{approved: false, name: 'd-1'}]); + }); +}); + +describe('SignIn consent decisions', () => { + const consentStep: Record = { + data: { + additionalData: {consentPrompt: JSON.stringify([attributePurpose, detailPurpose])}, + meta: { + components: [ + {eventType: 'SUBMIT', id: 'allow', label: 'Allow', type: 'ACTION', variant: 'PRIMARY'}, + {eventType: 'SUBMIT', id: 'deny', label: 'Deny', type: 'ACTION', variant: 'SECONDARY'}, + ], + }, + }, + executionId: 'exec-1', + flowStatus: 'INCOMPLETE', + type: 'VIEW', + }; + + const submit = async (action: string, inputs: Record): Promise> => { + const signIn: Mock = vi.fn().mockResolvedValue(consentStep); + const isInitialized = ref(false); + let onSubmit: ((payload: Record) => Promise) | undefined; + + mount(SignIn, { + global: { + provide: { + [THUNDERID_KEY as symbol]: { + applicationId: 'app1', + getStorageManager: () => null, + isInitialized, + isLoading: ref(false), + signIn, + vendor: 'thunderid', + }, + [FLOW_META_KEY as symbol]: {meta: ref(null)}, + [I18N_KEY as symbol]: {currentLanguage: ref('en-US'), t: (key: string): string => key}, + }, + }, + slots: { + default: (props: {onSubmit: (payload: Record) => Promise}): VNode[] => { + onSubmit = props.onSubmit; + return []; + }, + }, + }); + isInitialized.value = true; + await flushPromises(); + await onSubmit!({action, inputs}); + await flushPromises(); + + expect(signIn).toHaveBeenCalledTimes(2); + return (signIn.mock.calls[1][0] as {inputs: Record}).inputs; + }; + + it('sends the values the user modified on an approved authorization detail, and no consent form keys', async () => { + const inputs: Record = await submit('allow', { + [APPROVED_KEY]: 'true', + [INVALID_KEY]: '', + [MODIFIED_KEY]: '20', + __consent_opt__p1__phone: 'true', + }); + const decisions: ConsentDecisions = JSON.parse(inputs['consent_decisions']) as ConsentDecisions; + + expect(decisions.purposes[0].elements).toEqual([ + {approved: true, name: 'email'}, + {approved: true, name: 'phone'}, + ]); + expect(decisions.purposes[1].elements).toEqual([ + {approved: true, modified: {'instructedAmount.amount': 20}, name: 'd-1'}, + ]); + expect(Object.keys(inputs)).toEqual(['consent_decisions']); + }); + + it('does not approve an authorization detail the form holds no approval for', async () => { + const inputs: Record = await submit('allow', {[MODIFIED_KEY]: '20'}); + const decisions: ConsentDecisions = JSON.parse(inputs['consent_decisions']) as ConsentDecisions; + + expect(decisions.purposes[1].elements).toEqual([{approved: false, name: 'd-1'}]); + }); + + it('denies every element and sends no modified values on the deny action', async () => { + const inputs: Record = await submit('deny', {[APPROVED_KEY]: 'true', [MODIFIED_KEY]: '20'}); + const decisions: ConsentDecisions = JSON.parse(inputs['consent_decisions']) as ConsentDecisions; + + expect(decisions.approved).toBe(false); + expect(decisions.reason).toBe('user_denied'); + expect(decisions.purposes[1].elements).toEqual([{approved: false, name: 'd-1'}]); + expect(Object.keys(inputs)).toEqual(['consent_decisions']); + }); +}); diff --git a/packages/vue/src/components/auth/sign-in/AuthOptionFactoryCore.ts b/packages/vue/src/components/auth/sign-in/AuthOptionFactoryCore.ts index c4befd7c..6d3b08f8 100644 --- a/packages/vue/src/components/auth/sign-in/AuthOptionFactoryCore.ts +++ b/packages/vue/src/components/auth/sign-in/AuthOptionFactoryCore.ts @@ -21,6 +21,7 @@ import { ConsentAttributeElement, KeyValuePair, parseKeyValuePairs, + getConsentElementDecision, } from '@thunderid/browser'; import type {FetchAuthFlows, FetchUsers, FetchUserTypes} from '@thunderid/browser'; import DOMPurify from 'dompurify'; @@ -299,10 +300,13 @@ const createAuthComponentFromFlow = ( // Permission purposes carry no essential elements, so the server sends null here ...(p.essential ?? []).map((e): ConsentAttributeElement => ({approved: !isDeny, name: e.name})), ...(p.optional ?? []).map( - (e): ConsentAttributeElement => ({ - approved: !isDeny && formValues[getConsentOptionalKey(p.purposeId, e.name)] === 'true', - name: e.name, - }), + (e): ConsentAttributeElement => + getConsentElementDecision( + p.purposeId, + e, + !isDeny && formValues[getConsentOptionalKey(p.purposeId, e.name)] === 'true', + formValues, + ), ), ], purposeName: p.purposeName ?? '', diff --git a/packages/vue/src/components/auth/sign-in/SignIn.ts b/packages/vue/src/components/auth/sign-in/SignIn.ts index 092a1533..ff16285b 100644 --- a/packages/vue/src/components/auth/sign-in/SignIn.ts +++ b/packages/vue/src/components/auth/sign-in/SignIn.ts @@ -2,7 +2,10 @@ // SPDX-License-Identifier: Apache-2.0 import { + CONSENT_INVALID_KEY_PREFIX, + CONSENT_MODIFIED_KEY_PREFIX, ConsentConstants, + getConsentElementDecision, ThunderIDRuntimeError, type ConsentPurposeData, EmbeddedFlowComponent, @@ -344,7 +347,12 @@ const SignIn: Component = defineComponent({ ...(p.essential ?? []).map((e) => ({approved: !isDeny, name: e.name})), ...(p.optional ?? []).map((e) => { const key = `__consent_opt__${p.purposeId}__${e.name}`; - return {approved: !isDeny && processedInputs[key] === 'true', name: e.name}; + return getConsentElementDecision( + p.purposeId, + e, + !isDeny && processedInputs[key] === 'true', + processedInputs, + ); }), ], purposeName: p.purposeName, @@ -353,7 +361,13 @@ const SignIn: Component = defineComponent({ processedInputs['consent_decisions'] = JSON.stringify(decisions); Object.keys(processedInputs).forEach((key: string) => { - if (key.startsWith('__consent_opt__')) delete processedInputs[key]; + if ( + key.startsWith('__consent_opt__') || + key.startsWith(CONSENT_MODIFIED_KEY_PREFIX) || + key.startsWith(CONSENT_INVALID_KEY_PREFIX) + ) { + delete processedInputs[key]; + } }); } catch { // Ignore consent construction failures