From d1922ddeb38106eae7bafbb5ee991841cb856672 Mon Sep 17 00:00:00 2001 From: janithjay Date: Fri, 11 Sep 2026 12:22:27 +0530 Subject: [PATCH] Implement ChangeCredential component across JavaScript React and Vue SDKs Signed-off-by: janithjay --- packages/browser/src/index.ts | 1 + .../__tests__/createHttpClientFetcher.test.ts | 65 +++ .../src/utils/createHttpClientFetcher.ts | 60 +++ .../api/__tests__/updateMeCredentials.test.ts | 180 ++++++++ .../javascript/src/api/updateMeCredentials.ts | 134 ++++++ .../src/constants/CredentialConstants.ts | 31 ++ packages/javascript/src/i18n/models/i18n.ts | 18 + .../javascript/src/i18n/translations/en-US.ts | 18 + .../javascript/src/i18n/translations/fr-FR.ts | 19 + .../javascript/src/i18n/translations/hi-IN.ts | 18 + .../javascript/src/i18n/translations/ja-JP.ts | 18 + .../javascript/src/i18n/translations/pt-BR.ts | 18 + .../javascript/src/i18n/translations/pt-PT.ts | 18 + .../javascript/src/i18n/translations/si-LK.ts | 19 + .../javascript/src/i18n/translations/ta-IN.ts | 19 + .../javascript/src/i18n/translations/te-IN.ts | 19 + packages/javascript/src/index.ts | 11 + packages/javascript/src/models/config.ts | 8 +- ...enticationHelper.resourceEndpoints.test.ts | 5 + .../evaluateChangePasswordForm.test.ts | 50 ++ .../__tests__/evaluatePasswordPolicy.test.ts | 35 ++ .../mapCredentialUpdateError.test.ts | 62 +++ .../resolveChangeCredentialPolicy.test.ts | 41 ++ .../__tests__/resolveResourceEndpoint.test.ts | 8 +- .../__tests__/supportsCredential.test.ts | 49 ++ .../src/utils/evaluateChangePasswordForm.ts | 83 ++++ .../src/utils/evaluatePasswordPolicy.ts | 86 ++++ .../src/utils/mapCredentialUpdateError.ts | 74 +++ .../utils/resolveChangeCredentialPolicy.ts | 40 ++ .../src/utils/resolveResourceEndpoint.ts | 3 +- .../src/utils/supportsCredential.ts | 45 ++ packages/react/src/api/updateMeCredentials.ts | 51 ++ .../BaseChangeCredential.styles.ts | 125 +++++ .../ChangeCredential/BaseChangeCredential.tsx | 354 ++++++++++++++ .../ChangeCredential/ChangeCredential.tsx | 161 +++++++ .../__tests__/ChangeCredential.test.tsx | 350 ++++++++++++++ .../UserDropdown/BaseUserDropdown.tsx | 8 +- .../UserDropdown/UserDropdown.tsx | 25 +- .../UserProfile/BaseUserProfile.styles.ts | 2 + .../PasswordField/PasswordField.tsx | 9 +- packages/react/src/index.ts | 8 + .../api/update-me-credentials.test.ts | 70 +++ .../components/change-credential.test.ts | 302 ++++++++++++ packages/vue/src/api/updateMeCredentials.ts | 26 ++ .../change-credential/BaseChangeCredential.ts | 314 +++++++++++++ .../change-credential/ChangeCredential.css.ts | 107 +++++ .../change-credential/ChangeCredential.ts | 173 +++++++ .../user-dropdown/BaseUserDropdown.ts | 10 +- .../user-dropdown/UserDropdown.css.ts | 12 + .../user-dropdown/UserDropdown.ts | 42 +- .../user-profile/UserProfile.css.ts | 6 +- .../primitives/PasswordField/PasswordField.ts | 8 + packages/vue/src/index.ts | 3 + packages/vue/src/styles/injectStyles.ts | 8 +- .../browser/quickstart/src/components/nav.js | 6 +- .../{profileDialog.js => profileFields.js} | 76 ++- samples/browser/quickstart/src/main.js | 45 +- .../browser/quickstart/src/pages/account.js | 307 +++++++++++++ samples/browser/quickstart/src/style.css | 434 +++++++++++++++--- samples/react/quickstart/src/App.css | 241 ++++++++++ samples/react/quickstart/src/App.jsx | 2 + .../react/quickstart/src/components/Nav.jsx | 14 +- .../quickstart/src/pages/AccountPage.jsx | 187 ++++++++ samples/vue/quickstart/src/App.vue | 2 + samples/vue/quickstart/src/components/Nav.vue | 12 +- .../vue/quickstart/src/pages/AccountPage.vue | 146 ++++++ samples/vue/quickstart/src/style.css | 241 ++++++++++ tests/e2e/constants/credential-test-users.ts | 42 ++ tests/e2e/fixtures/sample-apps/index.ts | 12 +- tests/e2e/global-setup.ts | 23 +- tests/e2e/global-teardown.ts | 31 +- tests/e2e/pages/browser-quickstart.page.ts | 64 ++- tests/e2e/pages/thunderid-web-sample.page.ts | 107 ++++- .../change-credential.spec.ts | 92 ++++ .../change-credential.spec.ts | 92 ++++ .../vue-quickstart/change-credential.spec.ts | 92 ++++ 76 files changed, 5500 insertions(+), 197 deletions(-) create mode 100644 packages/browser/src/utils/__tests__/createHttpClientFetcher.test.ts create mode 100644 packages/browser/src/utils/createHttpClientFetcher.ts create mode 100644 packages/javascript/src/api/__tests__/updateMeCredentials.test.ts create mode 100644 packages/javascript/src/api/updateMeCredentials.ts create mode 100644 packages/javascript/src/constants/CredentialConstants.ts create mode 100644 packages/javascript/src/utils/__tests__/evaluateChangePasswordForm.test.ts create mode 100644 packages/javascript/src/utils/__tests__/evaluatePasswordPolicy.test.ts create mode 100644 packages/javascript/src/utils/__tests__/mapCredentialUpdateError.test.ts create mode 100644 packages/javascript/src/utils/__tests__/resolveChangeCredentialPolicy.test.ts create mode 100644 packages/javascript/src/utils/__tests__/supportsCredential.test.ts create mode 100644 packages/javascript/src/utils/evaluateChangePasswordForm.ts create mode 100644 packages/javascript/src/utils/evaluatePasswordPolicy.ts create mode 100644 packages/javascript/src/utils/mapCredentialUpdateError.ts create mode 100644 packages/javascript/src/utils/resolveChangeCredentialPolicy.ts create mode 100644 packages/javascript/src/utils/supportsCredential.ts create mode 100644 packages/react/src/api/updateMeCredentials.ts create mode 100644 packages/react/src/components/presentation/ChangeCredential/BaseChangeCredential.styles.ts create mode 100644 packages/react/src/components/presentation/ChangeCredential/BaseChangeCredential.tsx create mode 100644 packages/react/src/components/presentation/ChangeCredential/ChangeCredential.tsx create mode 100644 packages/react/src/components/presentation/ChangeCredential/__tests__/ChangeCredential.test.tsx create mode 100644 packages/vue/src/__tests__/api/update-me-credentials.test.ts create mode 100644 packages/vue/src/__tests__/components/change-credential.test.ts create mode 100644 packages/vue/src/api/updateMeCredentials.ts create mode 100644 packages/vue/src/components/presentation/change-credential/BaseChangeCredential.ts create mode 100644 packages/vue/src/components/presentation/change-credential/ChangeCredential.css.ts create mode 100644 packages/vue/src/components/presentation/change-credential/ChangeCredential.ts rename samples/browser/quickstart/src/components/{profileDialog.js => profileFields.js} (80%) create mode 100644 samples/browser/quickstart/src/pages/account.js create mode 100644 samples/react/quickstart/src/pages/AccountPage.jsx create mode 100644 samples/vue/quickstart/src/pages/AccountPage.vue create mode 100644 tests/e2e/constants/credential-test-users.ts create mode 100644 tests/e2e/tests/browser-quickstart/change-credential.spec.ts create mode 100644 tests/e2e/tests/react-quickstart/change-credential.spec.ts create mode 100644 tests/e2e/tests/vue-quickstart/change-credential.spec.ts diff --git a/packages/browser/src/index.ts b/packages/browser/src/index.ts index 38c98efe..4c00bc36 100644 --- a/packages/browser/src/index.ts +++ b/packages/browser/src/index.ts @@ -37,6 +37,7 @@ export {default as hasAuthParamsInUrl} from './utils/hasAuthParamsInUrl'; export {default as hasCalledForThisInstanceInUrl} from './utils/hasCalledForThisInstanceInUrl'; export {default as navigate} from './utils/navigate'; export {default as http} from './utils/http'; +export {default as createHttpClientFetcher} from './utils/createHttpClientFetcher'; export {default as handleWebAuthnAuthentication} from './utils/handleWebAuthnAuthentication'; export {default as resolveEmojiUrisInHtml} from './utils/resolveEmojiUrisInHtml'; export {default as isAvatarUri, AVATAR_URI_SCHEME} from './utils/isAvatarUri'; diff --git a/packages/browser/src/utils/__tests__/createHttpClientFetcher.test.ts b/packages/browser/src/utils/__tests__/createHttpClientFetcher.test.ts new file mode 100644 index 00000000..c9661a8d --- /dev/null +++ b/packages/browser/src/utils/__tests__/createHttpClientFetcher.test.ts @@ -0,0 +1,65 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import FetchHttpClient from '../../FetchHttpClient'; +import createHttpClientFetcher from '../createHttpClientFetcher'; + +const mockRequest = vi.fn(); +let getInstanceSpy: ReturnType; + +describe('createHttpClientFetcher', () => { + beforeEach(() => { + getInstanceSpy = vi + .spyOn(FetchHttpClient, 'getInstance') + .mockReturnValue({request: mockRequest} as unknown as FetchHttpClient); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.clearAllMocks(); + }); + + it('resolves an ok Response on a successful request', async () => { + mockRequest.mockResolvedValueOnce({data: {ok: true}, status: 200, statusText: 'OK'}); + + const response = await createHttpClientFetcher()('https://localhost:8090/x', {method: 'POST'}); + + expect(response.ok).toBe(true); + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ok: true}); + }); + + it('reconstructs a non-ok Response from a rejected request that carries a real HTTP response', async () => { + // FetchHttpClient throws on a non-2xx response rather than resolving it; this must convert + // that back into a resolved, non-ok Response so the caller sees the real status and body + // instead of treating it as a network failure. + mockRequest.mockRejectedValueOnce( + Object.assign(new Error('Bad Request'), { + response: {data: {code: 'USR-1017'}, status: 400, statusText: 'Bad Request'}, + }), + ); + + const response = await createHttpClientFetcher()('https://localhost:8090/x', {method: 'POST'}); + + expect(response.ok).toBe(false); + expect(response.status).toBe(400); + await expect(response.json()).resolves.toEqual({code: 'USR-1017'}); + }); + + it('rethrows a genuine network error with no attached response', async () => { + mockRequest.mockRejectedValueOnce(Object.assign(new Error('Failed to fetch'), {code: 'NETWORK_ERROR'})); + + await expect(createHttpClientFetcher()('https://localhost:8090/x', {method: 'POST'})).rejects.toMatchObject({ + code: 'NETWORK_ERROR', + }); + }); + + it('uses the FetchHttpClient instance for the given instanceId', async () => { + mockRequest.mockResolvedValueOnce({data: undefined, status: 204, statusText: 'No Content'}); + + await createHttpClientFetcher(3)('https://localhost:8090/x', {method: 'POST'}); + + expect(getInstanceSpy).toHaveBeenCalledWith(3); + }); +}); diff --git a/packages/browser/src/utils/createHttpClientFetcher.ts b/packages/browser/src/utils/createHttpClientFetcher.ts new file mode 100644 index 00000000..d5901603 --- /dev/null +++ b/packages/browser/src/utils/createHttpClientFetcher.ts @@ -0,0 +1,60 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {HttpError, HttpRequestConfig, HttpResponse} from '@thunderid/javascript'; +import FetchHttpClient from '../FetchHttpClient'; + +/** + * Builds a `fetch`-shaped function backed by the shared `FetchHttpClient` singleton, for + * framework wrappers (`@thunderid/react`, `@thunderid/vue`) that need to hand a core + * `@thunderid/javascript` API function a `fetcher` carrying the access token automatically. + * + * Unlike a plain `fetch` call, `FetchHttpClient.request` throws on a non-2xx response instead + * of resolving it. This adapter catches that and reconstructs a `Response`-shaped object from + * the thrown error's `response`, so the core API function sees the real status and body instead + * of treating every non-2xx result as a network failure. A genuine network error (no response + * attached) still propagates. + * + * @param instanceId - Which `FetchHttpClient` instance to use, for multi-instance apps. Defaults to `0`. + * @returns A function matching the `(url, config) => Promise` shape core API functions expect. + * @example + * ```typescript + * await updateMeCredentials({ + * ...config, + * fetcher: fetcher ?? createHttpClientFetcher(instanceId), + * }); + * ``` + */ +const createHttpClientFetcher = (instanceId = 0): ((url: string, config: RequestInit) => Promise) => { + return async (url: string, config: RequestInit): Promise => { + const httpClient: FetchHttpClient = FetchHttpClient.getInstance(instanceId); + + const toResponse = (data: unknown, status: number, statusText: string): Response => + ({ + json: () => Promise.resolve(data), + ok: status >= 200 && status < 300, + status, + statusText, + text: () => Promise.resolve(typeof data === 'string' ? data : JSON.stringify(data)), + }) as Response; + + try { + const response: HttpResponse = await httpClient.request({ + data: config.body ? JSON.parse(config.body as string) : undefined, + headers: config.headers as Record, + method: config.method || 'POST', + url, + } as HttpRequestConfig); + + return toResponse(response.data, response.status, response.statusText || ''); + } catch (error) { + const httpError: HttpError = error as HttpError; + if (httpError?.response) { + return toResponse(httpError.response.data, httpError.response.status, httpError.response.statusText ?? ''); + } + throw error; + } + }; +}; + +export default createHttpClientFetcher; diff --git a/packages/javascript/src/api/__tests__/updateMeCredentials.test.ts b/packages/javascript/src/api/__tests__/updateMeCredentials.test.ts new file mode 100644 index 00000000..c62db758 --- /dev/null +++ b/packages/javascript/src/api/__tests__/updateMeCredentials.test.ts @@ -0,0 +1,180 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {Mock, beforeEach, describe, expect, it, vi} from 'vitest'; +import ThunderIDAPIError from '../../errors/ThunderIDAPIError'; +import updateMeCredentials from '../updateMeCredentials'; + +describe('updateMeCredentials', (): void => { + beforeEach((): void => { + vi.resetAllMocks(); + }); + + it('should wrap the payload under attributes and resolve with no value on 204', async (): Promise => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + status: 204, + text: () => Promise.resolve(''), + }); + + const url = 'https://localhost:8090/users/me/update-credentials'; + + const result: void = await updateMeCredentials({ + payload: {password: 'n3wP@ssword!'}, + url, + }); + + expect(result).toBeUndefined(); + expect(fetch).toHaveBeenCalledTimes(1); + + const [calledUrl, init] = (fetch as unknown as Mock).mock.calls[0] as [string, RequestInit]; + + expect(calledUrl).toBe(url); + expect(init.method).toBe('POST'); + expect((init.headers as Record)['Content-Type']).toBe('application/json'); + expect((init.headers as Record)['Accept']).toBe('application/json'); + + const parsed = JSON.parse(init.body as string) as Record; + expect(parsed).toEqual({attributes: {password: 'n3wP@ssword!'}}); + }); + + it('should support updating more than one credential in a single call', async (): Promise => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + status: 204, + text: () => Promise.resolve(''), + }); + + await updateMeCredentials({ + payload: { + password: 'n3wP@ss!', + pin: '1234', + }, + url: 'https://localhost:8090/users/me/update-credentials', + }); + + const [, init] = (fetch as unknown as Mock).mock.calls[0] as [string, RequestInit]; + const parsed = JSON.parse(init.body as string) as Record; + + expect(parsed).toEqual({ + attributes: { + password: 'n3wP@ss!', + pin: '1234', + }, + }); + }); + + it('should never read the response body on success', async (): Promise => { + const json: Mock = vi.fn(); + + global.fetch = vi.fn().mockResolvedValue({ + json, + ok: true, + status: 204, + text: () => Promise.resolve(''), + }); + + await updateMeCredentials({ + payload: {password: 'n3wP@ssword!'}, + url: 'https://localhost:8090/users/me/update-credentials', + }); + + expect(json).not.toHaveBeenCalled(); + }); + + it('should fall back to baseUrl when url is not provided', async (): Promise => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + status: 204, + text: () => Promise.resolve(''), + }); + + await updateMeCredentials({baseUrl: 'https://localhost:8090', payload: {password: 'n3wP@ssword!'}}); + + const [calledUrl] = (fetch as unknown as Mock).mock.calls[0] as [string, RequestInit]; + + expect(calledUrl).toBe('https://localhost:8090/users/me/update-credentials'); + }); + + it('should strip a trailing slash from baseUrl', async (): Promise => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + status: 204, + text: () => Promise.resolve(''), + }); + + await updateMeCredentials({baseUrl: 'https://localhost:8090/', payload: {password: 'n3wP@ssword!'}}); + + const [calledUrl] = (fetch as unknown as Mock).mock.calls[0] as [string, RequestInit]; + + expect(calledUrl).toBe('https://localhost:8090/users/me/update-credentials'); + }); + + it('should use a custom fetcher when provided', async (): Promise => { + const fetcher: Mock = vi.fn().mockResolvedValue({ + ok: true, + status: 204, + text: () => Promise.resolve(''), + }); + + global.fetch = vi.fn(); + + await updateMeCredentials({ + fetcher, + payload: {password: 'n3wP@ssword!'}, + url: 'https://localhost:8090/users/me/update-credentials', + }); + + expect(fetcher).toHaveBeenCalledTimes(1); + expect(fetch).not.toHaveBeenCalled(); + }); + + it('should throw a validation error for a malformed URL', async (): Promise => { + global.fetch = vi.fn(); + + await expect(updateMeCredentials({payload: {password: 'x'}, url: 'not-a-url'})).rejects.toThrow(ThunderIDAPIError); + expect(fetch).not.toHaveBeenCalled(); + + await expect(updateMeCredentials({payload: {password: 'x'}, url: 'not-a-url'})).rejects.toMatchObject({ + code: 'updateMeCredentials-ValidationError-001', + }); + }); + + it('should throw a response error carrying the server status', async (): Promise => { + global.fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 400, + statusText: 'Bad Request', + text: () => + Promise.resolve( + JSON.stringify({ + code: 'USR-1017', + message: {defaultValue: 'Missing credentials', key: 'error.userservice.missing_credentials'}, + }), + ), + }); + + await expect( + updateMeCredentials({ + payload: {password: 'n3wP@ssword!'}, + url: 'https://localhost:8090/users/me/update-credentials', + }), + ).rejects.toMatchObject({ + code: 'updateMeCredentials-ResponseError-001', + statusCode: 400, + }); + }); + + it('should throw a network error when the request itself fails', async (): Promise => { + global.fetch = vi.fn().mockRejectedValue(new Error('connection refused')); + + await expect( + updateMeCredentials({ + payload: {password: 'n3wP@ssword!'}, + url: 'https://localhost:8090/users/me/update-credentials', + }), + ).rejects.toMatchObject({ + code: 'updateMeCredentials-NetworkError-001', + }); + }); +}); diff --git a/packages/javascript/src/api/updateMeCredentials.ts b/packages/javascript/src/api/updateMeCredentials.ts new file mode 100644 index 00000000..1c3ecfd0 --- /dev/null +++ b/packages/javascript/src/api/updateMeCredentials.ts @@ -0,0 +1,134 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import ThunderIDAPIError from '../errors/ThunderIDAPIError'; + +/** + * Configuration for the updateMeCredentials request + */ +export interface UpdateMeCredentialsConfig extends Omit { + /** + * The base path of the API endpoint. + */ + baseUrl?: string; + /** + * Optional custom fetcher function. + * If not provided, native fetch will be used + */ + fetcher?: (url: string, config: RequestInit) => Promise; + /** + * The new values to set, keyed by credential name, whatever attribute the user's entity + * type schema declares `credential: true` (for example `password` or `pin`). One or more + * may be included in a single call. The server requires each value here to be a plain + * string, and the whole map to be wrapped under `attributes` on the wire, which this + * function does; do not pass a pre-wrapped `{attributes: ...}` object. + */ + payload: Record; + /** + * The absolute API endpoint. + */ + url?: string; +} + +/** + * Updates one or more of the signed-in user's credentials at the + * /users/me/update-credentials endpoint. + * + * The endpoint responds with `204 No Content` on success, so this function resolves with + * `void` rather than a parsed body. + * + * @param config - Configuration object with URL, payload and optional request config. + * @returns A promise that resolves once the credentials have been updated. + * @example + * ```typescript + * // Using default fetch + * await updateMeCredentials({ + * url: "https://localhost:8090/users/me/update-credentials", + * payload: {password: "n3wP@ssword!"} + * }); + * ``` + * + * @example + * ```typescript + * // Using custom fetcher (e.g. an httpClient that attaches the access token) + * await updateMeCredentials({ + * baseUrl: "https://localhost:8090", + * payload: {password: "n3wP@ssword!"}, + * fetcher: async (url, config) => { + * const response = await httpClient({url, method: config.method, headers: config.headers, data: config.body}); + * return { + * ok: response.status >= 200 && response.status < 300, + * status: response.status, + * statusText: response.statusText, + * json: () => Promise.resolve(response.data), + * text: () => Promise.resolve(typeof response.data === 'string' ? response.data : JSON.stringify(response.data)) + * } as Response; + * } + * }); + * ``` + */ +const updateMeCredentials = async ({ + url, + baseUrl, + payload, + fetcher, + ...requestConfig +}: UpdateMeCredentialsConfig): Promise => { + try { + // eslint-disable-next-line no-new + new URL((url ?? baseUrl)!); + } catch (error) { + throw new ThunderIDAPIError( + `Invalid URL provided. ${error instanceof Error ? error.message : String(error)}`, + 'updateMeCredentials-ValidationError-001', + 'javascript', + 400, + 'The provided `url` or `baseUrl` path does not adhere to the URL schema.', + ); + } + + const fetchFn: typeof fetch = fetcher ?? fetch; + const resolvedUrl: string = url ?? `${baseUrl?.replace(/\/$/, '')}/users/me/update-credentials`; + + const requestInit: RequestInit = { + ...requestConfig, + method: 'POST', + body: JSON.stringify({attributes: payload}), + headers: { + ...requestConfig.headers, + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + }; + + try { + const response: Response = await fetchFn(resolvedUrl, requestInit); + + if (!response?.ok) { + const errorText: string = await response.text(); + + throw new ThunderIDAPIError( + errorText, + 'updateMeCredentials-ResponseError-001', + 'javascript', + response.status, + response.statusText, + 'Failed to update user credentials', + ); + } + } catch (error) { + if (error instanceof ThunderIDAPIError) { + throw error; + } + + throw new ThunderIDAPIError( + `Network or parsing error: ${error instanceof Error ? error.message : 'Unknown error'}`, + 'updateMeCredentials-NetworkError-001', + 'javascript', + 0, + 'Network Error', + ); + } +}; + +export default updateMeCredentials; diff --git a/packages/javascript/src/constants/CredentialConstants.ts b/packages/javascript/src/constants/CredentialConstants.ts new file mode 100644 index 00000000..3aa75854 --- /dev/null +++ b/packages/javascript/src/constants/CredentialConstants.ts @@ -0,0 +1,31 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +/** + * Constants for the credential types the server accepts on the self-service + * credential write path. + * + * The server keys credentials by type. `password` is the one every user type schema + * declares by default, hence a named constant for it; any other schema-declared + * `credential: true` attribute (for example `pin`) can be self-managed too, just by its + * own attribute name rather than a constant here. + * + * @example + * ```typescript + * await updateMeCredentials({ + * payload: {[CredentialConstants.PASSWORD]: newPassword}, + * url, + * }); + * ``` + */ +const CredentialConstants: { + PASSWORD: string; +} = { + /** + * The credential attribute written when changing a password. Also the key the user + * type schema stores the password `regex` under. + */ + PASSWORD: 'password', +} as const; + +export default CredentialConstants; diff --git a/packages/javascript/src/i18n/models/i18n.ts b/packages/javascript/src/i18n/models/i18n.ts index 3189084d..cae903df 100644 --- a/packages/javascript/src/i18n/models/i18n.ts +++ b/packages/javascript/src/i18n/models/i18n.ts @@ -103,6 +103,24 @@ export interface I18nTranslations { 'user.profile.heading': string; 'user.profile.update.generic.error': string; + /* |---------------------------------------------------------------| */ + /* | Change Password | */ + /* |---------------------------------------------------------------| */ + + 'user.change_password.heading': string; + 'user.change_password.new.label': string; + 'user.change_password.new.placeholder': string; + 'user.change_password.confirm.label': string; + 'user.change_password.confirm.placeholder': string; + 'user.change_password.requirements.heading': string; + 'user.change_password.submit': string; + 'user.change_password.success': string; + 'user.change_password.mismatch.error': string; + 'user.change_password.generic.error': string; + 'user.change_password.unavailable.heading': string; + 'user.change_password.unavailable.description': string; + 'validation.password.pattern': string; + /* |---------------------------------------------------------------| */ /* | Organization Switcher | */ /* |---------------------------------------------------------------| */ diff --git a/packages/javascript/src/i18n/translations/en-US.ts b/packages/javascript/src/i18n/translations/en-US.ts index 67507ba7..54328b31 100644 --- a/packages/javascript/src/i18n/translations/en-US.ts +++ b/packages/javascript/src/i18n/translations/en-US.ts @@ -103,6 +103,24 @@ const translations: I18nTranslations = { 'user.profile.heading': 'Profile', 'user.profile.update.generic.error': 'An error occurred while updating your profile. Please try again.', + /* |---------------------------------------------------------------| */ + /* | Change Password | */ + /* |---------------------------------------------------------------| */ + + 'user.change_password.heading': 'Change {credential}', + 'user.change_password.new.label': 'New {credential}', + 'user.change_password.new.placeholder': 'Enter your new {credentialLower}', + 'user.change_password.confirm.label': 'Confirm New {credential}', + 'user.change_password.confirm.placeholder': 'Re-enter your new {credentialLower}', + 'user.change_password.requirements.heading': 'Your {credentialLower} must have:', + 'user.change_password.submit': 'Update {credential}', + 'user.change_password.success': 'Your {credentialLower} has been updated.', + 'user.change_password.mismatch.error': '{credential}s do not match.', + 'user.change_password.generic.error': 'An error occurred while updating your {credentialLower}. Please try again.', + 'user.change_password.unavailable.heading': '{credential} changes unavailable', + 'user.change_password.unavailable.description': 'Please contact your administrator.', + 'validation.password.pattern': 'Matches the required format', + /* |---------------------------------------------------------------| */ /* | Organization Switcher | */ /* |---------------------------------------------------------------| */ diff --git a/packages/javascript/src/i18n/translations/fr-FR.ts b/packages/javascript/src/i18n/translations/fr-FR.ts index ef58cbc9..5178f2f7 100644 --- a/packages/javascript/src/i18n/translations/fr-FR.ts +++ b/packages/javascript/src/i18n/translations/fr-FR.ts @@ -105,6 +105,25 @@ const translations: I18nTranslations = { 'user.profile.update.generic.error': 'Une erreur est survenue lors de la mise à jour de votre profil. Veuillez réessayer.', + /* |---------------------------------------------------------------| */ + /* | Change Password | */ + /* |---------------------------------------------------------------| */ + + 'user.change_password.heading': 'Modifier {credential}', + 'user.change_password.new.label': 'Nouveau {credential}', + 'user.change_password.new.placeholder': 'Entrez votre nouveau {credentialLower}', + 'user.change_password.confirm.label': 'Confirmer le nouveau {credential}', + 'user.change_password.confirm.placeholder': 'Saisissez à nouveau votre nouveau {credentialLower}', + 'user.change_password.requirements.heading': 'Votre {credentialLower} doit contenir :', + 'user.change_password.submit': 'Mettre à jour {credential}', + 'user.change_password.success': 'Votre {credentialLower} a été mis à jour.', + 'user.change_password.mismatch.error': 'Les {credentialLower}s ne correspondent pas.', + 'user.change_password.generic.error': + 'Une erreur est survenue lors de la mise à jour de votre {credentialLower}. Veuillez réessayer.', + 'user.change_password.unavailable.heading': 'Modification du {credentialLower} indisponible', + 'user.change_password.unavailable.description': 'Veuillez contacter votre administrateur.', + 'validation.password.pattern': 'Correspond au format requis', + /* |---------------------------------------------------------------| */ /* | Organization Switcher | */ /* |---------------------------------------------------------------| */ diff --git a/packages/javascript/src/i18n/translations/hi-IN.ts b/packages/javascript/src/i18n/translations/hi-IN.ts index 8272bb09..0afc7fec 100644 --- a/packages/javascript/src/i18n/translations/hi-IN.ts +++ b/packages/javascript/src/i18n/translations/hi-IN.ts @@ -103,6 +103,24 @@ const translations: I18nTranslations = { 'user.profile.heading': 'प्रोफ़ाइल', 'user.profile.update.generic.error': 'प्रोफ़ाइल अपडेट करते समय त्रुटि हुई। कृपया पुनः प्रयास करें।', + /* |---------------------------------------------------------------| */ + /* | Change Password | */ + /* |---------------------------------------------------------------| */ + + 'user.change_password.heading': '{credential} बदलें', + 'user.change_password.new.label': 'नया {credential}', + 'user.change_password.new.placeholder': 'अपना नया {credentialLower} दर्ज करें', + 'user.change_password.confirm.label': 'नए {credential} की पुष्टि करें', + 'user.change_password.confirm.placeholder': 'अपना नया {credentialLower} फिर से दर्ज करें', + 'user.change_password.requirements.heading': 'आपके {credentialLower} में यह होना चाहिए:', + 'user.change_password.submit': '{credential} अपडेट करें', + 'user.change_password.success': 'आपका {credentialLower} अपडेट कर दिया गया है।', + 'user.change_password.mismatch.error': '{credential} मेल नहीं खाते।', + 'user.change_password.generic.error': 'आपका {credentialLower} अपडेट करते समय त्रुटि हुई। कृपया पुनः प्रयास करें।', + 'user.change_password.unavailable.heading': '{credential} परिवर्तन उपलब्ध नहीं है', + 'user.change_password.unavailable.description': 'कृपया अपने व्यवस्थापक से संपर्क करें।', + 'validation.password.pattern': 'आवश्यक प्रारूप से मेल खाता है', + /* |---------------------------------------------------------------| */ /* | Organization Switcher | */ /* |---------------------------------------------------------------| */ diff --git a/packages/javascript/src/i18n/translations/ja-JP.ts b/packages/javascript/src/i18n/translations/ja-JP.ts index 8e82b0b3..b26ee3a1 100644 --- a/packages/javascript/src/i18n/translations/ja-JP.ts +++ b/packages/javascript/src/i18n/translations/ja-JP.ts @@ -103,6 +103,24 @@ const translations: I18nTranslations = { 'user.profile.heading': 'プロフィール', 'user.profile.update.generic.error': 'プロフィール更新中にエラーが発生しました。もう一度お試しください。', + /* |---------------------------------------------------------------| */ + /* | Change Password | */ + /* |---------------------------------------------------------------| */ + + 'user.change_password.heading': '{credential}の変更', + 'user.change_password.new.label': '新しい{credential}', + 'user.change_password.new.placeholder': '新しい{credentialLower}を入力してください', + 'user.change_password.confirm.label': '新しい{credential}の確認', + 'user.change_password.confirm.placeholder': '新しい{credentialLower}を再入力してください', + 'user.change_password.requirements.heading': '{credentialLower}の条件:', + 'user.change_password.submit': '{credential}を更新', + 'user.change_password.success': '{credentialLower}を更新しました。', + 'user.change_password.mismatch.error': '{credentialLower}が一致しません。', + 'user.change_password.generic.error': '{credentialLower}の更新中にエラーが発生しました。もう一度お試しください。', + 'user.change_password.unavailable.heading': '{credential}の変更はできません', + 'user.change_password.unavailable.description': '管理者にお問い合わせください。', + 'validation.password.pattern': '必要な形式に一致しています', + /* |---------------------------------------------------------------| */ /* | Organization Switcher | */ /* |---------------------------------------------------------------| */ diff --git a/packages/javascript/src/i18n/translations/pt-BR.ts b/packages/javascript/src/i18n/translations/pt-BR.ts index d401e0c9..de2c4a76 100644 --- a/packages/javascript/src/i18n/translations/pt-BR.ts +++ b/packages/javascript/src/i18n/translations/pt-BR.ts @@ -103,6 +103,24 @@ const translations: I18nTranslations = { 'user.profile.heading': 'Perfil', 'user.profile.update.generic.error': 'Ocorreu um erro ao atualizar seu perfil. Tente novamente.', + /* |---------------------------------------------------------------| */ + /* | Change Password | */ + /* |---------------------------------------------------------------| */ + + 'user.change_password.heading': 'Alterar {credential}', + 'user.change_password.new.label': 'Novo {credential}', + 'user.change_password.new.placeholder': 'Digite seu novo {credentialLower}', + 'user.change_password.confirm.label': 'Confirmar novo {credential}', + 'user.change_password.confirm.placeholder': 'Digite novamente seu novo {credentialLower}', + 'user.change_password.requirements.heading': 'Seu {credentialLower} deve conter:', + 'user.change_password.submit': 'Atualizar {credential}', + 'user.change_password.success': 'Seu {credentialLower} foi atualizado.', + 'user.change_password.mismatch.error': 'Os {credentialLower}s não coincidem.', + 'user.change_password.generic.error': 'Ocorreu um erro ao atualizar seu {credentialLower}. Tente novamente.', + 'user.change_password.unavailable.heading': 'Alteração de {credentialLower} indisponível', + 'user.change_password.unavailable.description': 'Entre em contato com o administrador.', + 'validation.password.pattern': 'Corresponde ao formato exigido', + /* |---------------------------------------------------------------| */ /* | Organization Switcher | */ /* |---------------------------------------------------------------| */ diff --git a/packages/javascript/src/i18n/translations/pt-PT.ts b/packages/javascript/src/i18n/translations/pt-PT.ts index f90cc480..0c1e495a 100644 --- a/packages/javascript/src/i18n/translations/pt-PT.ts +++ b/packages/javascript/src/i18n/translations/pt-PT.ts @@ -103,6 +103,24 @@ const translations: I18nTranslations = { 'user.profile.heading': 'Perfil', 'user.profile.update.generic.error': 'Ocorreu um erro ao actualizar o seu perfil. Tente novamente.', + /* |---------------------------------------------------------------| */ + /* | Change Password | */ + /* |---------------------------------------------------------------| */ + + 'user.change_password.heading': 'Alterar {credential}', + 'user.change_password.new.label': 'Novo {credential}', + 'user.change_password.new.placeholder': 'Introduza o seu novo {credentialLower}', + 'user.change_password.confirm.label': 'Confirmar novo {credential}', + 'user.change_password.confirm.placeholder': 'Introduza novamente o seu novo {credentialLower}', + 'user.change_password.requirements.heading': 'O seu {credentialLower} deve conter:', + 'user.change_password.submit': 'Atualizar {credential}', + 'user.change_password.success': 'O seu {credentialLower} foi atualizado.', + 'user.change_password.mismatch.error': 'Os {credentialLower}s não coincidem.', + 'user.change_password.generic.error': 'Ocorreu um erro ao actualizar o seu {credentialLower}. Tente novamente.', + 'user.change_password.unavailable.heading': 'Alteração de {credentialLower} indisponível', + 'user.change_password.unavailable.description': 'Contacte o seu administrador.', + 'validation.password.pattern': 'Corresponde ao formato exigido', + /* |---------------------------------------------------------------| */ /* | Organization Switcher | */ /* |---------------------------------------------------------------| */ diff --git a/packages/javascript/src/i18n/translations/si-LK.ts b/packages/javascript/src/i18n/translations/si-LK.ts index 4c848fb6..562d9023 100644 --- a/packages/javascript/src/i18n/translations/si-LK.ts +++ b/packages/javascript/src/i18n/translations/si-LK.ts @@ -103,6 +103,25 @@ const translations: I18nTranslations = { 'user.profile.heading': 'පැතිකඩ', 'user.profile.update.generic.error': 'ඔබේ පැතිකඩ යාවත්කාලීන කිරීමේදී දෝෂයක් ඇතිවිය.කරුණාකර නැවත උත්සාහ කරන්න', + /* |---------------------------------------------------------------| */ + /* | Change Password | */ + /* |---------------------------------------------------------------| */ + + 'user.change_password.heading': '{credential} වෙනස් කරන්න', + 'user.change_password.new.label': 'නව {credential}', + 'user.change_password.new.placeholder': 'ඔබේ නව {credentialLower} ඇතුළත් කරන්න', + 'user.change_password.confirm.label': 'නව {credential} තහවුරු කරන්න', + 'user.change_password.confirm.placeholder': 'ඔබේ නව {credentialLower} නැවත ඇතුළත් කරන්න', + 'user.change_password.requirements.heading': 'ඔබේ {credentialLower} තුළ තිබිය යුතුය:', + 'user.change_password.submit': '{credential} යාවත්කාලීන කරන්න', + 'user.change_password.success': 'ඔබේ {credentialLower} යාවත්කාලීන කර ඇත.', + 'user.change_password.mismatch.error': '{credentialLower} නොගැලපේ.', + 'user.change_password.generic.error': + 'ඔබේ {credentialLower} යාවත්කාලීන කිරීමේදී දෝෂයක් ඇතිවිය. කරුණාකර නැවත උත්සාහ කරන්න.', + 'user.change_password.unavailable.heading': '{credential} වෙනස් කිරීම් ලබා ගත නොහැක', + 'user.change_password.unavailable.description': 'කරුණාකර ඔබේ පරිපාලක සම්බන්ධ කර ගන්න.', + 'validation.password.pattern': 'අවශ්‍ය ආකෘතියට ගැලපේ', + /* |---------------------------------------------------------------| */ /* | Organization Switcher | */ /* |---------------------------------------------------------------| */ diff --git a/packages/javascript/src/i18n/translations/ta-IN.ts b/packages/javascript/src/i18n/translations/ta-IN.ts index d124f54f..758db99e 100644 --- a/packages/javascript/src/i18n/translations/ta-IN.ts +++ b/packages/javascript/src/i18n/translations/ta-IN.ts @@ -104,6 +104,25 @@ const translations: I18nTranslations = { 'user.profile.update.generic.error': 'உங்கள் சுயவிவரத்தை புதுப்பிக்கும் போது பிழை ஏற்பட்டது. மீண்டும் முயற்சிக்கவும்.', + /* |---------------------------------------------------------------| */ + /* | Change Password | */ + /* |---------------------------------------------------------------| */ + + 'user.change_password.heading': '{credential} மாற்றவும்', + 'user.change_password.new.label': 'புதிய {credential}', + 'user.change_password.new.placeholder': 'உங்கள் புதிய {credentialLower}-ஐ உள்ளிடவும்', + 'user.change_password.confirm.label': 'புதிய {credential}-ஐ உறுதிப்படுத்தவும்', + 'user.change_password.confirm.placeholder': 'உங்கள் புதிய {credentialLower}-ஐ மீண்டும் உள்ளிடவும்', + 'user.change_password.requirements.heading': 'உங்கள் {credentialLower} பின்வருவனவற்றைக் கொண்டிருக்க வேண்டும்:', + 'user.change_password.submit': '{credential}-ஐ புதுப்பிக்கவும்', + 'user.change_password.success': 'உங்கள் {credentialLower} புதுப்பிக்கப்பட்டது.', + 'user.change_password.mismatch.error': '{credentialLower} பொருந்தவில்லை.', + 'user.change_password.generic.error': + 'உங்கள் {credentialLower}-ஐ புதுப்பிக்கும் போது பிழை ஏற்பட்டது. மீண்டும் முயற்சிக்கவும்.', + 'user.change_password.unavailable.heading': '{credential} மாற்றங்கள் கிடைக்கவில்லை', + 'user.change_password.unavailable.description': 'உங்கள் நிர்வாகியை தொடர்பு கொள்ளவும்.', + 'validation.password.pattern': 'தேவையான வடிவமைப்பிற்கு பொருந்துகிறது', + /* |---------------------------------------------------------------| */ /* | Organization Switcher | */ /* |---------------------------------------------------------------| */ diff --git a/packages/javascript/src/i18n/translations/te-IN.ts b/packages/javascript/src/i18n/translations/te-IN.ts index 43de608a..c23be0ca 100644 --- a/packages/javascript/src/i18n/translations/te-IN.ts +++ b/packages/javascript/src/i18n/translations/te-IN.ts @@ -104,6 +104,25 @@ const translations: I18nTranslations = { 'user.profile.heading': 'ప్రొఫైల్', 'user.profile.update.generic.error': 'ప్రొఫైల్ అప్‌డేట్ చేస్తూ లోపం వచ్చింది. దయచేసి మళ్లీ ప్రయత్నించండి.', + /* |---------------------------------------------------------------| */ + /* | Change Password | */ + /* |---------------------------------------------------------------| */ + + 'user.change_password.heading': '{credential} మార్చండి', + 'user.change_password.new.label': 'కొత్త {credential}', + 'user.change_password.new.placeholder': 'మీ కొత్త {credentialLower} నమోదు చేయండి', + 'user.change_password.confirm.label': 'కొత్త {credential} నిర్ధారించండి', + 'user.change_password.confirm.placeholder': 'మీ కొత్త {credentialLower}ని మళ్లీ నమోదు చేయండి', + 'user.change_password.requirements.heading': 'మీ {credentialLower} కింది వాటిని కలిగి ఉండాలి:', + 'user.change_password.submit': '{credential} నవీకరించండి', + 'user.change_password.success': 'మీ {credentialLower} నవీకరించబడింది.', + 'user.change_password.mismatch.error': '{credentialLower} సరిపోలలేదు.', + 'user.change_password.generic.error': + 'మీ {credentialLower} నవీకరించేటప్పుడు లోపం వచ్చింది. దయచేసి మళ్లీ ప్రయత్నించండి.', + 'user.change_password.unavailable.heading': '{credential} మార్పులు అందుబాటులో లేవు', + 'user.change_password.unavailable.description': 'దయచేసి మీ నిర్వాహకుడిని సంప్రదించండి.', + 'validation.password.pattern': 'అవసరమైన ఫార్మాట్‌కు సరిపోతుంది', + /* |---------------------------------------------------------------| */ /* | Organization Switcher | */ /* |---------------------------------------------------------------| */ diff --git a/packages/javascript/src/index.ts b/packages/javascript/src/index.ts index ee8198f2..307eb2d2 100644 --- a/packages/javascript/src/index.ts +++ b/packages/javascript/src/index.ts @@ -20,6 +20,8 @@ export {default as getUsersMe} from './api/getUsersMe'; export type {GetUsersMeConfig} from './api/getUsersMe'; export {default as getUsersMeMeta} from './api/getUsersMeMeta'; export type {GetUsersMeMetaConfig, UsersMeMetaResponse, AttributeSchema} from './api/getUsersMeMeta'; +export {default as updateMeCredentials} from './api/updateMeCredentials'; +export type {UpdateMeCredentialsConfig} from './api/updateMeCredentials'; export {default as updateMeProfile} from './api/updateMeProfile'; export type {UpdateMeProfileConfig} from './api/updateMeProfile'; @@ -27,6 +29,7 @@ export {default as ApplicationNativeAuthenticationConstants} from './constants/A export {default as TokenConstants} from './constants/TokenConstants'; export {default as OIDCRequestConstants} from './constants/OIDCRequestConstants'; export {default as VendorConstants} from './constants/VendorConstants'; +export {default as CredentialConstants} from './constants/CredentialConstants'; export {default as ConsentConstants} from './constants/ConsentConstants'; export {default as ThunderIDError} from './errors/ThunderIDError'; @@ -177,6 +180,14 @@ export {default as removeTrailingSlash} from './utils/removeTrailingSlash'; export {default as resolveFieldName} from './utils/resolveFieldName'; export {default as resolveResourceEndpoint} from './utils/resolveResourceEndpoint'; export type {ResourceEndpointKey, ResourceEndpointConfig} from './utils/resolveResourceEndpoint'; +export {default as evaluatePasswordPolicy} from './utils/evaluatePasswordPolicy'; +export type {PasswordPolicy, PasswordRuleResult} from './utils/evaluatePasswordPolicy'; +export {default as evaluateChangePasswordForm} from './utils/evaluateChangePasswordForm'; +export type {ChangePasswordFormValues, ChangePasswordFormEvaluation} from './utils/evaluateChangePasswordForm'; +export {default as resolveChangeCredentialPolicy} from './utils/resolveChangeCredentialPolicy'; +export {default as supportsCredential} from './utils/supportsCredential'; +export {default as mapCredentialUpdateError} from './utils/mapCredentialUpdateError'; +export type {CredentialUpdateErrorField, CredentialUpdateErrorResult} from './utils/mapCredentialUpdateError'; export {default as resolveMeta} from './utils/resolveMeta'; export {default as resolveFlowTemplateLiterals} from './utils/resolveFlowTemplateLiterals'; export {default as countryCodeToFlagEmoji} from './utils/countryCodeToFlagEmoji'; diff --git a/packages/javascript/src/models/config.ts b/packages/javascript/src/models/config.ts index 594b039b..290d9ab1 100644 --- a/packages/javascript/src/models/config.ts +++ b/packages/javascript/src/models/config.ts @@ -210,7 +210,7 @@ export interface BaseConfig extends WithPreferences, WithExtensions * `endSession`, `wellKnown`) — by default derived from the well-known discovery document at * `{baseUrl}/oauth2/token/.well-known/openid-configuration`. Individual overrides take * precedence over values resolved from the discovery document. - * - **Resource-server endpoints** (`flowExecute`, `flowMeta`, `usersMe`) — + * - **Resource-server endpoints** (`flowExecute`, `flowMeta`, `usersMe`, `usersMeCredentials`) — * by default derived by concatenating `baseUrl` with a fixed path (e.g. `{baseUrl}/flow/execute`). * These do not participate in OIDC discovery. * @@ -283,6 +283,12 @@ export interface BaseConfig extends WithPreferences, WithExtensions * If not provided, defaults to `{baseUrl}/users/me`. */ usersMe?: string; + /** + * The self-service credential update endpoint URL used to change one of the signed-in + * user's own credentials (for example `password` or `pin`). + * If not provided, defaults to `{baseUrl}/users/me/update-credentials`. + */ + usersMeCredentials?: string; /** * The user profile schema metadata endpoint URL used to fetch profile schema attributes. * If not provided, defaults to `{baseUrl}/users/me/meta`. diff --git a/packages/javascript/src/utils/__tests__/AuthenticationHelper.resourceEndpoints.test.ts b/packages/javascript/src/utils/__tests__/AuthenticationHelper.resourceEndpoints.test.ts index a153f970..024b2fe8 100644 --- a/packages/javascript/src/utils/__tests__/AuthenticationHelper.resourceEndpoints.test.ts +++ b/packages/javascript/src/utils/__tests__/AuthenticationHelper.resourceEndpoints.test.ts @@ -24,10 +24,12 @@ const RESOURCE_KEY_FORMS: string[] = [ 'flowMeta', 'usersMe', 'usersMeMeta', + 'usersMeCredentials', 'flow_execute', 'flow_meta', 'users_me', 'users_me_meta', + 'users_me_credentials', ]; describe('AuthenticationHelper resource-endpoint filtering', (): void => { @@ -41,6 +43,7 @@ describe('AuthenticationHelper resource-endpoint filtering', (): void => { flowExecute: 'https://rs.example.com/flow/execute', flowMeta: 'https://rs.example.com/flow/meta', usersMe: 'https://rs.example.com/users/me', + usersMeCredentials: 'https://rs.example.com/users/me/update-credentials', usersMeMeta: 'https://rs.example.com/users/me/meta', }, }); @@ -64,6 +67,7 @@ describe('AuthenticationHelper resource-endpoint filtering', (): void => { flowExecute: 'https://rs.example.com/flow/execute', flowMeta: 'https://rs.example.com/flow/meta', usersMe: 'https://rs.example.com/users/me', + usersMeCredentials: 'https://rs.example.com/users/me/update-credentials', usersMeMeta: 'https://rs.example.com/users/me/meta', }, }); @@ -94,6 +98,7 @@ describe('AuthenticationHelper resource-endpoint filtering', (): void => { flowExecute: 'https://rs.example.com/flow/execute', flowMeta: 'https://rs.example.com/flow/meta', usersMe: 'https://rs.example.com/users/me', + usersMeCredentials: 'https://rs.example.com/users/me/update-credentials', usersMeMeta: 'https://rs.example.com/users/me/meta', }, }); diff --git a/packages/javascript/src/utils/__tests__/evaluateChangePasswordForm.test.ts b/packages/javascript/src/utils/__tests__/evaluateChangePasswordForm.test.ts new file mode 100644 index 00000000..5a61f111 --- /dev/null +++ b/packages/javascript/src/utils/__tests__/evaluateChangePasswordForm.test.ts @@ -0,0 +1,50 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {describe, expect, it} from 'vitest'; +import evaluateChangePasswordForm, {ChangePasswordFormValues} from '../evaluateChangePasswordForm'; + +const values = (overrides: Partial = {}): ChangePasswordFormValues => ({ + confirmPassword: 'N3wPassw0rd!', + newPassword: 'N3wPassw0rd!', + ...overrides, +}); + +describe('evaluateChangePasswordForm', (): void => { + it('should accept a complete, consistent form with no policy', (): void => { + const result = evaluateChangePasswordForm(values(), {}); + + expect(result.isValid).toBe(true); + expect(result.confirmMatches).toBe(true); + expect(result.meetsPolicy).toBe(true); + expect(result.ruleResults).toEqual([]); + }); + + it.each([['newPassword'], ['confirmPassword']])('should reject the form when %s is empty', (field: string): void => { + expect(evaluateChangePasswordForm(values({[field]: ''}), {}).isValid).toBe(false); + }); + + it('should reject a mismatched confirmation', (): void => { + const result = evaluateChangePasswordForm(values({confirmPassword: 'something-else'}), {}); + + expect(result.confirmMatches).toBe(false); + expect(result.isValid).toBe(false); + }); + + it('should reject a new password that fails the policy', (): void => { + const policy = {regex: '^.{12,}$'}; + const result = evaluateChangePasswordForm(values({confirmPassword: 'short', newPassword: 'short'}), policy); + + expect(result.meetsPolicy).toBe(false); + expect(result.isValid).toBe(false); + expect(result.ruleResults).toHaveLength(1); + }); + + it('should surface the rule results for the requirement checklist', (): void => { + const result = evaluateChangePasswordForm(values(), {regex: '^.{8,}$'}); + + expect(result.ruleResults).toHaveLength(1); + expect(result.ruleResults[0]?.passed).toBe(true); + expect(result.isValid).toBe(true); + }); +}); diff --git a/packages/javascript/src/utils/__tests__/evaluatePasswordPolicy.test.ts b/packages/javascript/src/utils/__tests__/evaluatePasswordPolicy.test.ts new file mode 100644 index 00000000..925cd18b --- /dev/null +++ b/packages/javascript/src/utils/__tests__/evaluatePasswordPolicy.test.ts @@ -0,0 +1,35 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {describe, expect, it} from 'vitest'; +import evaluatePasswordPolicy, {PasswordRuleResult} from '../evaluatePasswordPolicy'; + +const passedOf = (results: PasswordRuleResult[]): boolean | undefined => results[0]?.passed; + +describe('evaluatePasswordPolicy', (): void => { + it('should return an empty list for an empty policy', (): void => { + expect(evaluatePasswordPolicy('anything', {})).toEqual([]); + }); + + it('should ignore an empty regex', (): void => { + expect(evaluatePasswordPolicy('anything', {regex: ''})).toEqual([]); + }); + + it('should evaluate a schema-supplied regex', (): void => { + const policy = {regex: '^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d).{8,}$'}; + + expect(passedOf(evaluatePasswordPolicy('Passw0rdd', policy))).toBe(true); + expect(passedOf(evaluatePasswordPolicy('password', policy))).toBe(false); + }); + + it('should treat an uncompilable regex as passing so a bad schema cannot lock the user out', (): void => { + expect(passedOf(evaluatePasswordPolicy('anything', {regex: '([unclosed'}))).toBe(true); + }); + + it('should expose a stable key and the pattern i18n key', (): void => { + const [result]: PasswordRuleResult[] = evaluatePasswordPolicy('x', {regex: '.*'}); + + expect(result.key).toBe('regex'); + expect(result.messageKey).toBe('validation.password.pattern'); + }); +}); diff --git a/packages/javascript/src/utils/__tests__/mapCredentialUpdateError.test.ts b/packages/javascript/src/utils/__tests__/mapCredentialUpdateError.test.ts new file mode 100644 index 00000000..699e5f78 --- /dev/null +++ b/packages/javascript/src/utils/__tests__/mapCredentialUpdateError.test.ts @@ -0,0 +1,62 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {describe, expect, it} from 'vitest'; +import ThunderIDAPIError from '../../errors/ThunderIDAPIError'; +import ThunderIDError from '../../errors/ThunderIDError'; +import mapCredentialUpdateError from '../mapCredentialUpdateError'; + +const apiError = (statusCode: number, message = 'Server said no'): ThunderIDAPIError => + new ThunderIDAPIError(message, 'test-code', 'test-origin', statusCode); + +describe('mapCredentialUpdateError', (): void => { + it('should blame the new password on 400 and surface the server message', (): void => { + const result = mapCredentialUpdateError(apiError(400, 'Password is too common')); + + expect(result.field).toBe('newPassword'); + expect(result.message).toContain('Password is too common'); + }); + + it('should report a 403 at form level, since this form collects no current-value field', (): void => { + const result = mapCredentialUpdateError(apiError(403, 'Not allowed')); + + expect(result.field).toBeNull(); + expect(result.message).toContain('Not allowed'); + }); + + it('should report other API errors at form level with the server message', (): void => { + const result = mapCredentialUpdateError(apiError(500, 'Upstream exploded')); + + expect(result.field).toBeNull(); + expect(result.message).toContain('Upstream exploded'); + }); + + it('should report a non-API ThunderIDError at form level', (): void => { + const result = mapCredentialUpdateError(new ThunderIDError('Network down', 'test-code', 'test-origin')); + + expect(result.field).toBeNull(); + expect(result.message).toContain('Network down'); + }); + + it.each([ + [apiError(403)], + [apiError(400, 'too common')], + [apiError(500, 'boom')], + [new ThunderIDError('offline', 'test-code', 'test-origin')], + [new Error('plain')], + [undefined], + ])('should always supply a messageKey so callers need no non-null assertion (%s)', (thrown: unknown): void => { + expect(typeof mapCredentialUpdateError(thrown).messageKey).toBe('string'); + }); + + it.each([[new Error('plain')], ['a string'], [undefined], [null]])( + 'should fall back to the generic key for an unrecognized throw (%s)', + (thrown: unknown): void => { + const result = mapCredentialUpdateError(thrown); + + expect(result.field).toBeNull(); + expect(result.messageKey).toBe('user.change_password.generic.error'); + expect(result.message).toBeUndefined(); + }, + ); +}); diff --git a/packages/javascript/src/utils/__tests__/resolveChangeCredentialPolicy.test.ts b/packages/javascript/src/utils/__tests__/resolveChangeCredentialPolicy.test.ts new file mode 100644 index 00000000..3b372d5b --- /dev/null +++ b/packages/javascript/src/utils/__tests__/resolveChangeCredentialPolicy.test.ts @@ -0,0 +1,41 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {describe, expect, it} from 'vitest'; +import {AttributeSchema} from '../../api/getUsersMeMeta'; +import resolveChangeCredentialPolicy from '../resolveChangeCredentialPolicy'; + +const schemaWith = (name: string, regex?: string): Record => + ({[name]: {regex}}) as unknown as Record; + +describe('resolveChangeCredentialPolicy', (): void => { + it('should derive the policy from the named credential attribute regex', (): void => { + expect(resolveChangeCredentialPolicy(schemaWith('password', '^.{8,}$'), 'password')).toEqual({regex: '^.{8,}$'}); + }); + + it('should derive the policy for a non-password credential name', (): void => { + expect(resolveChangeCredentialPolicy(schemaWith('pin', '^\\d{4,6}$'), 'pin')).toEqual({regex: '^\\d{4,6}$'}); + }); + + it('should return an empty policy when the schema carries no regex', (): void => { + expect(resolveChangeCredentialPolicy(schemaWith('password', undefined), 'password')).toEqual({}); + }); + + it('should return an empty policy when the schema has no attribute of that name', (): void => { + expect(resolveChangeCredentialPolicy({} as Record, 'password')).toEqual({}); + }); + + it.each([[null], [undefined]])('should tolerate a %s schema', (schema: null | undefined): void => { + expect(resolveChangeCredentialPolicy(schema, 'password')).toEqual({}); + }); + + it('should let an explicit override win over the schema', (): void => { + expect(resolveChangeCredentialPolicy(schemaWith('password', '^.{8,}$'), 'password', {regex: '^.{12,}$'})).toEqual({ + regex: '^.{12,}$', + }); + }); + + it('should honour an override that deliberately configures no rules', (): void => { + expect(resolveChangeCredentialPolicy(schemaWith('password', '^.{8,}$'), 'password', {})).toEqual({}); + }); +}); diff --git a/packages/javascript/src/utils/__tests__/resolveResourceEndpoint.test.ts b/packages/javascript/src/utils/__tests__/resolveResourceEndpoint.test.ts index e8c9aa6a..34eb9775 100644 --- a/packages/javascript/src/utils/__tests__/resolveResourceEndpoint.test.ts +++ b/packages/javascript/src/utils/__tests__/resolveResourceEndpoint.test.ts @@ -48,6 +48,12 @@ describe('resolveResourceEndpoint', (): void => { }); it('exposes the resource endpoint keys for filtering OIDC metadata', (): void => { - expect([...RESOURCE_ENDPOINT_KEYS].sort()).toEqual(['flowExecute', 'flowMeta', 'usersMe', 'usersMeMeta']); + expect([...RESOURCE_ENDPOINT_KEYS].sort()).toEqual([ + 'flowExecute', + 'flowMeta', + 'usersMe', + 'usersMeCredentials', + 'usersMeMeta', + ]); }); }); diff --git a/packages/javascript/src/utils/__tests__/supportsCredential.test.ts b/packages/javascript/src/utils/__tests__/supportsCredential.test.ts new file mode 100644 index 00000000..8cfeebe9 --- /dev/null +++ b/packages/javascript/src/utils/__tests__/supportsCredential.test.ts @@ -0,0 +1,49 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {describe, expect, it} from 'vitest'; +import {AttributeSchema} from '../../api/getUsersMeMeta'; +import supportsCredential from '../supportsCredential'; + +describe('supportsCredential', (): void => { + it('should accept a schema that defines the named attribute', (): void => { + expect(supportsCredential({password: {credential: true, type: 'string'}}, 'password')).toBe(true); + }); + + it('should accept a non-password credential name declared on the schema', (): void => { + expect(supportsCredential({pin: {credential: true, type: 'string'}}, 'pin')).toBe(true); + }); + + it('should reject an attribute that carries no metadata (not marked credential: true)', (): void => { + expect(supportsCredential({password: {}}, 'password')).toBe(false); + }); + + it('should reject a declared attribute that is not marked credential: true', (): void => { + const schema: Record = { + email: {type: 'string', unique: true}, + }; + + expect(supportsCredential(schema, 'email')).toBe(false); + }); + + it('should accept an attribute the schema marks optional', (): void => { + expect(supportsCredential({password: {credential: true, required: false}}, 'password')).toBe(true); + }); + + it('should reject a schema that declares another credential but not the named one', (): void => { + const schema: Record = { + email: {type: 'string', unique: true}, + pin: {credential: true, type: 'string'}, + }; + + expect(supportsCredential(schema, 'password')).toBe(false); + }); + + it('should reject an empty schema', (): void => { + expect(supportsCredential({}, 'password')).toBe(false); + }); + + it.each([[null], [undefined]])('should accept an unresolved schema (%s)', (schema): void => { + expect(supportsCredential(schema, 'password')).toBe(true); + }); +}); diff --git a/packages/javascript/src/utils/evaluateChangePasswordForm.ts b/packages/javascript/src/utils/evaluateChangePasswordForm.ts new file mode 100644 index 00000000..647c0cdd --- /dev/null +++ b/packages/javascript/src/utils/evaluateChangePasswordForm.ts @@ -0,0 +1,83 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import evaluatePasswordPolicy, {PasswordPolicy, PasswordRuleResult} from './evaluatePasswordPolicy'; + +/** + * The two values a change-password form collects. + * + * There is deliberately no `currentPassword` here. The self-service credential write + * endpoint does not verify the account's existing value today, so a field the server + * ignores would only teach the user a false sense of security; once server-side + * current-value verification ships, this form gains that field back. + */ +export interface ChangePasswordFormValues { + /** + * The re-typed new password, used only to catch typos client-side. + */ + confirmPassword: string; + /** + * The password to set. + */ + newPassword: string; +} + +/** + * The derived state a change-password form needs to render and to gate submission. + */ +export interface ChangePasswordFormEvaluation { + /** + * Whether the new password and its confirmation match. + */ + confirmMatches: boolean; + /** + * Whether the values are complete and internally consistent. Callers combine this with + * their own in-flight flag, since whether a request is pending is UI state rather than + * validation. + */ + isValid: boolean; + /** + * Whether the new password satisfies every configured rule. + */ + meetsPolicy: boolean; + /** + * Per-rule results, for the live requirement checklist. + */ + ruleResults: PasswordRuleResult[]; +} + +/** + * Evaluates a change-password form against a policy. + * + * Every predicate a change-password UI needs is derived here so the React and Vue + * components stay pure rendering concerns and cannot drift apart on what counts as a + * submittable form. + * + * @param values - The current field values. + * @param policy - The rules the new password must satisfy. + * @returns The derived flags plus the per-rule results. + * @example + * ```typescript + * const {isValid, ruleResults} = evaluateChangePasswordForm( + * {confirmPassword, newPassword}, + * {regex: '^.{12,}$'}, + * ); + * const canSubmit = !loading && isValid; + * ``` + */ +const evaluateChangePasswordForm = ( + values: ChangePasswordFormValues, + policy: PasswordPolicy, +): ChangePasswordFormEvaluation => { + const {confirmPassword, newPassword} = values; + + const ruleResults: PasswordRuleResult[] = evaluatePasswordPolicy(newPassword, policy); + const meetsPolicy: boolean = ruleResults.every((rule: PasswordRuleResult) => rule.passed); + const confirmMatches: boolean = newPassword === confirmPassword; + + const isValid: boolean = newPassword !== '' && confirmPassword !== '' && meetsPolicy && confirmMatches; + + return {confirmMatches, isValid, meetsPolicy, ruleResults}; +}; + +export default evaluateChangePasswordForm; diff --git a/packages/javascript/src/utils/evaluatePasswordPolicy.ts b/packages/javascript/src/utils/evaluatePasswordPolicy.ts new file mode 100644 index 00000000..dd51e06d --- /dev/null +++ b/packages/javascript/src/utils/evaluatePasswordPolicy.ts @@ -0,0 +1,86 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +/** + * Client-side password rule used to drive both the requirement checklist and the + * submit gate of a change-password form. + * + * The server does not enforce the user type schema's `password` regex on the credential + * write path, and the default schema ships without a regex at all, so this rule is + * advisory: it exists to give the user actionable feedback before the request is sent. + * The schema's `regex` is the sole source of truth; the SDK does not layer its own + * character-class or length rules on top, since doing so could reject a password the + * organization's policy accepts. + */ +export interface PasswordPolicy { + /** + * A regular expression the whole value must match. Typically sourced from the + * `password` attribute's `regex` in `GET /users/me/meta`. + */ + regex?: string; +} + +/** + * The outcome of the password rule. + */ +export interface PasswordRuleResult { + /** + * Stable identifier for the rule, usable as a React key or test selector. + */ + key: string; + /** + * i18n key describing the requirement. Resolved by the consuming component rather + * than here, so this module stays free of translation concerns. + */ + messageKey: string; + /** + * Substitution params for `messageKey`, in the `{token}` form the i18n layer expects. + */ + params?: Record; + /** + * Whether the value satisfies this rule. + */ + passed: boolean; +} + +/** + * Evaluates a password against a policy, returning one result when the policy configures + * a `regex` and none otherwise. + * + * An uncompilable `regex` is treated as passing, matching `evaluateValidationRule`: + * the SDK stays lenient so a misconfigured schema cannot lock a user out of their own + * password change. + * + * @param value - The candidate password. + * @param policy - The rule to apply. + * @returns A single-item list when `policy.regex` is set, otherwise an empty list. + * @example + * ```typescript + * const results = evaluatePasswordPolicy('sh0rt', {regex: '^.{8,}$'}); + * const isValid = results.every(result => result.passed); + * ``` + */ +const evaluatePasswordPolicy = (value: string, policy: PasswordPolicy): PasswordRuleResult[] => { + if (!policy.regex) { + return []; + } + + let matches = true; + + try { + matches = new RegExp(policy.regex).test(value); + } catch { + // An uncompilable pattern must not block the user. The server is authoritative. + matches = true; + } + + return [ + { + key: 'regex', + messageKey: 'validation.password.pattern', + passed: matches, + }, + ]; +}; + +export default evaluatePasswordPolicy; diff --git a/packages/javascript/src/utils/mapCredentialUpdateError.ts b/packages/javascript/src/utils/mapCredentialUpdateError.ts new file mode 100644 index 00000000..fa9130b2 --- /dev/null +++ b/packages/javascript/src/utils/mapCredentialUpdateError.ts @@ -0,0 +1,74 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import ThunderIDAPIError from '../errors/ThunderIDAPIError'; +import ThunderIDError from '../errors/ThunderIDError'; + +/** + * The form field a credential-update failure belongs to, or `null` when the failure has + * no single field to blame and belongs at form level. + */ +export type CredentialUpdateErrorField = 'newPassword' | null; + +/** + * Where a credential-update failure should be shown, and what it should say. + * + * `messageKey` is always set, so the caller can resolve text with a plain + * `message ?? t(messageKey)` and never needs a non-null assertion. `message` is present + * only when the server supplied something human-readable, and takes precedence when it + * is. Translation is left to the caller, which keeps this module free of i18n concerns + * in the same way {@link evaluatePasswordPolicy} is. + */ +export interface CredentialUpdateErrorResult { + /** + * The field to attach the error to, or `null` for a form-level error. + */ + field: CredentialUpdateErrorField; + /** + * A server-supplied message, already human-readable. Shown as-is when present. + */ + message?: string; + /** + * The i18n key to fall back to when `message` is absent. Always set. + */ + messageKey: string; +} + +/** + * Maps a failure from the credential write path onto the field that caused it. + * + * `400` is the new password failing a server-side check. Everything else, including a + * `403`, has no single field to blame here: this form collects no current-value field for + * a `403` to attach to, since the server does not verify one on this path today. + * + * @param error - The value thrown by the credential update call. + * @returns Where to show the failure and what to show. + * @example + * ```typescript + * const {field, message, messageKey} = mapCredentialUpdateError(caughtError); + * const text = message ?? t(messageKey); + * + * if (field) { + * setFieldErrors({[field]: text}); + * } else { + * setError(text); + * } + * ``` + */ +const GENERIC_MESSAGE_KEY = 'user.change_password.generic.error'; + +const mapCredentialUpdateError = (error: unknown): CredentialUpdateErrorResult => { + const status: number | undefined = error instanceof ThunderIDAPIError ? error.statusCode : undefined; + + if (status === 400 && error instanceof ThunderIDError) { + return {field: 'newPassword', message: error.message, messageKey: GENERIC_MESSAGE_KEY}; + } + + if (error instanceof ThunderIDError) { + return {field: null, message: error.message, messageKey: GENERIC_MESSAGE_KEY}; + } + + return {field: null, messageKey: GENERIC_MESSAGE_KEY}; +}; + +export default mapCredentialUpdateError; diff --git a/packages/javascript/src/utils/resolveChangeCredentialPolicy.ts b/packages/javascript/src/utils/resolveChangeCredentialPolicy.ts new file mode 100644 index 00000000..710baa2d --- /dev/null +++ b/packages/javascript/src/utils/resolveChangeCredentialPolicy.ts @@ -0,0 +1,40 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {PasswordPolicy} from './evaluatePasswordPolicy'; +import {AttributeSchema} from '../api/getUsersMeMeta'; + +/** + * Resolves the rules a change-credential form should enforce for the given credential. + * + * The organization's own policy, expressed as the credential attribute's `regex` in + * `GET /users/me/meta`, is the sole source of truth: the SDK does not layer its own + * character-class or length rules on top, since doing so could reject a value the + * organization's policy accepts. When the schema carries no regex for this credential, + * there is no policy to check client-side and the requirement checklist is simply empty. + * + * @param userSchema - The user type schema resolved by the provider, keyed by attribute. + * @param attribute - The credential attribute this form manages (e.g. `password`, `pin`). + * @param override - An explicit policy supplied by the caller, which wins outright. + * @returns The policy to hand to {@link evaluatePasswordPolicy}. + * @example + * ```typescript + * const policy = resolveChangeCredentialPolicy(userSchema, 'pin', undefined); + * const results = evaluatePasswordPolicy(candidate, policy); + * ``` + */ +const resolveChangeCredentialPolicy = ( + userSchema: Record | null | undefined, + attribute: string, + override?: PasswordPolicy, +): PasswordPolicy => { + if (override) { + return override; + } + + const schemaRegex: string | undefined = userSchema?.[attribute]?.regex; + + return schemaRegex ? {regex: schemaRegex} : {}; +}; + +export default resolveChangeCredentialPolicy; diff --git a/packages/javascript/src/utils/resolveResourceEndpoint.ts b/packages/javascript/src/utils/resolveResourceEndpoint.ts index c4fb530f..f7fdc801 100644 --- a/packages/javascript/src/utils/resolveResourceEndpoint.ts +++ b/packages/javascript/src/utils/resolveResourceEndpoint.ts @@ -12,7 +12,7 @@ import {BaseConfig} from '../models/config'; * issuers), these overrides let the SDK send flow and user-management requests to the resource * server while OAuth requests continue to target the authorization server. */ -export type ResourceEndpointKey = 'flowExecute' | 'flowMeta' | 'usersMe' | 'usersMeMeta'; +export type ResourceEndpointKey = 'flowExecute' | 'flowMeta' | 'usersMe' | 'usersMeCredentials' | 'usersMeMeta'; /** * The `config.endpoints` keys that address resource-server endpoints rather than OIDC/OAuth @@ -22,6 +22,7 @@ export const RESOURCE_ENDPOINT_KEYS: readonly ResourceEndpointKey[] = [ 'flowExecute', 'flowMeta', 'usersMe', + 'usersMeCredentials', 'usersMeMeta', ]; diff --git a/packages/javascript/src/utils/supportsCredential.ts b/packages/javascript/src/utils/supportsCredential.ts new file mode 100644 index 00000000..961d4f31 --- /dev/null +++ b/packages/javascript/src/utils/supportsCredential.ts @@ -0,0 +1,45 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {AttributeSchema} from '../api/getUsersMeMeta'; + +/** + * Whether the signed-in user's type allows them to set the given credential for themselves. + * + * The user type schema from `GET /users/me/meta` is the only thing a client can check before + * rendering. It says whether the credential is declared on this type at all, which is a property + * of the type rather than of the account, so it cannot say whether this particular user has one + * stored today. That second question is the server's to answer. + * + * Without this check the form would submit into a guaranteed failure. `POST + * /users/me/update-credentials` rejects a write for a name not declared on the type, since the + * entity layer only accepts schema-declared credential keys. + * + * An absent schema means the answer is not known yet, either because the profile is still loading + * or because the consuming app never supplied one. Both resolve to `true` so a change-credential + * affordance is never hidden on missing information alone, and so apps that do not wire up + * `userSchema` keep the behaviour they had before this check existed. + * + * @param userSchema - The user type schema resolved by the provider, keyed by attribute. + * @param attribute - The credential attribute to check (e.g. `password`, `pin`). + * @returns `false` only when the schema is known and either defines no attribute named + * `attribute` or defines one that isn't marked `credential: true`. + * @example + * ```typescript + * if (!supportsCredential(userSchema, 'pin')) { + * return null; + * } + * ``` + */ +const supportsCredential = ( + userSchema: Record | null | undefined, + attribute: string, +): boolean => { + if (!userSchema) { + return true; + } + + return userSchema[attribute]?.credential === true; +}; + +export default supportsCredential; diff --git a/packages/react/src/api/updateMeCredentials.ts b/packages/react/src/api/updateMeCredentials.ts new file mode 100644 index 00000000..7c327694 --- /dev/null +++ b/packages/react/src/api/updateMeCredentials.ts @@ -0,0 +1,51 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import { + createHttpClientFetcher, + updateMeCredentials as baseUpdateMeCredentials, + UpdateMeCredentialsConfig as BaseUpdateMeCredentialsConfig, +} from '@thunderid/browser'; + +/** + * Configuration for the updateMeCredentials request (React-specific) + */ +export interface UpdateMeCredentialsConfig extends Omit { + /** + * Optional custom fetcher function. If not provided, the ThunderID SPA client's httpClient will be used + * which is a wrapper around axios http.request + */ + fetcher?: (url: string, config: RequestInit) => Promise; + /** + * Optional instance ID for multi-instance support. Defaults to 0. + */ + instanceId?: number; +} + +/** + * Updates one or more of the signed-in user's credentials at the /users/me/update-credentials + * endpoint. Uses the ThunderID SPA client's httpClient by default, which attaches the access + * token, but allows a custom fetcher. + * + * @param config - Configuration object with URL, payload and optional request config. + * @returns A promise that resolves once the credentials have been updated. + * @example + * ```typescript + * await updateMeCredentials({ + * url: "https://localhost:8090/users/me/update-credentials", + * payload: {password: "n3wP@ssword!"} + * }); + * ``` + */ +const updateMeCredentials = async ({ + fetcher, + instanceId = 0, + ...requestConfig +}: UpdateMeCredentialsConfig): Promise => { + return baseUpdateMeCredentials({ + ...requestConfig, + fetcher: fetcher || createHttpClientFetcher(instanceId), + }); +}; + +export default updateMeCredentials; diff --git a/packages/react/src/components/presentation/ChangeCredential/BaseChangeCredential.styles.ts b/packages/react/src/components/presentation/ChangeCredential/BaseChangeCredential.styles.ts new file mode 100644 index 00000000..915cf48a --- /dev/null +++ b/packages/react/src/components/presentation/ChangeCredential/BaseChangeCredential.styles.ts @@ -0,0 +1,125 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {Theme} from '@thunderid/browser'; +import {css} from '../../../styles/emotion'; + +/** + * Creates styles for the BaseChangeCredential component + * @param theme - The theme object containing design tokens + * @param colorScheme - The current color scheme (used for memoization) + * @returns Object containing CSS class names for component styling + */ +const useStyles = (theme: Theme, colorScheme: string): Record => { + const root: string = css` + display: flex; + flex-direction: column; + gap: calc(${theme.vars.spacing.unit} * 2); + width: 100%; + `; + + const card: string = css` + padding: calc(${theme.vars.spacing.unit} * 3); + border: 1px solid ${theme.vars.colors.border}; + border-radius: ${theme.vars.borderRadius.large}; + `; + + const heading: string = css` + margin: 0; + `; + + const fields: string = css` + display: flex; + flex-direction: column; + gap: calc(${theme.vars.spacing.unit} * 2); + `; + + const requirements: string = css` + display: flex; + flex-direction: column; + gap: calc(${theme.vars.spacing.unit} / 2); + margin: 0; + padding: 0; + list-style: none; + `; + + const requirementsHeading: string = css` + margin: 0 0 calc(${theme.vars.spacing.unit} / 2) 0; + opacity: 0.8; + `; + + const requirement: string = css` + display: flex; + align-items: center; + gap: ${theme.vars.spacing.unit}; + `; + + const requirementPassed: string = css` + color: ${theme.vars.colors.success.main}; + `; + + const requirementPending: string = css` + opacity: 0.7; + `; + + const requirementIcon: string = css` + flex-shrink: 0; + width: 14px; + height: 14px; + `; + + const alert: string = css` + width: 100%; + `; + + const actions: string = css` + display: flex; + gap: ${theme.vars.spacing.unit}; + align-items: center; + justify-content: flex-end; + `; + + const unavailableRoot: string = css` + position: relative; + display: flex; + width: 100%; + `; + + const unavailableContent: string = css` + width: 100%; + filter: blur(3px); + opacity: 0.55; + pointer-events: none; + user-select: none; + `; + + const unavailableOverlay: string = css` + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + padding: calc(${theme.vars.spacing.unit} * 2); + `; + + return { + actions, + alert, + card, + colorScheme, + fields, + heading, + requirement, + requirementIcon, + requirementPassed, + requirementPending, + requirements, + requirementsHeading, + root, + unavailableContent, + unavailableOverlay, + unavailableRoot, + }; +}; + +export default useStyles; diff --git a/packages/react/src/components/presentation/ChangeCredential/BaseChangeCredential.tsx b/packages/react/src/components/presentation/ChangeCredential/BaseChangeCredential.tsx new file mode 100644 index 00000000..797aaa7d --- /dev/null +++ b/packages/react/src/components/presentation/ChangeCredential/BaseChangeCredential.tsx @@ -0,0 +1,354 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import { + ChangePasswordFormEvaluation, + PasswordPolicy, + PasswordRuleResult, + Preferences, + bem, + evaluateChangePasswordForm, + withVendorCSSClassPrefix, +} from '@thunderid/browser'; +import {FC, FormEvent, ReactElement, useMemo, useState} from 'react'; +import useStyles from './BaseChangeCredential.styles'; +import useTheme from '../../../contexts/Theme/useTheme'; +import useTranslation from '../../../hooks/useTranslation'; +import {cx} from '../../../styles/emotion'; +import AlertPrimitive from '../../primitives/Alert/Alert'; +import Button from '../../primitives/Button/Button'; +import Check from '../../primitives/Icons/Check'; +import X from '../../primitives/Icons/X'; +import PasswordField from '../../primitives/PasswordField/PasswordField'; +import Typography from '../../primitives/Typography/Typography'; + +/** + * The values collected by the form and handed to `onSubmit`. + * + * There is deliberately no current-value field here. The self-service credential write + * endpoint does not verify the account's existing value today, so a field the server + * ignores would only teach the user a false sense of security. + */ +export interface ChangePasswordValues { + /** + * The value to set. + */ + newPassword: string; +} + +export interface BaseChangeCredentialProps { + /** + * Whether to wrap the form in a bordered card. + */ + cardLayout?: boolean; + /** + * Additional CSS class names + */ + className?: string; + /** + * The human-readable name of the credential, substituted into every default label, + * placeholder and message (for example "Change {credential}", "New {credential}"). + * Defaults to `Password`. {@link ChangeCredential} resolves this from the attribute's own + * `displayName` in the user schema; set it directly here when using this presentational + * component without that context. + */ + credentialDisplayName?: string; + /** + * A form-level error, typically a server failure that maps to no single field. + */ + error?: string | null; + /** + * Server-supplied errors keyed by field name (`newPassword`). + */ + fieldErrors?: Record; + /** + * Whether a submission is in flight. + */ + loading?: boolean; + /** + * Called with the collected values once client-side validation passes. + */ + onSubmit?: (values: ChangePasswordValues) => void; + /** + * The rules the new value must satisfy. Defaults to no rules, in which case the checklist + * is empty and the only submit gate is a non-empty value; the caller is expected to source + * this from the user type schema (see {@link ChangeCredential}) or supply its own. + */ + policy?: PasswordPolicy; + /** + * Component-level preference overrides, including i18n. + */ + preferences?: Preferences; + /** + * Whether to render the live requirement checklist. Defaults to `true`. + */ + showRequirements?: boolean; + /** + * Whether the last submission succeeded. + */ + success?: boolean; + /** + * Overrides the default "Change {credential}" heading. Pass an empty string to omit the + * heading entirely, for example when an app's own surrounding layout (a card header, a + * dialog title) already names the credential and a second heading would be redundant. + * Defaults to the translated `user.change_password.heading` string. + */ + title?: string; + /** + * Whether the account cannot have this credential changed at all, because the user type's + * schema defines no attribute of this name. The form is rendered inert behind an + * explanatory message rather than hidden, so an integrator who placed the component can see + * why it is not usable instead of finding an empty space. + */ + unavailable?: boolean; +} + +/** + * Presentational change-credential form with just the two fields a first-time or + * unverified credential change needs: new value and confirmation. + * + * Holds no context and performs no network calls: it renders the fields, evaluates the + * policy for the checklist and the submit gate, and hands validated values to `onSubmit`. + * Use {@link ChangeCredential} for the context-wired variant. + * + * @example + * ```tsx + * save(newPassword)} + * /> + * ``` + */ +const BaseChangeCredential: FC = ({ + cardLayout = false, + className = '', + credentialDisplayName = 'Password', + error = null, + fieldErrors = {}, + loading = false, + onSubmit = undefined, + policy = {}, + preferences = undefined, + showRequirements = true, + success = false, + title = undefined, + unavailable = false, +}: BaseChangeCredentialProps) => { + const {theme, colorScheme}: ReturnType = useTheme(); + const styles: Record = useStyles(theme, colorScheme); + const {t} = useTranslation(preferences?.i18n); + + // Substituted into every default label, placeholder and message. A caller-supplied + // translation that carries no `{credential}`/`{credentialLower}` placeholder is unaffected. + const labelParams: Record = { + credential: credentialDisplayName, + credentialLower: credentialDisplayName.toLowerCase(), + }; + + // `title` overrides the default heading; an explicit empty string omits it entirely. + const resolvedTitle: string = title ?? t('user.change_password.heading', labelParams); + + const [newPassword, setNewPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [submitted, setSubmitted] = useState(false); + + // Clear the entered values once the container reports the write succeeded, so a shared + // machine is not left with the new credential sitting in the form. Adjusting state during + // render (rather than in an effect) is React's documented way to reset on a prop change and + // avoids the extra commit an effect would cause. + const [prevSuccess, setPrevSuccess] = useState(success); + + if (success !== prevSuccess) { + setPrevSuccess(success); + + if (success) { + setNewPassword(''); + setConfirmPassword(''); + setSubmitted(false); + } + } + + const {confirmMatches, isValid, ruleResults}: ChangePasswordFormEvaluation = useMemo( + () => evaluateChangePasswordForm({confirmPassword, newPassword}, policy), + [confirmPassword, newPassword, policy], + ); + + // A schema with no attribute of this name makes every control pointless, so they are + // disabled outright rather than left focusable behind the overlay. + const interactionDisabled: boolean = loading || unavailable; + const canSubmit: boolean = !interactionDisabled && isValid; + + const handleSubmit = (event: FormEvent): void => { + event.preventDefault(); + setSubmitted(true); + + if (!canSubmit) { + return; + } + + onSubmit?.({newPassword}); + }; + + const renderRequirement = (rule: PasswordRuleResult): ReactElement => ( +
  • + + {rule.passed ? : } + + + {t(rule.messageKey, rule.params)} + +
  • + ); + + // Only surface the local error once the user has attempted a submit, so the form does + // not flag fields the user has not finished filling in. + const confirmError: string | undefined = + submitted && !confirmMatches ? t('user.change_password.mismatch.error', labelParams) : undefined; + const newPasswordError: string | undefined = fieldErrors['newPassword']; + + const form: ReactElement = ( +
    + {resolvedTitle && ( + + {resolvedTitle} + + )} + + {error && ( + + {t('errors.heading') || 'Error'} + {error} + + )} + + {success && ( + + {t('user.change_password.success', labelParams)} + + )} + +
    + + + {showRequirements && ruleResults.length > 0 && ( +
    + + {t('user.change_password.requirements.heading', labelParams)} + +
      {ruleResults.map(renderRequirement)}
    +
    + )} + + +
    + +
    + +
    +
    + ); + + if (!unavailable) { + return form; + } + + return ( +
    + + +
    + + {t('user.change_password.unavailable.heading', labelParams)} + + {t('user.change_password.unavailable.description', labelParams)} + + +
    +
    + ); +}; + +export default BaseChangeCredential; diff --git a/packages/react/src/components/presentation/ChangeCredential/ChangeCredential.tsx b/packages/react/src/components/presentation/ChangeCredential/ChangeCredential.tsx new file mode 100644 index 00000000..92811144 --- /dev/null +++ b/packages/react/src/components/presentation/ChangeCredential/ChangeCredential.tsx @@ -0,0 +1,161 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import { + CredentialConstants, + CredentialUpdateErrorResult, + PasswordPolicy, + mapCredentialUpdateError, + resolveChangeCredentialPolicy, + resolveResourceEndpoint, + supportsCredential, +} from '@thunderid/browser'; +import {FC, useMemo, useState} from 'react'; +import BaseChangeCredential, {BaseChangeCredentialProps, ChangePasswordValues} from './BaseChangeCredential'; +import updateMeCredentials from '../../../api/updateMeCredentials'; +import useThunderID from '../../../contexts/ThunderID/useThunderID'; +import useUser from '../../../contexts/User/useUser'; +import useTranslation from '../../../hooks/useTranslation'; + +/** + * Title-cases a credential attribute name for use as a display-name fallback when the schema + * declares no `displayName` for it, e.g. `pin` -> `Pin`. + */ +const defaultDisplayName = (attribute: string): string => attribute.charAt(0).toUpperCase() + attribute.slice(1); + +export interface ChangeCredentialProps + extends Omit< + BaseChangeCredentialProps, + 'credentialDisplayName' | 'error' | 'fieldErrors' | 'loading' | 'onSubmit' | 'success' + > { + /** + * The credential attribute this instance manages, any attribute the user's entity type + * schema declares `credential: true` (for example `password` or `pin`). Defaults to + * `password`. Render the component once per credential to let a user manage more than one, + * for example `` for the password and + * `` for a PIN. + */ + attribute?: string; + /** + * Called after the credential has been changed successfully. + */ + onSuccess?: () => void; +} + +/** + * ChangeCredential lets the signed-in user set a new value for one of their own credentials. + * + * It collects only a new value and its confirmation. The self-service credential write path + * does not verify the account's existing value today, so this component does not ask for one; + * once server-side current-value verification ships, that field returns without a breaking + * change to this component's public props. + * + * It reads the applicable rules from the user schema already resolved by the ThunderID + * provider, so it adds no network request beyond the write itself, and posts to + * `/users/me/update-credentials` with the access token attached by the SDK's HTTP client. + * + * Defaults to managing the `password` credential. To manage a different one (for example a + * PIN declared on the user type schema), set `attribute`; render the component once per + * credential to let a user manage several. Every default label, placeholder and message is + * built from the attribute's own `displayName` in the schema (`GET /users/me/meta`), so it + * always matches whatever an admin named the attribute there; when the schema carries no + * `displayName` for it, it falls back to the attribute name title-cased (`pin` -> `Pin`). + * Override individual strings via `preferences.i18n` for full control (including other + * languages) if the schema-derived name isn't the right fit. + * + * @example + * ```tsx + * // Basic usage, manages the password + * toast('Password updated')} /> + * + * // With an explicit rule instead of the schema-derived policy + * + * + * // Managing a different credential declared on the schema; labels use the schema's own + * // displayName for it automatically + * + * ``` + */ +const ChangeCredential: FC = ({ + attribute = CredentialConstants.PASSWORD, + onSuccess = undefined, + policy = undefined, + preferences = undefined, + ...rest +}: ChangeCredentialProps) => { + const {baseUrl, endpoints, instanceId, preferences: contextPreferences} = useThunderID(); + const {userSchema} = useUser(); + const resolvedDisplayName: string = userSchema?.[attribute]?.displayName ?? defaultDisplayName(attribute); + + const resolvedPreferences = useMemo( + () => ({ + ...contextPreferences, + ...preferences, + user: {...contextPreferences?.user, ...preferences?.user}, + }), + [contextPreferences, preferences], + ); + const {t} = useTranslation(resolvedPreferences?.i18n); + + const [error, setError] = useState(null); + const [fieldErrors, setFieldErrors] = useState>({}); + const [loading, setLoading] = useState(false); + const [success, setSuccess] = useState(false); + + const resolvedPolicy: PasswordPolicy = useMemo( + () => resolveChangeCredentialPolicy(userSchema, attribute, policy), + [userSchema, attribute, policy], + ); + + const handleSubmit = async ({newPassword}: ChangePasswordValues): Promise => { + setError(null); + setFieldErrors({}); + setSuccess(false); + setLoading(true); + + try { + await updateMeCredentials({ + baseUrl, + instanceId, + payload: {[attribute]: newPassword}, + url: resolveResourceEndpoint('usersMeCredentials', {endpoints}), + }); + + setSuccess(true); + onSuccess?.(); + } catch (caughtError: unknown) { + const {field, message, messageKey}: CredentialUpdateErrorResult = mapCredentialUpdateError(caughtError); + const text: string = + message ?? t(messageKey, {credential: resolvedDisplayName, credentialLower: resolvedDisplayName.toLowerCase()}); + + if (field) { + setFieldErrors({[field]: text}); + } else { + setError(text); + } + } finally { + setLoading(false); + } + }; + + return ( + { + void handleSubmit(values); + }} + /> + ); +}; + +export default ChangeCredential; diff --git a/packages/react/src/components/presentation/ChangeCredential/__tests__/ChangeCredential.test.tsx b/packages/react/src/components/presentation/ChangeCredential/__tests__/ChangeCredential.test.tsx new file mode 100644 index 00000000..3930c3e5 --- /dev/null +++ b/packages/react/src/components/presentation/ChangeCredential/__tests__/ChangeCredential.test.tsx @@ -0,0 +1,350 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react'; +import {Mock, afterEach, beforeEach, 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 UserContext, {UserContextProps} from '../../../../contexts/User/UserContext'; +import ChangeCredential from '../ChangeCredential'; + +const mockUpdateMeCredentials = vi.fn() as Mock; + +vi.mock('../../../../api/updateMeCredentials', () => ({ + default: (...args: unknown[]): unknown => mockUpdateMeCredentials(...args) as unknown, +})); + +const thunderIDContext: ThunderIDContextProps = { + baseUrl: 'https://localhost:8090', + instanceId: 0, + isInitialized: true, + isLoading: false, + vendor: 'thunderid', +} as unknown as ThunderIDContextProps; + +const buildUserContext = (overrides: Partial = {}): UserContextProps => + ({ + flattenedProfile: null, + onUpdateProfile: vi.fn(), + profile: null, + revalidateProfile: vi.fn(), + updateProfile: vi.fn(), + userSchema: null, + ...overrides, + }) as unknown as UserContextProps; + +const renderChangeCredential = ( + props: Record = {}, + userContext: UserContextProps = buildUserContext(), +) => + render( + + + + + + + + + , + ); + +const fieldByName = (name: string): HTMLInputElement => + document.querySelector(`input[name="${name}"]`)!; + +const setField = (name: string, value: string): void => { + fireEvent.change(fieldByName(name), {target: {value}}); +}; + +// Queried by type rather than by label text, since the label's default text now varies with +// attribute (e.g. "Update Password" vs "Update Pin"). +const submitButton = (): HTMLButtonElement => document.querySelector('button[type="submit"]')!; + +describe('ChangeCredential', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + cleanup(); + }); + + it('renders only the two fields, with no current-value field', () => { + renderChangeCredential(); + + expect(fieldByName('newPassword')).toBeTruthy(); + expect(fieldByName('confirmPassword')).toBeTruthy(); + expect(document.querySelector('input[name="currentPassword"]')).toBeNull(); + }); + + it('marks both fields as new-password for password managers', () => { + renderChangeCredential(); + + expect(fieldByName('newPassword').getAttribute('autocomplete')).toBe('new-password'); + expect(fieldByName('confirmPassword').getAttribute('autocomplete')).toBe('new-password'); + }); + + it('updates the requirement checklist as the user types', () => { + renderChangeCredential({policy: {regex: '^(?=.*\\d).{8,}$'}}); + + const isPassed = (): string | null => + document.querySelector('li[data-passed]')?.getAttribute('data-passed') ?? null; + + expect(isPassed()).toBe('false'); + + setField('newPassword', 'longenough'); + expect(isPassed()).toBe('false'); + + setField('newPassword', 'longenough1'); + expect(isPassed()).toBe('true'); + }); + + it('keeps submit disabled until every rule passes and the confirmation matches', () => { + renderChangeCredential({policy: {regex: '^.{8,}$'}}); + + expect(submitButton().disabled).toBe(true); + + setField('newPassword', 'sh0rt'); + setField('confirmPassword', 'sh0rt'); + expect(submitButton().disabled).toBe(true); + + setField('newPassword', 'longenough1'); + setField('confirmPassword', 'longenough1'); + expect(submitButton().disabled).toBe(false); + }); + + it('does not submit when the confirmation does not match', () => { + renderChangeCredential({policy: {regex: '^.{8,}$'}}); + + setField('newPassword', 'longenough1'); + setField('confirmPassword', 'different99'); + + expect(submitButton().disabled).toBe(true); + expect(mockUpdateMeCredentials).not.toHaveBeenCalled(); + }); + + it('sends only the new value, keyed by password by default', async () => { + mockUpdateMeCredentials.mockResolvedValueOnce(undefined); + renderChangeCredential({policy: {regex: '^.{8,}$'}}); + + setField('newPassword', 'n3wP@ssword'); + setField('confirmPassword', 'n3wP@ssword'); + fireEvent.click(submitButton()); + + await waitFor(() => expect(mockUpdateMeCredentials).toHaveBeenCalledTimes(1)); + + expect(mockUpdateMeCredentials).toHaveBeenCalledWith( + expect.objectContaining({ + baseUrl: 'https://localhost:8090', + payload: {password: 'n3wP@ssword'}, + }), + ); + }); + + it('keys the payload by attribute when set to something other than password', async () => { + mockUpdateMeCredentials.mockResolvedValueOnce(undefined); + renderChangeCredential({attribute: 'pin', policy: {regex: '^.{4,}$'}}); + + setField('newPassword', '1234'); + setField('confirmPassword', '1234'); + fireEvent.click(submitButton()); + + await waitFor(() => expect(mockUpdateMeCredentials).toHaveBeenCalledTimes(1)); + + expect(mockUpdateMeCredentials).toHaveBeenCalledWith( + expect.objectContaining({ + payload: {pin: '1234'}, + }), + ); + }); + + it('shows the success alert and fires onSuccess', async () => { + mockUpdateMeCredentials.mockResolvedValueOnce(undefined); + const onSuccess = vi.fn(); + renderChangeCredential({onSuccess, policy: {regex: '^.{8,}$'}}); + + setField('newPassword', 'n3wP@ssword'); + setField('confirmPassword', 'n3wP@ssword'); + fireEvent.click(submitButton()); + + await waitFor(() => expect(onSuccess).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(screen.getByText(/your password has been updated/i)).toBeTruthy()); + }); + + it('clears the entered passwords after a successful change', async () => { + mockUpdateMeCredentials.mockResolvedValueOnce(undefined); + renderChangeCredential({policy: {regex: '^.{8,}$'}}); + + setField('newPassword', 'n3wP@ssword'); + setField('confirmPassword', 'n3wP@ssword'); + fireEvent.click(submitButton()); + + await waitFor(() => expect(fieldByName('newPassword').value).toBe('')); + + expect(fieldByName('confirmPassword').value).toBe(''); + }); + + it('surfaces a 400 onto the new password field', async () => { + const {ThunderIDAPIError} = await import('@thunderid/browser'); + + mockUpdateMeCredentials.mockRejectedValueOnce( + new ThunderIDAPIError('Password is too common', 'x-001', 'react', 400, 'Bad Request'), + ); + renderChangeCredential({policy: {regex: '^.{8,}$'}}); + + setField('newPassword', 'n3wP@ssword'); + setField('confirmPassword', 'n3wP@ssword'); + fireEvent.click(submitButton()); + + await waitFor(() => expect(screen.getByText(/password is too common/i)).toBeTruthy()); + }); + + it('surfaces an unmapped failure as a form-level error', async () => { + const {ThunderIDAPIError} = await import('@thunderid/browser'); + + mockUpdateMeCredentials.mockRejectedValueOnce( + new ThunderIDAPIError('Server exploded', 'x-002', 'react', 500, 'Internal Server Error'), + ); + renderChangeCredential({policy: {regex: '^.{8,}$'}}); + + setField('newPassword', 'n3wP@ssword'); + setField('confirmPassword', 'n3wP@ssword'); + fireEvent.click(submitButton()); + + await waitFor(() => expect(screen.getByText(/server exploded/i)).toBeTruthy()); + }); + + it('derives the policy regex from the user schema', () => { + const userContext: UserContextProps = buildUserContext({ + userSchema: { + password: {credential: true, regex: '^[a-z]+$', type: 'string'}, + }, + }); + + renderChangeCredential({}, userContext); + + setField('newPassword', 'Str0ng!Pass'); + + // The schema regex is the whole policy; no SDK-side rules are layered alongside it. + const items: NodeListOf = document.querySelectorAll('li[data-passed]'); + expect(items.length).toBe(1); + expect(items[0].getAttribute('data-passed')).toBe('false'); + }); + + it('derives the policy from a non-password credential attribute', () => { + const userContext: UserContextProps = buildUserContext({ + userSchema: { + pin: {credential: true, regex: '^\\d{4}$', type: 'string'}, + }, + }); + + renderChangeCredential({attribute: 'pin'}, userContext); + + setField('newPassword', '12'); + + const items: NodeListOf = document.querySelectorAll('li[data-passed]'); + expect(items.length).toBe(1); + expect(items[0].getAttribute('data-passed')).toBe('false'); + }); + + it('applies no client-side rules when the schema has no password regex', () => { + renderChangeCredential({}, buildUserContext({userSchema: {password: {credential: true, type: 'string'}}})); + + setField('newPassword', 'x'); + + expect(document.querySelectorAll('li[data-passed]').length).toBe(0); + }); + + describe('when the schema defines no password attribute', () => { + const schemaWithoutPassword = {email: {type: 'string'}, pin: {credential: true, type: 'string'}}; + + it('explains why the form is unusable instead of rendering nothing', () => { + renderChangeCredential({}, buildUserContext({userSchema: schemaWithoutPassword})); + + expect(screen.getByRole('status')).toBeTruthy(); + expect(screen.getByText(/password changes unavailable/i)).toBeTruthy(); + }); + + it('still renders the form so the overlay has something to sit on', () => { + renderChangeCredential({}, buildUserContext({userSchema: schemaWithoutPassword})); + + expect(fieldByName('newPassword')).toBeTruthy(); + }); + + it('disables every control so nothing is reachable behind the overlay', () => { + renderChangeCredential({}, buildUserContext({userSchema: schemaWithoutPassword})); + + expect(fieldByName('newPassword').disabled).toBe(true); + expect(fieldByName('confirmPassword').disabled).toBe(true); + // Queried through the DOM rather than by role: the blurred form is aria-hidden, so an + // accessible-role lookup correctly cannot reach it. + expect(document.querySelector('button[type="submit"]')!.disabled).toBe(true); + }); + + it('never writes credentials even if a submit is forced through', () => { + renderChangeCredential({}, buildUserContext({userSchema: schemaWithoutPassword})); + + fireEvent.submit(document.querySelector('form')!); + + expect(mockUpdateMeCredentials).not.toHaveBeenCalled(); + }); + + it('renders the usable form when the schema does define a password', () => { + renderChangeCredential({}, buildUserContext({userSchema: {password: {credential: true, type: 'string'}}})); + + expect(screen.queryByRole('status')).toBeNull(); + expect(fieldByName('newPassword').disabled).toBe(false); + }); + + it('is available when attribute points at a declared non-password credential', () => { + renderChangeCredential({attribute: 'pin'}, buildUserContext({userSchema: schemaWithoutPassword})); + + expect(screen.queryByRole('status')).toBeNull(); + expect(fieldByName('newPassword').disabled).toBe(false); + }); + + it('shows a generic contact-administrator message rather than naming the credential', () => { + renderChangeCredential({}, buildUserContext({userSchema: schemaWithoutPassword})); + + expect(screen.getByText(/contact your administrator/i)).toBeTruthy(); + }); + }); + + it('takes the display name from the schema rather than title-casing the attribute', () => { + const userContext: UserContextProps = buildUserContext({ + userSchema: { + pin: {credential: true, displayName: 'Security PIN', type: 'string'}, + }, + }); + + renderChangeCredential({attribute: 'pin'}, userContext); + + expect(screen.getByText('Change Security PIN')).toBeTruthy(); + }); + + it('falls back to title-casing the attribute when the schema has no displayName', () => { + const userContext: UserContextProps = buildUserContext({ + userSchema: { + pin: {credential: true, type: 'string'}, + }, + }); + + renderChangeCredential({attribute: 'pin'}, userContext); + + expect(screen.getByText('Change Pin')).toBeTruthy(); + }); + + it('overrides the default heading when title is set', () => { + renderChangeCredential({title: 'Update your PIN'}); + + expect(screen.getByText('Update your PIN')).toBeTruthy(); + expect(screen.queryByText('Change Password')).toBeNull(); + }); + + it('omits the heading entirely when title is an empty string', () => { + renderChangeCredential({title: ''}); + + expect(screen.queryByText('Change Password')).toBeNull(); + }); +}); diff --git a/packages/react/src/components/presentation/UserDropdown/BaseUserDropdown.tsx b/packages/react/src/components/presentation/UserDropdown/BaseUserDropdown.tsx index 90a5fe79..f0c5da75 100644 --- a/packages/react/src/components/presentation/UserDropdown/BaseUserDropdown.tsx +++ b/packages/react/src/components/presentation/UserDropdown/BaseUserDropdown.tsx @@ -62,6 +62,11 @@ export interface BaseUserDropdownProps { * Whether the user data is currently loading */ isLoading?: boolean; + /** + * Label for the "Manage Profile" menu item. Defaults to `'Manage Profile'`; set this + * to relabel the action (e.g. `'Manage Account'`) without replacing the whole item. + */ + manageProfileLabel?: string; /** * Menu items to display in the dropdown */ @@ -106,6 +111,7 @@ export const BaseUserDropdown: FC = ({ menuItems = [], showTriggerLabel = false, avatarSize = 32, + manageProfileLabel = 'Manage Profile', onManageProfile, onSignOut, attributeMapping = {}, @@ -159,7 +165,7 @@ export const BaseUserDropdown: FC = ({ if (onManageProfile) { defaultMenuItems.push({ icon: , - label: 'Manage Profile', + label: manageProfileLabel, onClick: onManageProfile, }); } diff --git a/packages/react/src/components/presentation/UserDropdown/UserDropdown.tsx b/packages/react/src/components/presentation/UserDropdown/UserDropdown.tsx index 1cfaafb4..ef874fdb 100644 --- a/packages/react/src/components/presentation/UserDropdown/UserDropdown.tsx +++ b/packages/react/src/components/presentation/UserDropdown/UserDropdown.tsx @@ -37,6 +37,18 @@ export type UserDropdownProps = Omit ReactNode; + /** + * Called instead of opening the built-in "Manage Profile" popup when the "Manage Profile" + * menu item (see `manageProfileLabel`) is clicked. Use this when the app has its own + * profile/account page it wants to navigate to instead, for example with a router's + * `navigate()`. + * + * @example + * ```tsx + * navigate('/account')} /> + * ``` + */ + onManageProfile?: () => void; /** * Custom render function for the dropdown content. * When provided, this replaces just the dropdown content while keeping the trigger. @@ -99,6 +111,7 @@ const UserDropdown: FC = ({ children, renderTrigger, renderDropdown, + onManageProfile: onManageProfileOverride, onSignOut, ...rest }: UserDropdownProps): ReactElement => { @@ -106,6 +119,10 @@ const UserDropdown: FC = ({ const [isProfileOpen, setIsProfileOpen] = useState(false); const handleManageProfile = (): void => { + if (onManageProfileOverride) { + onManageProfileOverride(); + return; + } setIsProfileOpen(true); }; @@ -136,7 +153,7 @@ const UserDropdown: FC = ({ return ( <> {children(renderProps)} - + {!onManageProfileOverride && } ); } @@ -159,7 +176,7 @@ const UserDropdown: FC = ({ /> )} {/* Note: renderDropdown would need BaseUserDropdown modifications to implement properly */} - + {!onManageProfileOverride && } ); } @@ -174,7 +191,9 @@ const UserDropdown: FC = ({ onSignOut={handleSignOut} {...rest} /> - {isProfileOpen && } + {!onManageProfileOverride && isProfileOpen && ( + + )} ); }; diff --git a/packages/react/src/components/presentation/UserProfile/BaseUserProfile.styles.ts b/packages/react/src/components/presentation/UserProfile/BaseUserProfile.styles.ts index 8d5549a2..913613a0 100644 --- a/packages/react/src/components/presentation/UserProfile/BaseUserProfile.styles.ts +++ b/packages/react/src/components/presentation/UserProfile/BaseUserProfile.styles.ts @@ -34,6 +34,7 @@ const useStyles = (theme: Theme, colorScheme: string): Record => display: flex; align-items: center; gap: ${theme.vars.spacing.unit}; + min-width: 0; `; const fieldActions: string = css` @@ -160,6 +161,7 @@ const useStyles = (theme: Theme, colorScheme: string): Record => align-items: center; gap: ${theme.vars.spacing.unit}; overflow: hidden; + min-width: 0; min-height: 28px; line-height: 28px; word-break: break-word; diff --git a/packages/react/src/components/primitives/PasswordField/PasswordField.tsx b/packages/react/src/components/primitives/PasswordField/PasswordField.tsx index 8a6e3122..1890ce3f 100644 --- a/packages/react/src/components/primitives/PasswordField/PasswordField.tsx +++ b/packages/react/src/components/primitives/PasswordField/PasswordField.tsx @@ -11,6 +11,12 @@ import EyeOff from '../Icons/EyeOff'; import TextField, {TextFieldProps} from '../TextField/TextField'; export interface PasswordFieldProps extends Omit { + /** + * The browser autofill hint. Defaults to `current-password`; set `new-password` on the + * fields of a change-password or sign-up form so password managers offer to generate and + * store a new credential instead of filling the existing one. + */ + autoComplete?: string; /** * Callback function when the field value changes */ @@ -22,6 +28,7 @@ export interface PasswordFieldProps extends Omit = ({ + autoComplete = 'current-password', onChange, className, disabled, @@ -46,7 +53,7 @@ const PasswordField: FC = ({ className={cx(withVendorCSSClassPrefix(bem('password-field')), className)} type={showPassword ? 'text' : 'password'} onChange={(e: ChangeEvent): void => onChange(e.target.value)} - autoComplete="current-password" + autoComplete={autoComplete} disabled={disabled} error={error} endIcon={ diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 41876211..1187c6cc 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -139,6 +139,12 @@ export * from './components/presentation/UserProfile/BaseUserProfile'; export {default as UserProfile} from './components/presentation/UserProfile/UserProfile'; export * from './components/presentation/UserProfile/UserProfile'; +export {default as BaseChangeCredential} from './components/presentation/ChangeCredential/BaseChangeCredential'; +export * from './components/presentation/ChangeCredential/BaseChangeCredential'; + +export {default as ChangeCredential} from './components/presentation/ChangeCredential/ChangeCredential'; +export * from './components/presentation/ChangeCredential/ChangeCredential'; + export {default as BaseUserAvatar} from './components/presentation/UserAvatar/BaseUserAvatar'; export * from './components/presentation/UserAvatar/BaseUserAvatar'; @@ -228,6 +234,8 @@ export {default as BuildingAlt} from './components/primitives/Icons/BuildingAlt' export {default as updateMeProfile} from './api/updateMeProfile'; export type {UpdateMeProfileConfig} from './api/updateMeProfile'; +export {default as updateMeCredentials} from './api/updateMeCredentials'; +export type {UpdateMeCredentialsConfig} from './api/updateMeCredentials'; export {default as getMeProfile} from './api/getUsersMe'; export * from './api/getUsersMe'; export {default as getUsersMeMeta} from './api/getUsersMeMeta'; diff --git a/packages/vue/src/__tests__/api/update-me-credentials.test.ts b/packages/vue/src/__tests__/api/update-me-credentials.test.ts new file mode 100644 index 00000000..e60db3a6 --- /dev/null +++ b/packages/vue/src/__tests__/api/update-me-credentials.test.ts @@ -0,0 +1,70 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {afterEach, describe, expect, it, vi} from 'vitest'; +import updateMeCredentials from '../../api/updateMeCredentials'; + +// The httpClient-to-Response adapter itself (status-code recovery, network-error passthrough) +// now lives in @thunderid/browser's createHttpClientFetcher and is covered there directly +// (packages/browser/src/utils/__tests__/createHttpClientFetcher.test.ts). This file exercises +// this wrapper's own job instead: plumbing that adapter through to the core updateMeCredentials +// as the default fetcher, and letting the core function's own error mapping run end to end. +const mockFetcher = vi.fn(); + +vi.mock('@thunderid/browser', async () => { + const actual = await vi.importActual('@thunderid/browser'); + return { + ...actual, + createHttpClientFetcher: () => mockFetcher, + }; +}); + +describe('updateMeCredentials (vue)', () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it('surfaces the real status code when the write is rejected', async () => { + // The core updateMeCredentials reads the error body via `.text()`, not `.json()`. + mockFetcher.mockResolvedValueOnce({ + ok: false, + status: 400, + statusText: 'Bad Request', + text: () => Promise.resolve(JSON.stringify({code: 'USR-1017', message: {defaultValue: 'Missing credentials'}})), + } as Response); + + await expect( + updateMeCredentials({ + payload: {password: 'n3wP@ssword!'}, + url: 'https://localhost:8090/users/me/update-credentials', + }), + ).rejects.toMatchObject({statusCode: 400}); + }); + + it('still throws a network error when the request never reaches the server', async () => { + mockFetcher.mockRejectedValueOnce(Object.assign(new Error('Failed to fetch'), {code: 'NETWORK_ERROR'})); + + await expect( + updateMeCredentials({ + payload: {password: 'n3wP@ssword!'}, + url: 'https://localhost:8090/users/me/update-credentials', + }), + ).rejects.toMatchObject({code: 'updateMeCredentials-NetworkError-001'}); + }); + + it('resolves on a successful update', async () => { + mockFetcher.mockResolvedValueOnce({ + json: () => Promise.resolve(undefined), + ok: true, + status: 204, + statusText: 'No Content', + } as Response); + + await expect( + updateMeCredentials({ + payload: {password: 'n3wP@ssword!'}, + url: 'https://localhost:8090/users/me/update-credentials', + }), + ).resolves.toBeUndefined(); + }); +}); diff --git a/packages/vue/src/__tests__/components/change-credential.test.ts b/packages/vue/src/__tests__/components/change-credential.test.ts new file mode 100644 index 00000000..5a284cab --- /dev/null +++ b/packages/vue/src/__tests__/components/change-credential.test.ts @@ -0,0 +1,302 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {getDefaultI18nBundles, substituteTranslationParams} from '@thunderid/browser'; +import {DOMWrapper, mount} from '@vue/test-utils'; +import {Mock, beforeEach, describe, expect, it, vi} from 'vitest'; +import {nextTick, ref} from 'vue'; +import ChangeCredential from '../../components/presentation/change-credential/ChangeCredential'; +import {I18N_KEY, THUNDERID_KEY, USER_KEY} from '../../keys'; +import type {I18nContextValue, ThunderIDContext, UserContextValue} from '../../models/contexts'; + +const mockUpdateMeCredentials = vi.fn() as Mock; + +vi.mock('../../api/updateMeCredentials', () => ({ + default: (...args: unknown[]): unknown => mockUpdateMeCredentials(...args) as unknown, +})); + +const createThunderIDContext = (): ThunderIDContext => + ({ + baseUrl: 'https://localhost:8090', + instanceId: 0, + isInitialized: ref(true), + isLoading: ref(false), + isSignedIn: ref(true), + user: ref(null), + vendor: 'thunderid', + }) as unknown as ThunderIDContext; + +const createUserContext = (userSchema: Record | null = null): UserContextValue => + ({ + flattenedProfile: ref(null), + onUpdateProfile: vi.fn(), + profile: ref(null), + revalidateProfile: vi.fn(), + updateProfile: vi.fn(), + userSchema: ref(userSchema), + }) as unknown as UserContextValue; + +/** + * Minimal i18n context. Resolves keys through the real en-US bundle so the tests assert on the + * strings a consumer actually sees rather than on raw keys. + */ +const createI18nContext = (): I18nContextValue => + ({ + bundles: ref({}), + currentLanguage: ref('en-US'), + fallbackLanguage: 'en-US', + injectBundles: vi.fn(), + setLanguage: vi.fn(), + t: (key: string, params?: Record): string => { + const translations = getDefaultI18nBundles()['en-US']?.translations as Record; + const value: string = translations?.[key] ?? key; + + return params ? substituteTranslationParams(value, params) : value; + }, + }) as unknown as I18nContextValue; + +const mountChangeCredential = ( + props: Record = {}, + userSchema: Record | null = null, +) => + mount(ChangeCredential, { + global: { + provide: { + [I18N_KEY as symbol]: createI18nContext(), + [THUNDERID_KEY as symbol]: createThunderIDContext(), + [USER_KEY as symbol]: createUserContext(userSchema), + }, + }, + props, + }); + +const inputByName = (wrapper: ReturnType, name: string): DOMWrapper => + wrapper.find(`input[name="${name}"]`); + +const fill = async ( + wrapper: ReturnType, + values: Record, +): Promise => { + for (const [name, value] of Object.entries(values)) { + const field: DOMWrapper = inputByName(wrapper, name); + await field.setValue(value); + } +}; + +describe('ChangeCredential', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders only the two fields, with no current-value field', () => { + const wrapper = mountChangeCredential(); + + expect(inputByName(wrapper, 'newPassword').exists()).toBe(true); + expect(inputByName(wrapper, 'confirmPassword').exists()).toBe(true); + expect(wrapper.find('input[name="currentPassword"]').exists()).toBe(false); + }); + + it('marks both fields as new-password for password managers', () => { + const wrapper = mountChangeCredential(); + + expect(inputByName(wrapper, 'newPassword').attributes('autocomplete')).toBe('new-password'); + expect(inputByName(wrapper, 'confirmPassword').attributes('autocomplete')).toBe('new-password'); + }); + + it('updates the requirement checklist as the user types', async () => { + const wrapper = mountChangeCredential({policy: {regex: '^(?=.*\\d).{8,}$'}}); + + const isPassed = (): string | undefined => wrapper.find('li[data-passed]').attributes('data-passed'); + + expect(isPassed()).toBe('false'); + + await fill(wrapper, {newPassword: 'longenough'}); + expect(isPassed()).toBe('false'); + + await fill(wrapper, {newPassword: 'longenough1'}); + expect(isPassed()).toBe('true'); + }); + + it('keeps submit disabled until every rule passes and the confirmation matches', async () => { + const wrapper = mountChangeCredential({policy: {regex: '^.{8,}$'}}); + const submit = (): DOMWrapper => wrapper.find('button[type="submit"]'); + + expect(submit().attributes('disabled')).toBeDefined(); + + await fill(wrapper, {confirmPassword: 'sh0rt', newPassword: 'sh0rt'}); + expect(submit().attributes('disabled')).toBeDefined(); + + await fill(wrapper, {confirmPassword: 'longenough1', newPassword: 'longenough1'}); + expect(submit().attributes('disabled')).toBeUndefined(); + }); + + it('sends only the new value, keyed by password by default, and emits success', async () => { + mockUpdateMeCredentials.mockResolvedValueOnce(undefined); + const wrapper = mountChangeCredential({policy: {regex: '^.{8,}$'}}); + + await fill(wrapper, {confirmPassword: 'n3wP@ssword', newPassword: 'n3wP@ssword'}); + await wrapper.find('form').trigger('submit'); + await nextTick(); + + expect(mockUpdateMeCredentials).toHaveBeenCalledTimes(1); + expect(mockUpdateMeCredentials).toHaveBeenCalledWith( + expect.objectContaining({ + baseUrl: 'https://localhost:8090', + payload: {password: 'n3wP@ssword'}, + }), + ); + expect(wrapper.emitted('success')).toBeTruthy(); + }); + + it('keys the payload by attribute when set to something other than password', async () => { + mockUpdateMeCredentials.mockResolvedValueOnce(undefined); + const wrapper = mountChangeCredential({attribute: 'pin', policy: {regex: '^.{4,}$'}}); + + await fill(wrapper, {confirmPassword: '1234', newPassword: '1234'}); + await wrapper.find('form').trigger('submit'); + await nextTick(); + + expect(mockUpdateMeCredentials).toHaveBeenCalledWith( + expect.objectContaining({ + payload: {pin: '1234'}, + }), + ); + }); + + it('clears the entered passwords after a successful change', async () => { + mockUpdateMeCredentials.mockResolvedValueOnce(undefined); + const wrapper = mountChangeCredential({policy: {regex: '^.{8,}$'}}); + + await fill(wrapper, {confirmPassword: 'n3wP@ssword', newPassword: 'n3wP@ssword'}); + await wrapper.find('form').trigger('submit'); + await nextTick(); + await nextTick(); + + expect(inputByName(wrapper, 'newPassword').element.value).toBe(''); + expect(inputByName(wrapper, 'confirmPassword').element.value).toBe(''); + }); + + it('surfaces a 400 onto the new password field', async () => { + const {ThunderIDAPIError} = await import('@thunderid/browser'); + + mockUpdateMeCredentials.mockRejectedValueOnce( + new ThunderIDAPIError('Password is too common', 'x-001', 'vue', 400, 'Bad Request'), + ); + const wrapper = mountChangeCredential({policy: {regex: '^.{8,}$'}}); + + await fill(wrapper, {confirmPassword: 'n3wP@ssword', newPassword: 'n3wP@ssword'}); + await wrapper.find('form').trigger('submit'); + await nextTick(); + await nextTick(); + + expect(wrapper.text()).toMatch(/password is too common/i); + }); + + it('derives the policy regex from the user schema', async () => { + const wrapper = mountChangeCredential({}, {password: {credential: true, regex: '^[a-z]+$'}}); + + await fill(wrapper, {newPassword: 'Str0ng!Pass'}); + + // The schema regex is the whole policy; no SDK-side rules are layered alongside it. + const items = wrapper.findAll('li[data-passed]'); + expect(items.length).toBe(1); + expect(items[0].attributes('data-passed')).toBe('false'); + }); + + it('derives the policy from a non-password credential attribute', async () => { + const wrapper = mountChangeCredential({attribute: 'pin'}, {pin: {credential: true, regex: '^\\d{4}$'}}); + + await fill(wrapper, {newPassword: '12'}); + + const items = wrapper.findAll('li[data-passed]'); + expect(items.length).toBe(1); + expect(items[0].attributes('data-passed')).toBe('false'); + }); + + it('applies no client-side rules when the schema has no password regex', async () => { + const wrapper = mountChangeCredential({}, {password: {credential: true}}); + + await fill(wrapper, {newPassword: 'x'}); + + expect(wrapper.findAll('li[data-passed]').length).toBe(0); + }); + + describe('when the schema defines no password attribute', () => { + const schemaWithoutPassword = {email: {type: 'string'}, pin: {credential: true, type: 'string'}}; + + it('explains why the form is unusable instead of rendering nothing', () => { + const wrapper = mountChangeCredential({}, schemaWithoutPassword); + + expect(wrapper.find('[role="status"]').exists()).toBe(true); + expect(wrapper.text()).toContain('Password changes unavailable'); + }); + + it('still renders the form so the overlay has something to sit on', () => { + const wrapper = mountChangeCredential({}, schemaWithoutPassword); + + expect(inputByName(wrapper, 'newPassword').exists()).toBe(true); + expect(wrapper.find('[aria-hidden="true"]').exists()).toBe(true); + }); + + it('disables every control so nothing is reachable behind the overlay', () => { + const wrapper = mountChangeCredential({}, schemaWithoutPassword); + + expect(inputByName(wrapper, 'newPassword').attributes('disabled')).toBeDefined(); + expect(inputByName(wrapper, 'confirmPassword').attributes('disabled')).toBeDefined(); + expect(wrapper.find('button[type="submit"]').attributes('disabled')).toBeDefined(); + }); + + it('never writes credentials even if a submit is forced through', async () => { + const wrapper = mountChangeCredential({}, schemaWithoutPassword); + + await wrapper.find('form').trigger('submit'); + + expect(mockUpdateMeCredentials).not.toHaveBeenCalled(); + }); + + it('renders the usable form when the schema does define a password', () => { + const wrapper = mountChangeCredential({}, {password: {credential: true}}); + + expect(wrapper.find('[role="status"]').exists()).toBe(false); + expect(inputByName(wrapper, 'newPassword').attributes('disabled')).toBeUndefined(); + }); + + it('is available when attribute points at a declared non-password credential', () => { + const wrapper = mountChangeCredential({attribute: 'pin'}, schemaWithoutPassword); + + expect(wrapper.find('[role="status"]').exists()).toBe(false); + expect(inputByName(wrapper, 'newPassword').attributes('disabled')).toBeUndefined(); + }); + + it('shows a generic contact-administrator message rather than naming the credential', () => { + const wrapper = mountChangeCredential({}, schemaWithoutPassword); + + expect(wrapper.text()).toMatch(/contact your administrator/i); + }); + }); + + it('takes the display name from the schema rather than title-casing the attribute', () => { + const wrapper = mountChangeCredential({attribute: 'pin'}, {pin: {credential: true, displayName: 'Security PIN'}}); + + expect(wrapper.text()).toContain('Change Security PIN'); + }); + + it('falls back to title-casing the attribute when the schema has no displayName', () => { + const wrapper = mountChangeCredential({attribute: 'pin'}, {pin: {credential: true}}); + + expect(wrapper.text()).toContain('Change Pin'); + }); + + it('overrides the default heading when title is set', () => { + const wrapper = mountChangeCredential({title: 'Update your PIN'}); + + expect(wrapper.text()).toContain('Update your PIN'); + expect(wrapper.text()).not.toContain('Change Password'); + }); + + it('omits the heading entirely when title is an empty string', () => { + const wrapper = mountChangeCredential({title: ''}); + + expect(wrapper.text()).not.toContain('Change Password'); + }); +}); diff --git a/packages/vue/src/api/updateMeCredentials.ts b/packages/vue/src/api/updateMeCredentials.ts new file mode 100644 index 00000000..61339cb7 --- /dev/null +++ b/packages/vue/src/api/updateMeCredentials.ts @@ -0,0 +1,26 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import { + createHttpClientFetcher, + UpdateMeCredentialsConfig as BaseUpdateMeCredentialsConfig, + updateMeCredentials as baseUpdateMeCredentials, +} from '@thunderid/browser'; + +export interface UpdateMeCredentialsConfig extends Omit { + fetcher?: (url: string, config: RequestInit) => Promise; + instanceId?: number; +} + +const updateMeCredentials = async ({ + fetcher, + instanceId = 0, + ...requestConfig +}: UpdateMeCredentialsConfig): Promise => { + return baseUpdateMeCredentials({ + ...requestConfig, + fetcher: fetcher || createHttpClientFetcher(instanceId), + }); +}; + +export default updateMeCredentials; diff --git a/packages/vue/src/components/presentation/change-credential/BaseChangeCredential.ts b/packages/vue/src/components/presentation/change-credential/BaseChangeCredential.ts new file mode 100644 index 00000000..b08de20b --- /dev/null +++ b/packages/vue/src/components/presentation/change-credential/BaseChangeCredential.ts @@ -0,0 +1,314 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import { + ChangePasswordFormEvaluation, + PasswordPolicy, + PasswordRuleResult, + Preferences, + evaluateChangePasswordForm, + withVendorCSSClassPrefix, +} from '@thunderid/browser'; +import { + type Component, + type ComputedRef, + type PropType, + type Ref, + type SetupContext, + type VNode, + computed, + defineComponent, + h, + ref, + watch, +} from 'vue'; +import useI18n from '../../../composables/useI18n'; +import Alert from '../../primitives/Alert/Alert'; +import Button from '../../primitives/Button/Button'; +import {CheckIcon, XIcon} from '../../primitives/Icons'; +import PasswordField from '../../primitives/PasswordField/PasswordField'; + +/** + * The values collected by the form and emitted on `submit`. + * + * There is deliberately no current-value field here. The self-service credential write + * endpoint does not verify the account's existing value today, so a field the server + * ignores would only teach the user a false sense of security. + */ +export interface ChangePasswordValues { + newPassword: string; +} + +type BaseChangeCredentialProps = Readonly<{ + cardLayout: boolean; + className: string; + credentialDisplayName: string; + error: string | null; + fieldErrors: Record; + loading: boolean; + policy: PasswordPolicy; + preferences?: Preferences; + showRequirements: boolean; + success: boolean; + t?: (key: string, params?: Record) => string; + title?: string; + unavailable: boolean; +}>; + +const ruleIcon = (passed: boolean): VNode => (passed ? CheckIcon() : XIcon()); + +/** + * Presentational change-credential form with just the two fields a first-time or + * unverified credential change needs: new value and confirmation. + * + * Holds no context and performs no network calls: it renders the fields, evaluates the + * policy for the checklist and the submit gate, and emits `submit` with the validated + * values. Use `ChangeCredential` for the context-wired variant. + */ +const BaseChangeCredential: Component = defineComponent({ + name: 'BaseChangeCredential', + props: { + /** Whether to wrap the form in a bordered card. */ + cardLayout: {default: false, type: Boolean}, + /** Extra CSS class added to the root element. */ + className: {default: '', type: String}, + /** + * The human-readable name of the credential, substituted into every default label, + * placeholder and message (for example "Change {credential}", "New {credential}"). + * Defaults to `Password`. `ChangeCredential` resolves this from the attribute's own + * `displayName` in the user schema; set it directly here when using this presentational + * component without that context. + */ + credentialDisplayName: {default: 'Password', type: String}, + /** A form-level error, typically a server failure that maps to no single field. */ + error: {default: null, type: String as PropType}, + /** Server-supplied errors keyed by field name (`newPassword`). */ + fieldErrors: {default: () => ({}), type: Object as PropType>}, + /** Whether a submission is in flight. */ + loading: {default: false, type: Boolean}, + /** + * The rules the new value must satisfy. Defaults to no rules, in which case the + * checklist is empty and the only submit gate is a non-empty value; the caller is + * expected to source this from the user type schema (see `ChangeCredential`) or supply + * its own. + */ + policy: {default: () => ({}), type: Object as PropType}, + /** Component-level preferences to override global preferences. */ + preferences: {default: undefined, type: Object as PropType}, + /** Whether to render the live requirement checklist. */ + showRequirements: {default: true, type: Boolean}, + /** Whether the last submission succeeded. */ + success: {default: false, type: Boolean}, + /** Translation function, injected by the container so both variants resolve the same bundle. */ + t: { + default: undefined, + type: Function as PropType<(key: string, params?: Record) => string>, + }, + /** + * Overrides the default "Change {credential}" heading. Pass an empty string to omit the + * heading entirely, for example when an app's own surrounding layout (a card header, a + * dialog title) already names the credential and a second heading would be redundant. + * Defaults to the translated `user.change_password.heading` string. + */ + title: {default: undefined, type: String}, + /** + * Whether the account cannot have this credential changed at all, because the user + * type's schema defines no attribute of this name. The form is rendered inert behind an + * explanatory message rather than hidden. + */ + unavailable: {default: false, type: Boolean}, + }, + emits: ['submit'], + setup(props: BaseChangeCredentialProps, {emit}: SetupContext): () => VNode { + const {t: fallbackT} = useI18n(); + // Substituted into every default label, placeholder and message. A caller-supplied + // translation that carries no `{credential}`/`{credentialLower}` placeholder is unaffected. + const labelParams: ComputedRef> = computed(() => ({ + credential: props.credentialDisplayName, + credentialLower: props.credentialDisplayName.toLowerCase(), + })); + const translate = (key: string, params?: Record): string => + (props.t ?? fallbackT)(key, {...labelParams.value, ...params}); + // `title` overrides the default heading; an explicit empty string omits it entirely. + const resolvedTitle: ComputedRef = computed(() => props.title ?? translate('user.change_password.heading')); + + const newPassword: Ref = ref(''); + const confirmPassword: Ref = ref(''); + const submitted: Ref = ref(false); + + // Clear the entered values once the container reports the write succeeded, so a shared + // machine is not left with the new credential sitting in the form. + watch( + () => props.success, + (succeeded: boolean): void => { + if (!succeeded) return; + + newPassword.value = ''; + confirmPassword.value = ''; + submitted.value = false; + }, + ); + + const evaluation: ComputedRef = computed(() => + evaluateChangePasswordForm( + { + confirmPassword: confirmPassword.value, + newPassword: newPassword.value, + }, + props.policy, + ), + ); + const ruleResults: ComputedRef = computed(() => evaluation.value.ruleResults); + const confirmMatches: ComputedRef = computed(() => evaluation.value.confirmMatches); + // A schema with no attribute of this name makes every control pointless, so they are + // disabled outright rather than left focusable behind the overlay. + const interactionDisabled: ComputedRef = computed(() => props.loading || props.unavailable); + const canSubmit: ComputedRef = computed(() => !interactionDisabled.value && evaluation.value.isValid); + + function handleSubmit(event: Event): void { + event.preventDefault(); + submitted.value = true; + + if (!canSubmit.value) return; + + emit('submit', {newPassword: newPassword.value}); + } + + return (): VNode => { + const rootClass: string = [ + withVendorCSSClassPrefix('change-credential'), + props.cardLayout ? withVendorCSSClassPrefix('change-credential--card') : '', + props.className, + ] + .filter(Boolean) + .join(' '); + + // Only surface the local error once the user has attempted a submit, so the form does + // not flag fields the user has not finished filling in. + const confirmError: string | undefined = + submitted.value && !confirmMatches.value ? translate('user.change_password.mismatch.error') : undefined; + const newPasswordError: string | undefined = props.fieldErrors['newPassword']; + + const form: VNode = h('form', {class: rootClass, novalidate: true, onSubmit: handleSubmit}, [ + resolvedTitle.value + ? h('h3', {class: withVendorCSSClassPrefix('change-credential__heading')}, resolvedTitle.value) + : null, + + props.error ? h(Alert, {severity: 'error'}, {default: (): (VNode | string)[] => [props.error!]}) : null, + + props.success + ? h( + Alert, + {severity: 'success'}, + {default: (): (VNode | string)[] => [translate('user.change_password.success')]}, + ) + : null, + + h('div', {class: withVendorCSSClassPrefix('change-credential__fields')}, [ + h(PasswordField, { + autocomplete: 'new-password', + disabled: interactionDisabled.value, + error: newPasswordError, + label: translate('user.change_password.new.label'), + modelValue: newPassword.value, + name: 'newPassword', + 'onUpdate:modelValue': (value: string): void => { + newPassword.value = value; + }, + placeholder: translate('user.change_password.new.placeholder'), + required: true, + }), + + props.showRequirements && ruleResults.value.length > 0 + ? h('div', {}, [ + h( + 'p', + {class: withVendorCSSClassPrefix('change-credential__requirements-heading')}, + translate('user.change_password.requirements.heading'), + ), + h( + 'ul', + {class: withVendorCSSClassPrefix('change-credential__requirements')}, + ruleResults.value.map((rule: PasswordRuleResult) => + h( + 'li', + { + class: [ + withVendorCSSClassPrefix('change-credential__requirement'), + rule.passed ? withVendorCSSClassPrefix('change-credential__requirement--passed') : '', + ] + .filter(Boolean) + .join(' '), + 'data-passed': String(rule.passed), + key: rule.key, + }, + [ + h('span', {class: withVendorCSSClassPrefix('change-credential__requirement-icon')}, [ + ruleIcon(rule.passed), + ]), + h('span', {}, translate(rule.messageKey, rule.params)), + ], + ), + ), + ), + ]) + : null, + + h(PasswordField, { + autocomplete: 'new-password', + disabled: interactionDisabled.value, + error: confirmError, + label: translate('user.change_password.confirm.label'), + modelValue: confirmPassword.value, + name: 'confirmPassword', + 'onUpdate:modelValue': (value: string): void => { + confirmPassword.value = value; + }, + placeholder: translate('user.change_password.confirm.placeholder'), + required: true, + }), + ]), + + h('div', {class: withVendorCSSClassPrefix('change-credential__actions')}, [ + h( + Button, + { + color: 'primary', + disabled: !canSubmit.value, + loading: props.loading, + type: 'submit', + variant: 'solid', + }, + { + default: (): (VNode | string)[] => [translate('user.change_password.submit')], + }, + ), + ]), + ]); + + if (!props.unavailable) { + return form; + } + + return h('div', {class: withVendorCSSClassPrefix('change-credential__unavailable')}, [ + h('div', {'aria-hidden': 'true', class: withVendorCSSClassPrefix('change-credential__unavailable-content')}, [ + form, + ]), + h('div', {class: withVendorCSSClassPrefix('change-credential__unavailable-overlay'), role: 'status'}, [ + h( + Alert, + {severity: 'warning'}, + { + default: (): (VNode | string)[] => [ + h('strong', translate('user.change_password.unavailable.heading')), + h('div', translate('user.change_password.unavailable.description')), + ], + }, + ), + ]), + ]); + }; + }, +}); + +export default BaseChangeCredential; diff --git a/packages/vue/src/components/presentation/change-credential/ChangeCredential.css.ts b/packages/vue/src/components/presentation/change-credential/ChangeCredential.css.ts new file mode 100644 index 00000000..3d9108b6 --- /dev/null +++ b/packages/vue/src/components/presentation/change-credential/ChangeCredential.css.ts @@ -0,0 +1,107 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +/** + * Styles for the ChangeCredential presentation component. + * Parity target: `@thunderid/react` BaseChangeCredential.styles.ts + */ +const CHANGE_CREDENTIAL_CSS = ` +/* ============================================================ + ChangeCredential (React Parity) + ============================================================ */ + +.thunderid-change-credential { + display: flex; + flex-direction: column; + gap: calc(var(--thunderid-spacing-unit) * 2); + width: 100%; + box-sizing: border-box; + font-family: var(--thunderid-typography-fontFamily); +} + +.thunderid-change-credential--card { + padding: calc(var(--thunderid-spacing-unit) * 3); + border: 1px solid var(--thunderid-color-border); + border-radius: var(--thunderid-border-radius-large, 8px); + background: var(--thunderid-color-background-surface); +} + +.thunderid-change-credential__heading { + margin: 0; + font-size: 1.125rem; + font-weight: 600; +} + +.thunderid-change-credential__fields { + display: flex; + flex-direction: column; + gap: calc(var(--thunderid-spacing-unit) * 2); +} + +.thunderid-change-credential__requirements-heading { + margin: 0 0 calc(var(--thunderid-spacing-unit) / 2) 0; + font-size: 0.8125rem; + opacity: 0.8; +} + +.thunderid-change-credential__requirements { + display: flex; + flex-direction: column; + gap: calc(var(--thunderid-spacing-unit) / 2); + margin: 0; + padding: 0; + list-style: none; +} + +.thunderid-change-credential__requirement { + display: flex; + align-items: center; + gap: var(--thunderid-spacing-unit); + font-size: 0.8125rem; + opacity: 0.7; +} + +.thunderid-change-credential__requirement--passed { + color: var(--thunderid-color-success-main); + opacity: 1; +} + +.thunderid-change-credential__requirement-icon { + display: inline-flex; + flex-shrink: 0; + width: 14px; + height: 14px; +} + +.thunderid-change-credential__actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: var(--thunderid-spacing-unit); +} + +.thunderid-change-credential__unavailable { + position: relative; + display: flex; + width: 100%; +} + +.thunderid-change-credential__unavailable-content { + width: 100%; + filter: blur(3px); + opacity: 0.55; + pointer-events: none; + user-select: none; +} + +.thunderid-change-credential__unavailable-overlay { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + padding: calc(var(--thunderid-spacing-unit) * 2); +} +`; + +export default CHANGE_CREDENTIAL_CSS; diff --git a/packages/vue/src/components/presentation/change-credential/ChangeCredential.ts b/packages/vue/src/components/presentation/change-credential/ChangeCredential.ts new file mode 100644 index 00000000..e3df9646 --- /dev/null +++ b/packages/vue/src/components/presentation/change-credential/ChangeCredential.ts @@ -0,0 +1,173 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import { + CredentialConstants, + CredentialUpdateErrorResult, + PasswordPolicy, + Preferences, + mapCredentialUpdateError, + resolveChangeCredentialPolicy, + resolveResourceEndpoint, + supportsCredential, + withVendorCSSClassPrefix, +} from '@thunderid/browser'; +import { + type Component, + type ComputedRef, + type PropType, + type Ref, + type SetupContext, + type VNode, + computed, + defineComponent, + h, + ref, +} from 'vue'; +import BaseChangeCredential, {type ChangePasswordValues} from './BaseChangeCredential'; +import updateMeCredentials from '../../../api/updateMeCredentials'; +import useI18n from '../../../composables/useI18n'; +import useThunderID from '../../../composables/useThunderID'; +import useUser from '../../../composables/useUser'; + +/** + * Title-cases a credential attribute name for use as a display-name fallback when the schema + * declares no `displayName` for it, e.g. `pin` -> `Pin`. + */ +const defaultDisplayName = (attribute: string): string => attribute.charAt(0).toUpperCase() + attribute.slice(1); + +type ChangeCredentialProps = Readonly<{ + attribute: string; + cardLayout: boolean; + className: string; + policy?: PasswordPolicy; + preferences?: Preferences; + showRequirements: boolean; + title?: string; +}>; + +/** + * ChangeCredential lets the signed-in user set a new value for one of their own credentials. + * + * It collects only a new value and its confirmation. The self-service credential write path + * does not verify the account's existing value today, so this component does not ask for one; + * once server-side current-value verification ships, that field returns without a breaking + * change to this component's public props. + */ +const ChangeCredential: Component = defineComponent({ + name: 'ChangeCredential', + props: { + /** + * The credential attribute this instance manages, any attribute the user's entity type + * schema declares `credential: true` (for example `password` or `pin`). Defaults to + * `password`. Render the component once per credential to let a user manage more than + * one, for example `` for the password and + * `` for a PIN. Every default label, placeholder and + * message is built from the attribute's own `displayName` in the schema (`GET + * /users/me/meta`); when the schema carries none, it falls back to the attribute name + * title-cased (`pin` -> `Pin`). + */ + attribute: {default: CredentialConstants.PASSWORD, type: String}, + /** Whether to wrap the form in a bordered card. */ + cardLayout: {default: false, type: Boolean}, + /** Extra CSS class added to the root element. */ + className: {default: '', type: String}, + /** Explicit rules. When omitted, they are derived from the user schema. */ + policy: {default: undefined, type: Object as PropType}, + /** Component-level preferences to override global preferences. */ + preferences: {default: undefined, type: Object as PropType}, + /** Whether to render the live requirement checklist. */ + showRequirements: {default: true, type: Boolean}, + /** + * Overrides the default "Change {credential}" heading. Pass an empty string to omit the + * heading entirely, for example when an app's own surrounding layout (a card header, a + * dialog title) already names the credential and a second heading would be redundant. + */ + title: {default: undefined, type: String}, + }, + emits: ['success'], + setup(props: ChangeCredentialProps, {emit}: SetupContext): () => VNode { + const {baseUrl, endpoints, instanceId, preferences: contextPreferences} = useThunderID(); + const {userSchema} = useUser(); + const {t} = useI18n(); + const resolvedDisplayName: ComputedRef = computed( + () => userSchema?.value?.[props.attribute]?.displayName ?? defaultDisplayName(props.attribute), + ); + + const resolvedPreferences = computed(() => ({ + ...contextPreferences, + ...props.preferences, + user: { + ...contextPreferences?.user, + ...props.preferences?.user, + }, + })); + + const error: Ref = ref(null); + const fieldErrors: Ref> = ref>({}); + const loading: Ref = ref(false); + const success: Ref = ref(false); + + const resolvedPolicy: ComputedRef = computed(() => + resolveChangeCredentialPolicy(userSchema?.value, props.attribute, props.policy), + ); + + async function handleSubmit({newPassword}: ChangePasswordValues): Promise { + error.value = null; + fieldErrors.value = {}; + success.value = false; + loading.value = true; + + try { + await updateMeCredentials({ + baseUrl, + instanceId, + payload: { + [props.attribute]: newPassword, + }, + url: resolveResourceEndpoint('usersMeCredentials', {endpoints}), + }); + + success.value = true; + emit('success'); + } catch (caughtError: unknown) { + const {field, message, messageKey}: CredentialUpdateErrorResult = mapCredentialUpdateError(caughtError); + const text: string = + message ?? + t(messageKey, { + credential: resolvedDisplayName.value, + credentialLower: resolvedDisplayName.value.toLowerCase(), + }); + + if (field) { + fieldErrors.value = {[field]: text}; + } else { + error.value = text; + } + } finally { + loading.value = false; + } + } + + return (): VNode => + h(BaseChangeCredential, { + cardLayout: props.cardLayout, + class: withVendorCSSClassPrefix('change-credential--styled'), + className: props.className, + credentialDisplayName: resolvedDisplayName.value, + error: error.value, + fieldErrors: fieldErrors.value, + loading: loading.value, + onSubmit: handleSubmit, + policy: resolvedPolicy.value, + preferences: resolvedPreferences.value, + showRequirements: props.showRequirements, + success: success.value, + t, + title: props.title, + unavailable: !supportsCredential(userSchema?.value, props.attribute), + }); + }, +}); + +export default ChangeCredential; diff --git a/packages/vue/src/components/presentation/user-dropdown/BaseUserDropdown.ts b/packages/vue/src/components/presentation/user-dropdown/BaseUserDropdown.ts index d9a92d1c..aa7fa1a3 100644 --- a/packages/vue/src/components/presentation/user-dropdown/BaseUserDropdown.ts +++ b/packages/vue/src/components/presentation/user-dropdown/BaseUserDropdown.ts @@ -55,8 +55,11 @@ export interface BaseUserDropdownProps { onProfileClick?: () => void; onProfileModalClose?: () => void; onSignOut?: () => void; + /** Label for the default profile menu item. Defaults to `'Profile'`. */ + profileLabel?: string; profileContent?: VNode | null; showChevron?: boolean; + showTriggerLabel?: boolean; size?: 'sm' | 'md' | 'lg'; user?: User | null; } @@ -157,8 +160,12 @@ const BaseUserDropdown: Component = defineComponent({ onProfileModalClose: {default: undefined, type: Function as PropType<() => void>}, onSignOut: {default: undefined, type: Function as PropType<() => void>}, profileContent: {default: null, type: Object as PropType}, + /** Label for the default profile menu item. Defaults to `'Profile'`. */ + profileLabel: {default: 'Profile', type: String}, /** Show the animated chevron on the trigger. Default `false`. */ showChevron: {default: false, type: Boolean}, + /** Show the user's display name as text beside the trigger avatar. Default `false`. */ + showTriggerLabel: {default: false, type: Boolean}, /** Controls avatar size on the trigger and spacing density of the menu. */ size: {default: 'md', type: String as PropType<'sm' | 'md' | 'lg'>}, user: {default: null, type: Object as PropType}, @@ -308,6 +315,7 @@ const BaseUserDropdown: Component = defineComponent({ }, initials, ), + props.showTriggerLabel ? h('span', {class: px('user-dropdown__trigger-label')}, displayName) : null, props.showChevron ? h('span', {class: px('user-dropdown__chevron')}, [h(ChevronDownIcon, {size: 14})]) : null, ], ); @@ -357,7 +365,7 @@ const BaseUserDropdown: Component = defineComponent({ }, type: 'button', }, - [h(UserIcon, {size: 15}), h('span', null, 'Profile')], + [h(UserIcon, {size: 15}), h('span', null, props.profileLabel ?? 'Profile')], ), ); } diff --git a/packages/vue/src/components/presentation/user-dropdown/UserDropdown.css.ts b/packages/vue/src/components/presentation/user-dropdown/UserDropdown.css.ts index 31e77825..e6f814bf 100644 --- a/packages/vue/src/components/presentation/user-dropdown/UserDropdown.css.ts +++ b/packages/vue/src/components/presentation/user-dropdown/UserDropdown.css.ts @@ -19,6 +19,7 @@ * __item--danger – destructive action (red text/hover) * * Elements: + * __trigger-label – display name beside the trigger avatar (showTriggerLabel) * __chevron – rotates 180° when menu is open * __menu-header – user identity section at top of menu * __menu-header-avatar – gradient avatar circle in header @@ -66,6 +67,17 @@ const USER_DROPDOWN_CSS = ` box-shadow: 0 0 0 3px var(--thunderid-focus-ring-color); } +.thunderid-user-dropdown__trigger-label { + color: var(--thunderid-color-text-primary); + font-size: 0.875rem; + font-weight: 500; + max-width: 150px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + padding-right: calc(var(--thunderid-spacing-unit) * 0.5); +} + .thunderid-user-dropdown__trigger:focus-visible { border-color: var(--thunderid-color-primary-main); box-shadow: 0 0 0 var(--thunderid-focus-ring-width) var(--thunderid-focus-ring-color); diff --git a/packages/vue/src/components/presentation/user-dropdown/UserDropdown.ts b/packages/vue/src/components/presentation/user-dropdown/UserDropdown.ts index c9c63e28..9cb4d355 100644 --- a/packages/vue/src/components/presentation/user-dropdown/UserDropdown.ts +++ b/packages/vue/src/components/presentation/user-dropdown/UserDropdown.ts @@ -55,6 +55,22 @@ const UserDropdown: Component = defineComponent({ default: undefined, type: Array as PropType, }, + /** + * Called instead of opening the built-in profile popup when the profile menu item is + * clicked. Use this when the app has its own account page to navigate to. + * + * @example + * ```vue + * + * ``` + */ + onManageProfile: {default: undefined, type: Function as PropType<() => void>}, + /** + * Label for the "Profile" menu item. Defaults to `'Profile'`; set this alongside + * `onManageProfile` when redirecting to a custom account page instead of the built-in + * profile popup. + */ + profileLabel: {default: undefined, type: String}, /** Whether to show the animated down-chevron beside the avatar. Default `false`. */ showChevron: {default: false, type: Boolean}, /** @@ -67,6 +83,8 @@ const UserDropdown: Component = defineComponent({ default: 'md', type: String as PropType<'sm' | 'md' | 'lg'>, }, + /** Show the user's display name as text beside the trigger avatar. Default `false`. */ + showTriggerLabel: {default: false, type: Boolean}, }, emits: ['profileClick'], setup( @@ -74,7 +92,10 @@ const UserDropdown: Component = defineComponent({ className: string; menuAlign: 'auto' | 'left' | 'right'; menuItems?: DropdownMenuItem[]; + onManageProfile?: () => void; + profileLabel?: string; showChevron: boolean; + showTriggerLabel: boolean; size: 'sm' | 'md' | 'lg'; }, {slots, emit}: {emit: any; slots: any}, @@ -92,6 +113,10 @@ const UserDropdown: Component = defineComponent({ menuAlign: props.menuAlign, menuItems: props.menuItems, onProfileClick: (): void => { + if (props.onManageProfile) { + props.onManageProfile(); + return; + } isProfileModalOpen.value = true; emit('profileClick'); }, @@ -101,14 +126,17 @@ const UserDropdown: Component = defineComponent({ onSignOut: (): void => { signOut(); }, - profileContent: isProfileModalOpen.value - ? h(UserProfileComponent, { - cardLayout: false, - compact: true, - editable: true, - }) - : null, + profileContent: + !props.onManageProfile && isProfileModalOpen.value + ? h(UserProfileComponent, { + cardLayout: false, + compact: true, + editable: true, + }) + : null, + profileLabel: props.profileLabel, showChevron: props.showChevron, + showTriggerLabel: props.showTriggerLabel, size: props.size, user: user.value, }, diff --git a/packages/vue/src/components/presentation/user-profile/UserProfile.css.ts b/packages/vue/src/components/presentation/user-profile/UserProfile.css.ts index 8889cf70..872a9127 100644 --- a/packages/vue/src/components/presentation/user-profile/UserProfile.css.ts +++ b/packages/vue/src/components/presentation/user-profile/UserProfile.css.ts @@ -14,9 +14,7 @@ const USER_PROFILE_CSS = ` display: flex; flex-direction: column; padding: calc(var(--thunderid-spacing-unit) * 4); - width: 100%; - max-width: 600px; - margin: 0 auto; + min-width: 600px; font-family: var(--thunderid-typography-fontFamily); background: var(--thunderid-color-background-surface); border-radius: var(--thunderid-border-radius-large, 8px); @@ -153,6 +151,7 @@ img.thunderid-user-profile__avatar { display: flex; align-items: center; gap: var(--thunderid-spacing-unit); + min-width: 0; } .thunderid-user-profile__field-label { @@ -176,6 +175,7 @@ img.thunderid-user-profile__avatar { text-overflow: ellipsis; white-space: nowrap; max-width: 350px; + min-width: 0; text-align: start; overflow: hidden; } diff --git a/packages/vue/src/components/primitives/PasswordField/PasswordField.ts b/packages/vue/src/components/primitives/PasswordField/PasswordField.ts index 41c02a6d..e3fd0909 100644 --- a/packages/vue/src/components/primitives/PasswordField/PasswordField.ts +++ b/packages/vue/src/components/primitives/PasswordField/PasswordField.ts @@ -6,6 +6,7 @@ import {type Component, type Ref, type SetupContext, type VNode, defineComponent import {EyeIcon, EyeOffIcon} from '../Icons'; type PasswordFieldProps = Readonly<{ + autocomplete: string; disabled: boolean; error: string | undefined; label: string | undefined; @@ -18,6 +19,12 @@ type PasswordFieldProps = Readonly<{ const PasswordField: Component = defineComponent({ name: 'PasswordField', props: { + /** + * The browser autofill hint. Defaults to `current-password`; set `new-password` on the + * fields of a change-password or sign-up form so password managers offer to generate and + * store a new credential instead of filling the existing one. + */ + autocomplete: {default: 'current-password', type: String}, disabled: {default: false, type: Boolean}, error: {default: undefined, type: String}, label: {default: undefined, type: String}, @@ -56,6 +63,7 @@ const PasswordField: Component = defineComponent({ : null, h('div', {class: withVendorCSSClassPrefix('password-field__wrapper')}, [ h('input', { + autocomplete: props.autocomplete, class: withVendorCSSClassPrefix('password-field__input'), 'data-testid': attrs['data-testid'], disabled: props.disabled, diff --git a/packages/vue/src/index.ts b/packages/vue/src/index.ts index a5181461..48025bb6 100644 --- a/packages/vue/src/index.ts +++ b/packages/vue/src/index.ts @@ -106,6 +106,9 @@ export {default as Loading} from './components/control/Loading'; export {default as User} from './components/presentation/user/User'; export {default as UserProfile} from './components/presentation/user-profile/UserProfile'; export {default as BaseUserProfile} from './components/presentation/user-profile/BaseUserProfile'; +export {default as ChangeCredential} from './components/presentation/change-credential/ChangeCredential'; +export {default as BaseChangeCredential} from './components/presentation/change-credential/BaseChangeCredential'; +export type {ChangePasswordValues} from './components/presentation/change-credential/BaseChangeCredential'; export {default as UserDropdown} from './components/presentation/user-dropdown/UserDropdown'; export {default as BaseUserDropdown} from './components/presentation/user-dropdown/BaseUserDropdown'; export {default as AcceptInvite} from './components/presentation/accept-invite/AcceptInvite'; diff --git a/packages/vue/src/styles/injectStyles.ts b/packages/vue/src/styles/injectStyles.ts index f5622353..0d671e94 100644 --- a/packages/vue/src/styles/injectStyles.ts +++ b/packages/vue/src/styles/injectStyles.ts @@ -24,6 +24,8 @@ import ANIMATIONS_CSS from './animations.css'; import DEFAULTS_CSS from './defaults.css'; // Primitives +import BASE_SIGN_IN_CSS from '../components/auth/sign-in/BaseSignIn.css'; +import CHANGE_CREDENTIAL_CSS from '../components/presentation/change-credential/ChangeCredential.css'; import LANGUAGE_SWITCHER_CSS from '../components/presentation/language-switcher/LanguageSwitcher.css'; import USER_DROPDOWN_CSS from '../components/presentation/user-dropdown/UserDropdown.css'; import USER_PROFILE_CSS from '../components/presentation/user-profile/UserProfile.css'; @@ -41,11 +43,6 @@ import SPINNER_CSS from '../components/primitives/Spinner/Spinner.css'; import TEXT_FIELD_CSS from '../components/primitives/TextField/TextField.css'; import TYPOGRAPHY_CSS from '../components/primitives/Typography/Typography.css'; -// Auth -import BASE_SIGN_IN_CSS from '../components/auth/sign-in/BaseSignIn.css'; - -// Presentation - /** * Assembled CSS for all ThunderID Vue components. * Order is intentional: @@ -74,6 +71,7 @@ const STYLES: string = [ // Auth BASE_SIGN_IN_CSS, // Presentation + CHANGE_CREDENTIAL_CSS, LANGUAGE_SWITCHER_CSS, USER_DROPDOWN_CSS, USER_PROFILE_CSS, diff --git a/samples/browser/quickstart/src/components/nav.js b/samples/browser/quickstart/src/components/nav.js index ada2f047..25b1ca74 100644 --- a/samples/browser/quickstart/src/components/nav.js +++ b/samples/browser/quickstart/src/components/nav.js @@ -98,7 +98,7 @@ export function renderSignedInNav({ user, isDark, currentPage }) { ${ICON_KEY} Token debug
    @@ -108,7 +108,7 @@ export function renderSignedInNav({ user, isDark, currentPage }) { ` } -export function attachNavHandlers({ isDark, setIsDark, navigateTo, auth, openManageProfile }) { +export function attachNavHandlers({ isDark, setIsDark, navigateTo, auth }) { document.getElementById('dark-toggle')?.addEventListener('click', () => { const next = !isDark() setIsDark(next) @@ -149,7 +149,7 @@ export function attachNavHandlers({ isDark, setIsDark, navigateTo, auth, openMan document.getElementById('ud-manage-profile')?.addEventListener('click', () => { menu?.classList.remove('ud-menu--open') - openManageProfile?.() + navigateTo('account') }) document.getElementById('ud-sign-out')?.addEventListener('click', () => { diff --git a/samples/browser/quickstart/src/components/profileDialog.js b/samples/browser/quickstart/src/components/profileFields.js similarity index 80% rename from samples/browser/quickstart/src/components/profileDialog.js rename to samples/browser/quickstart/src/components/profileFields.js index 36e30dd8..f0fd0452 100644 --- a/samples/browser/quickstart/src/components/profileDialog.js +++ b/samples/browser/quickstart/src/components/profileFields.js @@ -1,6 +1,5 @@ import { deepMerge, getUsersMe, getUsersMeMeta, updateMeProfile } from '@thunderid/browser' -const ICON_CLOSE = `` const ICON_PENCIL = `` // Attributes that are always read-only regardless of schema mutability @@ -16,7 +15,7 @@ const ALWAYS_READONLY_KEYS = [ 'user_name', ] -function escapeHtml(str) { +export function escapeHtml(str) { if (str == null) return '' return String(str) .replace(/&/g, '&') @@ -96,7 +95,7 @@ function renderAvatarInner(user, displayName) { } } -function createFetcher(auth) { +export function createFetcher(auth) { return async (url, config) => { const token = await auth.getAccessToken() return fetch(url, { @@ -110,12 +109,17 @@ function createFetcher(auth) { export async function fetchProfileFormContext({ baseUrl, auth }) { const fetcher = createFetcher(auth) + // A failed fetch stays `null`, not `{}` — `supportsCredential` treats those differently: an + // unresolved schema is "not known yet" (every credential stays available), while `{}` is a + // known schema declaring nothing (every credential becomes unavailable). Collapsing a fetch + // failure into `{}` would make a transient network error look identical to "no credentials + // configured". const [metaRes, profile] = await Promise.all([ - getUsersMeMeta({ baseUrl, fetcher }).catch(() => ({ schema: {} })), + getUsersMeMeta({ baseUrl, fetcher }).catch(() => null), getUsersMe({ baseUrl, fetcher }).catch(() => null), ]) - return { schema: metaRes?.schema || {}, profile } + return { schema: metaRes?.schema ?? null, profile } } function renderFieldRow(key, schemaEntry, value) { @@ -140,36 +144,30 @@ function renderFieldRow(key, schemaEntry, value) { ` } -export function renderProfileDialog(user, { schema = {}, profile } = {}) { +// Renders the "Personal info" section: avatar/name summary plus the schema-driven, +// per-field editable rows. Used by the account page's Personal info tab. +export function renderProfileFields(user, { schema = {}, profile } = {}) { const attributes = profile?.attributes || {} const displayName = getDisplayName(user, attributes) const avatar = renderAvatarInner(user, displayName) const email = escapeHtml(user?.email || user?.username || '') - const rows = Object.entries(schema) + // `schema` may be `null` (the meta fetch failed) — a default parameter only covers + // `undefined`, not an explicit `null`, so this still needs its own fallback. + const rows = Object.entries(schema || {}) .map(([key, schemaEntry]) => renderFieldRow(key, schemaEntry, attributes[key])) .join('') return ` -
    - + +
    ${rows}
    ` } // Validates a field value against its schema entry (required + regex), matching @@ -195,19 +193,13 @@ function validateField(schemaEntry, label, value) { return null } -export function attachProfileDialogHandlers({ user, auth, schema = {}, profile, onSaved, onClose }) { - const overlay = document.getElementById('profile-dialog-overlay') - const closeDialog = () => { - overlay?.remove() - onClose?.() - } - - document.getElementById('profile-dialog-close')?.addEventListener('click', closeDialog) - overlay?.addEventListener('click', (e) => { - if (e.target === overlay) closeDialog() - }) - - const errorEl = document.getElementById('profile-dialog-error') +// Wires up inline per-field editing for the rows rendered by `renderProfileFields`. Safe to +// call once after that markup is in the DOM. +export function attachProfileFieldHandlers({ user, auth, schema, profile, onSaved }) { + // `schema` may be `null` (the meta fetch failed) — a default parameter only covers + // `undefined`, not an explicit `null`, so this still needs its own fallback. + const resolvedSchema = schema || {} + const errorEl = document.getElementById('profile-error') const fieldList = document.getElementById('profile-field-list') const baseUrl = import.meta.env.VITE_THUNDERID_BASE_URL const fetcher = createFetcher(auth) @@ -220,15 +212,15 @@ export function attachProfileDialogHandlers({ user, auth, schema = {}, profile, const mergedUser = { ...user, ...currentAttributes } const displayName = getDisplayName(mergedUser, currentAttributes) - const avatarEl = overlay?.querySelector('.profile-dialog-avatar') + const avatarEl = document.querySelector('.profile-summary-avatar') if (avatarEl) { const avatar = renderAvatarInner(mergedUser, displayName) - avatarEl.className = `profile-dialog-avatar ${avatar.className}` + avatarEl.className = `profile-summary-avatar ${avatar.className}` avatarEl.setAttribute('style', avatar.style || '') avatarEl.innerHTML = avatar.html } - const nameEl = overlay?.querySelector('.profile-dialog-name') + const nameEl = document.querySelector('.profile-summary-name') if (nameEl) nameEl.textContent = displayName } @@ -308,6 +300,6 @@ export function attachProfileDialogHandlers({ user, auth, schema = {}, profile, const row = editBtn.closest('.profile-field-row') const key = row?.dataset.field if (!row || !key) return - startEdit(row, key, schema[key]) + startEdit(row, key, resolvedSchema[key]) }) } diff --git a/samples/browser/quickstart/src/main.js b/samples/browser/quickstart/src/main.js index 52edd900..06eb46ae 100644 --- a/samples/browser/quickstart/src/main.js +++ b/samples/browser/quickstart/src/main.js @@ -1,7 +1,7 @@ import './style.css' import auth, { missingEnvVars } from './auth.js' import { renderSignedOutNav, renderSignedInNav, attachNavHandlers, attachSignedOutNavHandlers } from './components/nav.js' -import { renderProfileDialog, attachProfileDialogHandlers, fetchProfileFormContext } from './components/profileDialog.js' +import { mountAccountPage } from './pages/account.js' import { renderSignedOut, renderHome, renderConfigNeeded, startCountdown, attachSignedOutHandlers, attachConfigNeededHandlers } from './pages/home.js' import { renderTokenDebug, attachTokenHandlers } from './pages/token.js' @@ -22,6 +22,24 @@ function renderSignedInPage() { const app = document.getElementById('app') if (!app) return + if (currentPage === 'account') { + app.innerHTML = renderSignedInNav({ user, isDark, currentPage }) + '
    ' + attachNavHandlers({ + isDark: () => isDark, + setIsDark: (v) => { isDark = v }, + navigateTo, + auth, + }) + mountAccountPage(document.getElementById('account-root'), { + user, + auth, + onUserUpdated: (updatedUser) => { + user = updatedUser + }, + }) + return + } + let content if (currentPage === 'token') { content = renderTokenDebug({ rawToken }) @@ -36,7 +54,6 @@ function renderSignedInPage() { setIsDark: (v) => { isDark = v }, navigateTo, auth, - openManageProfile, }) if (currentPage === 'token') { @@ -46,30 +63,6 @@ function renderSignedInPage() { } } -async function openManageProfile() { - const app = document.getElementById('app') - if (!app) return - - const { schema, profile } = await fetchProfileFormContext({ - baseUrl: import.meta.env.VITE_THUNDERID_BASE_URL, - auth, - }) - - app.insertAdjacentHTML('beforeend', renderProfileDialog(user, { schema, profile })) - attachProfileDialogHandlers({ - user, - auth, - schema, - profile, - onSaved: (updatedUser) => { - user = updatedUser - }, - onClose: () => { - renderSignedInPage() - }, - }) -} - async function renderApp() { const app = document.getElementById('app') if (!app) return diff --git a/samples/browser/quickstart/src/pages/account.js b/samples/browser/quickstart/src/pages/account.js new file mode 100644 index 00000000..3e2fcb3b --- /dev/null +++ b/samples/browser/quickstart/src/pages/account.js @@ -0,0 +1,307 @@ +import { + CredentialConstants, + evaluateChangePasswordForm, + mapCredentialUpdateError, + resolveChangeCredentialPolicy, + supportsCredential, + updateMeCredentials, +} from '@thunderid/browser' +import { createFetcher, escapeHtml, fetchProfileFormContext, renderProfileFields, attachProfileFieldHandlers } from '../components/profileFields.js' + +const ICON_HOME = `` +const ICON_PERSON = `` +const ICON_SHIELD = `` +const ICON_EYE = `` +const ICON_CHECK = `` +const ICON_X = `` +const ICON_CHEVRON = `` + +const TABS = [ + { id: 'home', label: 'Home', icon: ICON_HOME }, + { id: 'personal', label: 'Personal info', icon: ICON_PERSON }, + { id: 'security', label: 'Security', icon: ICON_SHIELD }, +] + +const TILES = [ + { id: 'personal', icon: ICON_PERSON, tone: 'blue', title: 'Personal info', description: 'Name, email, phone, and profile photo' }, + { id: 'security', icon: ICON_SHIELD, tone: 'green', title: 'Security', description: 'Password and other credentials' }, +] + +// Mirrors the React/Vue quickstarts' Security tab: one credential per row. +const CREDENTIALS = [ + { + attribute: CredentialConstants.PASSWORD, + title: 'Password', + description: 'Used to sign in to your account.', + cta: 'Change password', + submitLabel: 'Update Password', + }, +] + +function renderHomeTab(user) { + const givenName = user?.given_name || user?.givenName || user?.name || user?.username || 'there' + + const tiles = TILES.map( + (tile) => ` + `, + ).join('') + + return ` + + + ` +} + +function renderCredentialCard(credential, { schema, openAttribute }) { + const isOpen = openAttribute === credential.attribute + + return ` + ` +} + +// The form fields, always rendered so the "unavailable" state has something to sit behind +// (blurred, disabled) rather than showing nothing at all — matches +// `BaseChangeCredential`'s unavailable rendering in the React/Vue SDKs. +function renderCredentialFields(credential) { + const nameLower = credential.title.toLowerCase() + const newFieldId = `cred-${credential.attribute}-new` + const confirmFieldId = `cred-${credential.attribute}-confirm` + + return ` +
    + +
    + + +
    +
    + +
    + +
    + + +
    +
    + + +
    + +
    ` +} + +function renderCredentialForm(credential, schema) { + const unavailable = !supportsCredential(schema, credential.attribute) + const fields = renderCredentialFields(credential) + + const body = unavailable + ? ` +
    +
    ${fields}
    +
    + ${escapeHtml(credential.title)} changes unavailable +
    Please contact your administrator.
    +
    +
    ` + : `
    ${fields}
    ` + + return `` +} + +function renderSecurityTab({ schema, openAttribute }) { + const cards = CREDENTIALS.map((credential) => renderCredentialCard(credential, { schema, openAttribute })).join('') + + return ` + + + ` +} + +function renderPersonalTab(user, { schema, profile }) { + return ` + + + ` +} + +function renderAccountPage({ tab, user, schema, profile, openAttribute }) { + const nav = TABS.map( + (t) => ` + `, + ).join('') + + let content + if (tab === 'personal') { + content = renderPersonalTab(user, { schema, profile }) + } else if (tab === 'security') { + content = renderSecurityTab({ schema, openAttribute }) + } else { + content = renderHomeTab(user) + } + + return ` + ` +} + +function attachCredentialForm(formEl, { attribute, schema, baseUrl, auth, onDone }) { + const policy = resolveChangeCredentialPolicy(schema, attribute) + const newInput = formEl.querySelector('[data-cred-field="newValue"]') + const confirmInput = formEl.querySelector('[data-cred-field="confirmValue"]') + const reqBox = formEl.querySelector('[data-cred-requirements]') + const reqList = formEl.querySelector('[data-cred-requirements-list]') + const submitBtn = formEl.querySelector('[data-cred-submit]') + const errorBox = formEl.querySelector('[data-cred-error]') + const successBox = formEl.querySelector('[data-cred-success]') + + const evaluate = () => { + const { ruleResults, isValid } = evaluateChangePasswordForm( + { newPassword: newInput.value, confirmPassword: confirmInput.value }, + policy, + ) + + if (ruleResults.length > 0) { + reqBox.hidden = false + reqList.innerHTML = ruleResults + .map( + (result) => + `
  • ${result.passed ? ICON_CHECK : ICON_X} Matches the required format
  • `, + ) + .join('') + } else { + reqBox.hidden = true + } + + submitBtn.disabled = !isValid + } + + newInput.addEventListener('input', evaluate) + confirmInput.addEventListener('input', evaluate) + + formEl.querySelectorAll('[data-cred-toggle-visibility]').forEach((btn) => { + btn.addEventListener('click', () => { + const input = btn.previousElementSibling + input.type = input.type === 'password' ? 'text' : 'password' + }) + }) + + submitBtn.addEventListener('click', async () => { + errorBox.hidden = true + successBox.hidden = true + submitBtn.disabled = true + + try { + const fetcher = createFetcher(auth) + await updateMeCredentials({ baseUrl, payload: { [attribute]: newInput.value }, fetcher }) + + successBox.hidden = false + newInput.value = '' + confirmInput.value = '' + evaluate() + onDone?.() + } catch (err) { + const { message } = mapCredentialUpdateError(err) + errorBox.hidden = false + errorBox.textContent = message || 'An error occurred while updating your credential. Please try again.' + submitBtn.disabled = false + } + }) +} + +// Self-mounting Account page: fetches the schema/profile once, then owns its own tab and +// per-credential open/closed state, re-rendering just its own container on every change +// rather than the whole app shell. +export async function mountAccountPage(container, { user, auth, initialTab = 'home', onUserUpdated }) { + const baseUrl = import.meta.env.VITE_THUNDERID_BASE_URL + + let tab = initialTab + let openAttribute = null + let currentUser = user + + const { schema, profile } = await fetchProfileFormContext({ baseUrl, auth }) + + const render = () => { + container.innerHTML = renderAccountPage({ tab, user: currentUser, schema, profile, openAttribute }) + attachHandlers() + } + + const attachHandlers = () => { + container.querySelectorAll('[data-tab]').forEach((btn) => { + btn.addEventListener('click', () => { + tab = btn.dataset.tab + openAttribute = null + render() + }) + }) + + if (tab === 'personal') { + attachProfileFieldHandlers({ + user: currentUser, + auth, + schema, + profile, + onSaved: (updatedUser) => { + currentUser = updatedUser + onUserUpdated?.(updatedUser) + }, + }) + } + + if (tab === 'security') { + // The same button opens and closes the form: clicking it while its own credential is + // already open collapses it, same as clicking the chevron. + container.querySelectorAll('[data-cred-toggle]').forEach((btn) => { + btn.addEventListener('click', () => { + const attribute = btn.dataset.credToggle + openAttribute = openAttribute === attribute ? null : attribute + render() + }) + }) + + const formEl = container.querySelector('[data-cred-form]') + if (formEl) { + attachCredentialForm(formEl, { + attribute: formEl.dataset.credForm, + schema, + baseUrl, + auth, + onDone: () => { + openAttribute = null + render() + }, + }) + } + } + } + + render() +} diff --git a/samples/browser/quickstart/src/style.css b/samples/browser/quickstart/src/style.css index 27a3de04..7b5ba13b 100644 --- a/samples/browser/quickstart/src/style.css +++ b/samples/browser/quickstart/src/style.css @@ -358,7 +358,12 @@ body { border: 1.5px solid var(--blue); } -.btn-primary:hover { +.btn-primary:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.btn-primary:hover:not(:disabled) { opacity: 0.88; } @@ -1054,65 +1059,8 @@ body { letter-spacing: -0.01em; } -/* Manage Profile dialog */ -.profile-dialog-overlay { - position: fixed; - inset: 0; - background: rgba(0, 0, 0, 0.5); - display: flex; - align-items: center; - justify-content: center; - padding: 24px; - z-index: 300; -} - -.profile-dialog { - width: 100%; - max-width: 520px; - max-height: 90vh; - overflow-y: auto; - background: var(--card); - border-radius: var(--radius); - box-shadow: var(--shadow-md); -} - -.profile-dialog-header { - display: flex; - align-items: center; - justify-content: space-between; - padding: 24px 32px; - border-bottom: 1px solid var(--border); -} - -.profile-dialog-header h2 { - font-size: 19px; - font-weight: 600; - color: var(--text); -} - -.profile-dialog-close { - display: flex; - align-items: center; - justify-content: center; - width: 28px; - height: 28px; - border: none; - background: transparent; - color: var(--muted); - border-radius: var(--radius-sm); - cursor: pointer; -} - -.profile-dialog-close:hover { - background: var(--blue-subtle); - color: var(--blue); -} - -.profile-dialog-body { - padding: 16px 32px 32px; -} - -.profile-dialog-summary { +/* Personal info (account page) */ +.profile-summary { display: flex; flex-direction: column; align-items: flex-start; @@ -1120,7 +1068,7 @@ body { margin-bottom: 12px; } -.profile-dialog-avatar { +.profile-summary-avatar { width: 70px; height: 70px; border-radius: 50%; @@ -1135,20 +1083,20 @@ body { overflow: hidden; } -.profile-dialog-avatar.has-gradient { +.profile-summary-avatar.has-gradient { color: #fff; text-shadow: 0 1px 2px rgba(0, 0, 0, 0.1); border: none; } -.profile-dialog-avatar img { +.profile-summary-avatar img { width: 100%; height: 100%; border-radius: 50%; object-fit: cover; } -.profile-dialog-error { +.profile-error { font-size: 12px; color: #dc2626; background: rgba(220, 38, 38, 0.08); @@ -1157,13 +1105,13 @@ body { margin-bottom: 14px; } -.profile-dialog-name { +.profile-summary-name { font-size: 24px; font-weight: 600; color: var(--text); } -.profile-dialog-subtitle { +.profile-summary-subtitle { font-size: 14px; color: var(--muted); } @@ -1291,3 +1239,357 @@ body { .profile-field-btn--cancel:hover { opacity: 0.9; } + +/* Account page (Home / Personal info / Security, left-nav layout) */ +.account-page { + display: flex; + align-items: flex-start; + min-height: calc(100vh - 52px); +} + +.account-sidebar { + width: 280px; + flex-shrink: 0; + padding: 40px 24px; + position: sticky; + top: 52px; +} + +.account-sidebar-title { + font-size: 22px; + font-weight: 700; + letter-spacing: -0.03em; + color: var(--text); + margin: 0 0 24px 16px; +} + +.account-nav { + display: flex; + flex-direction: column; + gap: 2px; +} + +.account-nav-item { + display: flex; + align-items: center; + gap: 14px; + width: 100%; + padding: 11px 16px; + border: none; + border-radius: 20px; + background: transparent; + color: var(--muted); + font-size: 14px; + font-weight: 500; + font-family: inherit; + text-align: left; + cursor: pointer; + white-space: nowrap; +} + +.account-nav-item:hover { + background: var(--border); + color: var(--text); +} + +.account-nav-item--active, +.account-nav-item--active:hover { + background: var(--blue-subtle); + color: var(--blue); +} + +.account-content { + flex: 1; + min-width: 0; + display: flex; + justify-content: center; + padding: 40px 32px 80px; + border-left: 1px solid var(--border); +} + +.account-content-inner { + width: 100%; + max-width: 720px; +} + +.account-content-title { + font-size: 26px; + font-weight: 700; + letter-spacing: -0.03em; + color: var(--text); + margin: 0 0 6px; +} + +.account-content-subtitle { + font-size: 14px; + color: var(--muted); + margin: 0 0 32px; +} + +.account-tiles { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 16px; + margin-top: 8px; +} + +.account-tile { + text-align: left; + background: var(--card); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 22px; + cursor: pointer; + font-family: inherit; + display: flex; + flex-direction: column; +} + +.account-tile:hover { + border-color: var(--blue); +} + +.account-tile-icon { + width: 36px; + height: 36px; + border-radius: 10px; + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 14px; +} + +.account-tile-icon--blue { + background: var(--blue-subtle); + color: var(--blue); +} + +.account-tile-icon--green { + background: rgba(16, 185, 129, 0.12); + color: #10b981; +} + +.account-tile-title { + font-size: 15px; + font-weight: 600; + color: var(--text); + margin-bottom: 4px; +} + +.account-tile-desc { + font-size: 12.5px; + color: var(--muted); + line-height: 1.5; +} + +/* Generic bordered content box, matching the Home tiles and Security cards. */ +.account-box { + background: var(--card); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 24px; +} + +/* Security tab: one credential per row, collapsed into an expandable form */ +.account-security-list { + display: flex; + flex-direction: column; + gap: 16px; +} + +.account-security-card { + background: var(--card); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 20px 22px; +} + +.account-security-card-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; +} + +.account-security-card-title { + font-size: 14px; + font-weight: 600; + color: var(--text); + margin-bottom: 2px; +} + +.account-security-card-desc { + font-size: 12.5px; + color: var(--muted); +} + +.account-security-card-cta { + flex-shrink: 0; + display: flex; + align-items: center; + gap: 6px; + padding: 0; + background: none; + border: none; + color: var(--blue); + font-size: 13px; + font-weight: 500; + font-family: inherit; + cursor: pointer; +} + +.account-security-card-cta:hover { + opacity: 0.8; +} + +.account-security-card-chevron { + display: inline-flex; + transition: transform 0.2s; +} + +.account-security-card-chevron--open { + transform: rotate(180deg); +} + +.account-security-card-form { + margin-top: 18px; + padding-top: 18px; + border-top: 1px solid var(--border); +} + +.account-security-card-error { + font-size: 12px; + color: #dc2626; + background: rgba(220, 38, 38, 0.08); + border-radius: var(--radius-sm); + padding: 8px 10px; + margin-bottom: 14px; +} + +.account-security-card-success { + font-size: 12px; + color: #059669; + background: rgba(5, 150, 105, 0.08); + border-radius: var(--radius-sm); + padding: 8px 10px; + margin-bottom: 14px; +} + +/* Change-credential form fields */ +.cred-field { + margin-bottom: 14px; +} + +.cred-label { + display: block; + font-size: 13px; + font-weight: 500; + color: var(--text); + margin-bottom: 6px; +} + +.cred-input-wrap { + position: relative; +} + +.cred-input { + width: 100%; + padding: 9px 38px 9px 12px; + font-size: 14px; + font-family: inherit; + color: var(--text); + background: var(--bg); + border: 1px solid var(--border); + border-radius: var(--radius-sm); +} + +.cred-input:focus { + outline: none; + border-color: var(--blue); +} + +.cred-eye-btn { + position: absolute; + top: 50%; + right: 8px; + transform: translateY(-50%); + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + border: none; + background: transparent; + color: var(--muted); + cursor: pointer; +} + +.cred-requirements { + margin: -6px 0 14px; +} + +.cred-requirements-heading { + font-size: 12px; + color: var(--muted); + margin-bottom: 6px; +} + +.cred-requirements-list { + list-style: none; + display: flex; + flex-direction: column; + gap: 4px; +} + +.cred-requirements-list li { + display: flex; + align-items: center; + gap: 8px; + font-size: 12.5px; + color: var(--muted); +} + +.cred-requirements-list li.passed { + color: #059669; +} + +.cred-requirements-list li svg { + flex-shrink: 0; +} + +.cred-form-actions { + display: flex; + justify-content: flex-end; +} + +.cred-unavailable { + position: relative; +} + +.cred-unavailable-content { + filter: blur(3px); + opacity: 0.55; + pointer-events: none; + user-select: none; +} + +.cred-unavailable-overlay { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + gap: 4px; + padding: 16px; + background: rgba(217, 119, 6, 0.08); + border: 1px solid rgba(217, 119, 6, 0.3); + border-radius: var(--radius-sm); + color: #d97706; + font-size: 13px; +} + +.cred-unavailable-overlay strong { + font-size: 13.5px; +} diff --git a/samples/react/quickstart/src/App.css b/samples/react/quickstart/src/App.css index 698ea889..741c085a 100644 --- a/samples/react/quickstart/src/App.css +++ b/samples/react/quickstart/src/App.css @@ -627,6 +627,247 @@ color: var(--color-text); } +/* ─── Account page (Google Account-style left-nav layout) ─────────── */ +.account-page { + display: flex; + align-items: flex-start; + min-height: calc(100vh - var(--nav-height)); + margin-top: var(--nav-height); +} + +.account-sidebar { + width: 280px; + flex-shrink: 0; + padding: 40px 24px; + position: sticky; + top: var(--nav-height); +} + +.account-sidebar-title { + font-size: 22px; + font-weight: 700; + letter-spacing: -0.03em; + color: var(--color-text); + margin: 0 0 24px 16px; +} + +.account-nav { + display: flex; + flex-direction: column; + gap: 2px; +} + +.account-nav-item { + display: flex; + align-items: center; + gap: 14px; + width: 100%; + padding: 11px 16px; + border: none; + border-radius: 20px; + background: transparent; + color: var(--color-muted); + font-size: 14px; + font-weight: 500; + font-family: inherit; + text-align: left; + cursor: pointer; + white-space: nowrap; + transition: background var(--transition), color var(--transition); +} + +.account-nav-item:hover { + background: var(--color-border); + color: var(--color-text); +} + +.account-nav-item--active, +.account-nav-item--active:hover { + background: rgba(54, 136, 255, 0.12); + color: var(--color-primary); +} + +.account-content { + flex: 1; + min-width: 0; + display: flex; + justify-content: center; + padding: 40px 32px 80px; + border-left: 1px solid var(--color-border); +} + +.account-content-inner { + width: 100%; + max-width: 720px; +} + +/* UserProfile's `min-width: 600px` (a floor meant to keep the field grid from getting + cramped in other contexts) fights this page's own responsive layout, refusing to shrink + below 600px even when the viewport is narrower. The Security/Home boxes have no such floor + and already shrink fluidly with their container, so match that here. */ +.account-content .thunderid-user-profile { + min-width: 0; +} + +.account-content-title { + font-size: 26px; + font-weight: 700; + letter-spacing: -0.03em; + color: var(--color-text); + margin: 0 0 6px; +} + +.account-content-subtitle { + font-size: 14px; + color: var(--color-muted); + margin: 0 0 32px; +} + +.account-tiles { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 16px; + margin-top: 8px; +} + +.account-tile { + text-align: left; + background: var(--color-card); + border: 1px solid var(--color-border); + border-radius: var(--radius-card); + padding: 22px; + cursor: pointer; + font-family: inherit; + display: flex; + flex-direction: column; + transition: border-color var(--transition); +} + +.account-tile:hover { + border-color: rgba(54, 136, 255, 0.35); +} + +.account-tile-icon { + width: 36px; + height: 36px; + border-radius: 10px; + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 14px; +} + +.account-tile-icon--blue { + background: rgba(54, 136, 255, 0.12); + color: var(--color-primary); +} + +.account-tile-icon--green { + background: rgba(16, 185, 129, 0.12); + color: #10b981; +} + +.account-tile-title { + font-size: 15px; + font-weight: 600; + color: var(--color-text); + margin-bottom: 4px; +} + +.account-tile-desc { + font-size: 12.5px; + color: var(--color-muted); + line-height: 1.5; +} + +.account-security-list { + display: flex; + flex-direction: column; + gap: 16px; +} + +.account-security-card { + background: var(--color-card); + border: 1px solid var(--color-border); + border-radius: var(--radius-card); + padding: 20px 22px; +} + +.account-security-card-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; +} + +.account-security-card-title { + font-size: 14px; + font-weight: 600; + color: var(--color-text); + margin-bottom: 2px; +} + +.account-security-card-desc { + font-size: 12.5px; + color: var(--color-muted); +} + +.account-security-card-cta { + flex-shrink: 0; + display: flex; + align-items: center; + gap: 6px; + padding: 0; + background: none; + border: none; + color: var(--color-primary); + font-size: 13px; + font-weight: 500; + font-family: inherit; + cursor: pointer; + transition: opacity var(--transition); +} + +.account-security-card-cta:hover { + opacity: 0.8; +} + +.account-security-card-chevron { + display: inline-flex; + transition: transform 0.2s; +} + +.account-security-card-chevron--open { + transform: rotate(180deg); +} + +.account-security-card-form { + margin-top: 18px; + padding-top: 18px; + border-top: 1px solid var(--color-border); +} + +@media (max-width: 760px) { + .account-page { + flex-direction: column; + } + .account-sidebar { + width: 100%; + position: static; + padding: 20px 16px 8px; + } + .account-nav { + flex-direction: row; + overflow-x: auto; + } + .account-content { + max-width: 100%; + border-left: none; + border-top: 1px solid var(--color-border); + padding: 24px 20px 60px; + } +} + /* ─── Token debug page ──────────────────────────────────────────── */ .token-main { width: 100%; diff --git a/samples/react/quickstart/src/App.jsx b/samples/react/quickstart/src/App.jsx index a988940f..e9b27ab8 100644 --- a/samples/react/quickstart/src/App.jsx +++ b/samples/react/quickstart/src/App.jsx @@ -3,6 +3,7 @@ import { ProtectedRoute } from '@thunderid/react-router' import Nav from './components/Nav' import HomePage from './pages/HomePage' import TokenDebugPage from './pages/TokenDebugPage' +import AccountPage from './pages/AccountPage' import './App.css' const router = createBrowserRouter([ @@ -11,6 +12,7 @@ const router = createBrowserRouter([ children: [ { path: '/', element: }, { path: '/token', element: }, + { path: '/account', element: }, ], }, ]) diff --git a/samples/react/quickstart/src/components/Nav.jsx b/samples/react/quickstart/src/components/Nav.jsx index a3936658..55955be0 100644 --- a/samples/react/quickstart/src/components/Nav.jsx +++ b/samples/react/quickstart/src/components/Nav.jsx @@ -1,6 +1,6 @@ import { useState } from 'react' -import { Link, Outlet, useLocation } from 'react-router' -import { SignedIn, SignedOut, SignInButton, UserDropdown, Loading } from '@thunderid/react' +import { Link, Outlet, useLocation, useNavigate } from 'react-router' +import { SignedIn, SignedOut, SignInButton, UserDropdown, Loading, useTheme } from '@thunderid/react' import ReactLogo from './icons/ReactLogo' function MoonIcon() { @@ -44,12 +44,15 @@ function KeyIcon() { export default function Nav() { const [dark, setDark] = useState(false) const location = useLocation() + const navigate = useNavigate() const isHome = location.pathname === '/' + const { toggleTheme } = useTheme() const toggleDark = () => { const next = !dark setDark(next) document.documentElement.setAttribute('data-theme', next ? 'dark' : '') + toggleTheme() } const tokenDebugMenuItem = { @@ -79,7 +82,12 @@ export default function Nav() { {dark ? : } - + navigate('/account')} + /> diff --git a/samples/react/quickstart/src/pages/AccountPage.jsx b/samples/react/quickstart/src/pages/AccountPage.jsx new file mode 100644 index 00000000..917ae57a --- /dev/null +++ b/samples/react/quickstart/src/pages/AccountPage.jsx @@ -0,0 +1,187 @@ +import { ChangeCredential, User, UserProfile } from '@thunderid/react' +import { useState } from 'react' +import { useSearchParams } from 'react-router' + +function HomeIcon() { + return ( + + ) +} + +function PersonIcon() { + return ( + + ) +} + +function ShieldIcon() { + return ( + + ) +} + +const TABS = [ + { id: 'home', label: 'Home', icon: HomeIcon }, + { id: 'personal', label: 'Personal info', icon: PersonIcon }, + { id: 'security', label: 'Security', icon: ShieldIcon }, +] + +const CREDENTIALS = [ + { attribute: 'password', title: 'Password', description: 'Used to sign in to your account.', cta: 'Change password' }, +] + +function ChevronIcon() { + return ( + + + + ) +} + +function CredentialCard({ attribute, title, description, cta }) { + const [open, setOpen] = useState(false) + const close = () => setOpen(false) + + return ( +
    +
    +
    +
    {title}
    +
    {description}
    +
    + +
    + {open && ( +
    + +
    + )} +
    + ) +} + +const TILES = [ + { + id: 'personal', + icon: PersonIcon, + tone: 'blue', + title: 'Personal info', + description: 'Name, email, phone, and profile photo', + }, + { + id: 'security', + icon: ShieldIcon, + tone: 'green', + title: 'Security', + description: 'Password and other credentials', + }, +] + +// Google Account-style layout: a persistent left nav switching between tabs of +// content on the right, rather than a single scrolling page or a modal. +export default function AccountPage() { + const [searchParams, setSearchParams] = useSearchParams() + const requestedTab = searchParams.get('tab') + const [tab, setTab] = useState(TABS.some((t) => t.id === requestedTab) ? requestedTab : 'home') + + const selectTab = (id) => { + setTab(id) + setSearchParams(id === 'home' ? {} : { tab: id }) + } + + return ( +
    + + +
    +
    + {tab === 'home' && ( + + {(user) => { + const givenName = user?.givenName || user?.given_name || user?.displayName || user?.username || 'there' + + return ( + <> +

    Hi, {givenName}

    +

    + Manage your info and security across ThunderID apps. +

    +
    + {TILES.map(({ id, icon: Icon, tone, title, description }) => ( + + ))} +
    + + ) + }} +
    + )} + + {tab === 'personal' && ( + <> +

    Personal info

    +

    Manage the basic profile info others may see.

    + + + )} + + {tab === 'security' && ( + <> +

    Security

    +

    Manage your password and other credentials.

    +
    + {CREDENTIALS.map((credential) => ( + + ))} +
    + + )} +
    +
    +
    + ) +} diff --git a/samples/vue/quickstart/src/App.vue b/samples/vue/quickstart/src/App.vue index b5e1a269..c1dcf670 100644 --- a/samples/vue/quickstart/src/App.vue +++ b/samples/vue/quickstart/src/App.vue @@ -5,6 +5,7 @@ import Nav from './components/Nav.vue' import ConfigNotice from './components/ConfigNotice.vue' import HomePage from './pages/HomePage.vue' import TokenDebugPage from './pages/TokenDebugPage.vue' +import AccountPage from './pages/AccountPage.vue' const REQUIRED_ENV_VARS = ['VITE_THUNDERID_CLIENT_ID', 'VITE_THUNDERID_BASE_URL'] const missingEnvVars = REQUIRED_ENV_VARS.filter((key) => !import.meta.env[key]) @@ -33,6 +34,7 @@ const dark = ref(false) +
    diff --git a/samples/vue/quickstart/src/components/Nav.vue b/samples/vue/quickstart/src/components/Nav.vue index 22d1a4ef..a4426a2c 100644 --- a/samples/vue/quickstart/src/components/Nav.vue +++ b/samples/vue/quickstart/src/components/Nav.vue @@ -1,6 +1,6 @@ @@ -60,7 +63,12 @@ function toggleDark() { - + diff --git a/samples/vue/quickstart/src/pages/AccountPage.vue b/samples/vue/quickstart/src/pages/AccountPage.vue new file mode 100644 index 00000000..eec1b9ae --- /dev/null +++ b/samples/vue/quickstart/src/pages/AccountPage.vue @@ -0,0 +1,146 @@ + + + diff --git a/samples/vue/quickstart/src/style.css b/samples/vue/quickstart/src/style.css index 745f1636..a8e73a24 100644 --- a/samples/vue/quickstart/src/style.css +++ b/samples/vue/quickstart/src/style.css @@ -860,3 +860,244 @@ body { color: var(--color-text); letter-spacing: -0.01em; } + +/* ─── Account page (Google Account-style left-nav layout) ─────────── */ +.account-page { + display: flex; + align-items: flex-start; + min-height: calc(100vh - var(--nav-height)); + margin-top: var(--nav-height); +} + +.account-sidebar { + width: 280px; + flex-shrink: 0; + padding: 40px 24px; + position: sticky; + top: var(--nav-height); +} + +.account-sidebar-title { + font-size: 22px; + font-weight: 700; + letter-spacing: -0.03em; + color: var(--color-text); + margin: 0 0 24px 16px; +} + +.account-nav { + display: flex; + flex-direction: column; + gap: 2px; +} + +.account-nav-item { + display: flex; + align-items: center; + gap: 14px; + width: 100%; + padding: 11px 16px; + border: none; + border-radius: 20px; + background: transparent; + color: var(--color-muted); + font-size: 14px; + font-weight: 500; + font-family: inherit; + text-align: left; + cursor: pointer; + white-space: nowrap; + transition: background var(--transition), color var(--transition); +} + +.account-nav-item:hover { + background: var(--color-border); + color: var(--color-text); +} + +.account-nav-item--active, +.account-nav-item--active:hover { + background: rgba(54, 136, 255, 0.12); + color: var(--color-primary); +} + +.account-content { + flex: 1; + min-width: 0; + display: flex; + justify-content: center; + padding: 40px 32px 80px; + border-left: 1px solid var(--color-border); +} + +.account-content-inner { + width: 100%; + max-width: 720px; +} + +/* UserProfile's `min-width: 600px` (a floor meant to keep the field grid from getting + cramped in other contexts) fights this page's own responsive layout, refusing to shrink + below 600px even when the viewport is narrower. The Security/Home boxes have no such floor + and already shrink fluidly with their container, so match that here. */ +.account-content .thunderid-user-profile { + min-width: 0; +} + +.account-content-title { + font-size: 26px; + font-weight: 700; + letter-spacing: -0.03em; + color: var(--color-text); + margin: 0 0 6px; +} + +.account-content-subtitle { + font-size: 14px; + color: var(--color-muted); + margin: 0 0 32px; +} + +.account-tiles { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 16px; + margin-top: 8px; +} + +.account-tile { + text-align: left; + background: var(--color-card); + border: 1px solid var(--color-border); + border-radius: var(--radius-card); + padding: 22px; + cursor: pointer; + font-family: inherit; + display: flex; + flex-direction: column; + transition: border-color var(--transition); +} + +.account-tile:hover { + border-color: rgba(54, 136, 255, 0.35); +} + +.account-tile-icon { + width: 36px; + height: 36px; + border-radius: 10px; + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 14px; +} + +.account-tile-icon--blue { + background: rgba(54, 136, 255, 0.12); + color: var(--color-primary); +} + +.account-tile-icon--green { + background: rgba(16, 185, 129, 0.12); + color: #10b981; +} + +.account-tile-title { + font-size: 15px; + font-weight: 600; + color: var(--color-text); + margin-bottom: 4px; +} + +.account-tile-desc { + font-size: 12.5px; + color: var(--color-muted); + line-height: 1.5; +} + +.account-security-list { + display: flex; + flex-direction: column; + gap: 16px; +} + +.account-security-card { + background: var(--color-card); + border: 1px solid var(--color-border); + border-radius: var(--radius-card); + padding: 20px 22px; +} + +.account-security-card-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; +} + +.account-security-card-title { + font-size: 14px; + font-weight: 600; + color: var(--color-text); + margin-bottom: 2px; +} + +.account-security-card-desc { + font-size: 12.5px; + color: var(--color-muted); +} + +.account-security-card-cta { + flex-shrink: 0; + display: flex; + align-items: center; + gap: 6px; + padding: 0; + background: none; + border: none; + color: var(--color-primary); + font-size: 13px; + font-weight: 500; + font-family: inherit; + cursor: pointer; + transition: opacity var(--transition); +} + +.account-security-card-cta:hover { + opacity: 0.8; +} + +.account-security-card-chevron { + display: inline-flex; + transition: transform 0.2s; +} + +.account-security-card-chevron--open { + transform: rotate(180deg); +} + +.account-security-card-form { + margin-top: 18px; + padding-top: 18px; + border-top: 1px solid var(--color-border); +} + +@media (max-width: 760px) { + .account-page { + flex-direction: column; + } + .account-sidebar { + width: 100%; + position: static; + padding: 20px 16px 8px; + } + .account-nav { + flex-direction: row; + overflow-x: auto; + } + .account-content { + max-width: 100%; + border-left: none; + border-top: 1px solid var(--color-border); + padding: 24px 20px 60px; + } +} diff --git a/tests/e2e/constants/credential-test-users.ts b/tests/e2e/constants/credential-test-users.ts new file mode 100644 index 00000000..d034e711 --- /dev/null +++ b/tests/e2e/constants/credential-test-users.ts @@ -0,0 +1,42 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +/** + * Credential Test Users + * + * One dedicated user per app whose E2E suite includes a change-credential spec + * (react/quickstart, vue/quickstart, browser/quickstart). These specs change the signed-in + * user's password mid-test (see each change-credential.spec.ts's own doc comment) and restore + * it afterward, but the suite runs `fullyParallel`, so any other concurrently-running test file + * signing in as the same identity would race that temporary change and fail transiently. + * + * The shared TEST_USER_USERNAME/PASSWORD (global-setup.ts) is what every sign-in-out spec signs + * in as across every app, so a credential-mutating test cannot safely use it. These users exist + * solely so each change-credential spec can mutate a password without any other test in the + * suite ever attempting to sign in as the same identity — see global-setup.ts/global-teardown.ts + * for provisioning, and each change-credential.spec.ts for `describe.serial`, which keeps that + * one dedicated user's own two tests (TC001/TC002) from racing each other too. + * + * Derived from TEST_USER_USERNAME/PASSWORD rather than requiring their own env vars/CI secrets: + * a distinct username is all that's needed for a distinct identity, and reusing the shared + * user's password as the dedicated user's starting password is no less secure than the shared + * user having it in the first place. + * + * A function, not a precomputed object: dotenv only populates `process.env` once global-setup.ts + * (or playwright.config.ts, for spec files) runs its own `dotenv.config()` call, which happens + * after this module is imported, so reading `process.env` at import time would capture `undefined`. + */ +export interface CredentialTestUser { + password: string; + username: string; +} + +export const CredentialTestUserApps = ['BROWSER', 'REACT', 'VUE'] as const; +export type CredentialTestUserApp = (typeof CredentialTestUserApps)[number]; + +export function credentialTestUser(app: CredentialTestUserApp): CredentialTestUser { + return { + password: process.env.TEST_USER_PASSWORD!, + username: `${process.env.TEST_USER_USERNAME}-cred-${app.toLowerCase()}`, + }; +} diff --git a/tests/e2e/fixtures/sample-apps/index.ts b/tests/e2e/fixtures/sample-apps/index.ts index af71e4ce..1180d46b 100644 --- a/tests/e2e/fixtures/sample-apps/index.ts +++ b/tests/e2e/fixtures/sample-apps/index.ts @@ -10,15 +10,15 @@ import {test as base} from '@playwright/test'; import {BrowserQuickstartPage} from '../../pages/browser-quickstart.page'; import {ExpressQuickstartPage} from '../../pages/express-quickstart.page'; -import {ThunderIDWebSamplePage} from '../../pages/thunderid-web-sample.page'; +import {ThunderIDAccountPageSamplePage, ThunderIDWebSamplePage} from '../../pages/thunderid-web-sample.page'; interface SampleAppFixtures { browserQuickstartPage: BrowserQuickstartPage; expressQuickstartPage: ExpressQuickstartPage; nextjsQuickstartPage: ThunderIDWebSamplePage; nuxtQuickstartPage: ThunderIDWebSamplePage; - reactQuickstartPage: ThunderIDWebSamplePage; - vueQuickstartPage: ThunderIDWebSamplePage; + reactQuickstartPage: ThunderIDAccountPageSamplePage; + vueQuickstartPage: ThunderIDAccountPageSamplePage; } export const test = base.extend({ @@ -34,11 +34,13 @@ export const test = base.extend({ nuxtQuickstartPage: async ({page}, use) => { await use(new ThunderIDWebSamplePage(page)); }, + // react/quickstart and vue/quickstart's Nav redirects "Manage Profile" to a full Account page + // instead of the SDK's built-in popup — see ThunderIDAccountPageSamplePage's doc comment. reactQuickstartPage: async ({page}, use) => { - await use(new ThunderIDWebSamplePage(page)); + await use(new ThunderIDAccountPageSamplePage(page)); }, vueQuickstartPage: async ({page}, use) => { - await use(new ThunderIDWebSamplePage(page)); + await use(new ThunderIDAccountPageSamplePage(page)); }, }); diff --git a/tests/e2e/global-setup.ts b/tests/e2e/global-setup.ts index 208a20bf..3a6152d6 100644 --- a/tests/e2e/global-setup.ts +++ b/tests/e2e/global-setup.ts @@ -4,16 +4,22 @@ /** * Global Setup * - * Runs once before the whole suite. Creates the one shared E2E test user every sample app spec + * Runs once before the whole suite. Creates the one shared E2E test user every sign-in-out spec * signs in as (see constants/timeouts.ts's SUITE_SETUP budget) — the OAuth *clients* are * per-app (thunderid-config/sample-apps.yaml), but there's no reason to provision a separate user * per app for the same identity signing into different client apps. * + * Also creates one dedicated user per app that has a change-credential spec. Those specs mutate + * a password mid-test, which the shared user above cannot tolerate under `fullyParallel` without + * racing every other concurrently-running spec's login — see + * constants/credential-test-users.ts. + * * Modeled on thunderid/tests/e2e/global-setup.ts. */ import path from 'node:path'; import dotenv from 'dotenv'; +import {CredentialTestUserApps, credentialTestUser} from './constants/credential-test-users'; import {createUser} from './utils/users-api'; async function globalSetup(): Promise { @@ -37,6 +43,21 @@ async function globalSetup(): Promise { username: process.env.TEST_USER_USERNAME, }); console.log(`✓ Test user ready: ${user.id}`); + + console.log('🚀 Creating dedicated credential-test users...'); + await Promise.all( + CredentialTestUserApps.map(async (app) => { + const {password, username} = credentialTestUser(app); + const credUser = await createUser({ + email: `${username}@example.com`, + family_name: 'E2E', + given_name: 'Credential Test', + password, + username, + }); + console.log(`✓ Credential test user ready (${app}): ${credUser.id}`); + }), + ); } export default globalSetup; diff --git a/tests/e2e/global-teardown.ts b/tests/e2e/global-teardown.ts index e8ca42c7..2ea3b0a7 100644 --- a/tests/e2e/global-teardown.ts +++ b/tests/e2e/global-teardown.ts @@ -4,27 +4,26 @@ /** * Global Teardown * - * Deletes the shared E2E test user created in global-setup.ts. Looked up by username rather than - * carried over in memory — global-setup and global-teardown run in separate processes. + * Deletes the shared E2E test user and the dedicated credential-test users created in + * global-setup.ts. Looked up by username rather than carried over in memory — global-setup and + * global-teardown run in separate processes. */ +import {CredentialTestUserApps, credentialTestUser} from './constants/credential-test-users'; import {send} from './utils/api-request'; -async function globalTeardown(): Promise { - const username = process.env.TEST_USER_USERNAME; - - console.log('🧹 Deleting shared E2E test user...'); +async function deleteUserByUsername(username: string | undefined, label: string): Promise { const filter = `username eq "${username}"`; const searchRes = await send('GET', `/users?filter=${encodeURIComponent(filter)}`); if (!searchRes.ok) { throw new Error( - `Failed to look up test user "${username}" for cleanup: HTTP ${searchRes.status}: ${await searchRes.text()}`, + `Failed to look up ${label} "${username}" for cleanup: HTTP ${searchRes.status}: ${await searchRes.text()}`, ); } const {users} = (await searchRes.json()) as {users?: {attributes?: {username?: string}; id: string}[]}; const matches = (users ?? []).filter((candidate) => candidate.attributes?.username === username); if (matches.length === 0) { - console.warn(`⚠️ Test user "${username}" not found — nothing to clean up`); + console.warn(`⚠️ ${label} "${username}" not found — nothing to clean up`); return; } const user = matches[0]; @@ -34,9 +33,21 @@ async function globalTeardown(): Promise { // A leaked user breaks the *next* run outright — global-setup's createUser call hits a // username conflict with no clue why — so fail loudly here, at the point the leak actually // happens, instead of letting it surface as a confusing failure days later. - throw new Error(`Failed to delete test user ${user.id}: HTTP ${deleteRes.status}: ${await deleteRes.text()}`); + throw new Error(`Failed to delete ${label} ${user.id}: HTTP ${deleteRes.status}: ${await deleteRes.text()}`); } - console.log(`✓ Test user deleted: ${user.id}`); + console.log(`✓ ${label} deleted: ${user.id}`); +} + +async function globalTeardown(): Promise { + console.log('🧹 Deleting shared E2E test user...'); + await deleteUserByUsername(process.env.TEST_USER_USERNAME, 'Test user'); + + console.log('🧹 Deleting dedicated credential-test users...'); + await Promise.all( + CredentialTestUserApps.map((app) => + deleteUserByUsername(credentialTestUser(app).username, `Credential test user (${app})`), + ), + ); } export default globalTeardown; diff --git a/tests/e2e/pages/browser-quickstart.page.ts b/tests/e2e/pages/browser-quickstart.page.ts index d5d113a4..04b22b6c 100644 --- a/tests/e2e/pages/browser-quickstart.page.ts +++ b/tests/e2e/pages/browser-quickstart.page.ts @@ -70,17 +70,21 @@ export class BrowserQuickstartPage extends GateLoginPage { return `${header}.${payload}.${signature}`; } - /** Opens the "Manage Profile" dialog (src/components/profileDialog.js). */ + /** Opens the "Manage Account" page (src/pages/account.js) via the nav dropdown, then switches + * to its Personal info tab — the account page lands on the Home tab first, same as + * react/vue/quickstart's own Account page. Replaces the old "Manage Profile" popup; the field + * rendering/editing logic that used to back that popup (src/components/profileDialog.js) was + * kept and repurposed to feed this page's Personal info tab instead (renamed to + * profileFields.js). */ async openManageProfile(): Promise { await this.page.locator('#ud-trigger').click(); await this.page.locator('#ud-manage-profile').click(); - await this.page - .locator('#profile-dialog-overlay') - .waitFor({state: 'visible', timeout: Timeouts.ELEMENT_VISIBILITY}); + await this.page.locator('.account-nav-item[data-tab="personal"]').click(); + await this.page.locator('#profile-field-list').waitFor({state: 'visible', timeout: Timeouts.ELEMENT_VISIBILITY}); } - /** Edits one field of the profile dialog, which renders each schema attribute as its own row - * with a pencil "Edit" button.*/ + /** Edits one field of the Personal info tab, which renders each schema attribute as its own + * row with a pencil "Edit" button. */ async editProfileField(fieldKey: string, value: string): Promise { const row = this.page.locator(`.profile-field-row[data-field="${fieldKey}"]`); await row.locator('[data-action="edit"]').click(); @@ -95,9 +99,53 @@ export class BrowserQuickstartPage extends GateLoginPage { await expect(row.locator('.profile-field-row-value')).toHaveText(value, {timeout: Timeouts.ELEMENT_VISIBILITY}); } + /** Leaves the Account page via the nav's "‹ Home" back link, same as closing the old dialog + * used to return control to the main app shell. */ async closeManageProfile(): Promise { - await this.page.locator('#profile-dialog-close').click(); - await this.page.locator('#profile-dialog-overlay').waitFor({state: 'hidden', timeout: Timeouts.DEFAULT_ACTION}); + await this.page.locator('#nav-back-btn').click(); + await this.page.locator('#profile-field-list').waitFor({state: 'hidden', timeout: Timeouts.DEFAULT_ACTION}); + } + + /** Opens the Account page and switches to its Security tab, where each credential renders as + * a collapsed row (src/pages/account.js's `renderCredentialCard`) that expands into the real + * form — see {@link changeCredential}. */ + async openSecurityTab(): Promise { + await this.page.locator('#ud-trigger').click(); + await this.page.locator('#ud-manage-profile').click(); + await this.page.locator('.account-nav-item[data-tab="security"]').click(); + await this.page.locator('.account-security-list').waitFor({state: 'visible', timeout: Timeouts.ELEMENT_VISIBILITY}); + } + + /** Expands or collapses the named credential's row — the same button does both, keyed by the + * schema attribute (`"password"`) rather than its label. Call {@link openSecurityTab} first. */ + async toggleCredential(attribute: string): Promise { + await this.page.locator(`[data-cred-toggle="${attribute}"]`).click(); + } + + /** Fills the currently-open credential form's new-value and confirmation fields, without + * submitting. */ + async fillCredentialFields(newValue: string, confirmValue: string): Promise { + await this.page.locator('[data-cred-field="newValue"]').fill(newValue); + await this.page.locator('[data-cred-field="confirmValue"]').fill(confirmValue); + } + + /** Whether the currently-open credential form's submit button is disabled. */ + async isCredentialSubmitDisabled(): Promise { + return this.page.locator('[data-cred-submit]').isDisabled(); + } + + /** Expands the named credential's row, fills the new value and its confirmation, and + * submits. Waits for the row to collapse back afterward, which is this sample's own + * `onDone` behavior on a successful write — proof the change actually succeeded server-side + * rather than just that the button was clicked. Call {@link openSecurityTab} first. */ + async changeCredential(attribute: string, newValue: string): Promise { + const toggle = this.page.locator(`[data-cred-toggle="${attribute}"]`); + await toggle.click(); + + await this.fillCredentialFields(newValue, newValue); + await this.page.locator('[data-cred-submit]').click(); + + await expect(toggle).toHaveAttribute('aria-expanded', 'false', {timeout: Timeouts.ELEMENT_VISIBILITY}); } async verifyDisplayedName(fullName: string): Promise { diff --git a/tests/e2e/pages/thunderid-web-sample.page.ts b/tests/e2e/pages/thunderid-web-sample.page.ts index 2506e3b5..a34be7de 100644 --- a/tests/e2e/pages/thunderid-web-sample.page.ts +++ b/tests/e2e/pages/thunderid-web-sample.page.ts @@ -66,7 +66,7 @@ export class ThunderIDWebSamplePage extends GateLoginPage { * to wait on. A second click after hydration catches up recovers cleanly; this has been * observed to matter specifically for nuxt/quickstart under CI-level CPU contention, where the * gap is wide enough to lose the first click outright rather than just render it late. */ - private async openDropdown(target: Locator): Promise { + protected async openDropdown(target: Locator): Promise { const trigger = this.page.locator(USER_DROPDOWN_TRIGGER).first(); for (let attempt = 1; attempt <= 3; attempt++) { await trigger.click(); @@ -112,12 +112,13 @@ export class ThunderIDWebSamplePage extends GateLoginPage { return `${header}.${payload}.${signature}`; } - /** Opens the SDK-provided profile dialog — `UserDropdown`'s built-in profile action, present - * (and always on, no opt-in prop needed) in both the React and Vue packages, just under - * different labels: React's wrapped `UserDropdown` hardcodes "Manage Profile" - * (BaseUserDropdown.tsx's `handleManageProfile`); Vue's hardcodes plain "Profile" - * (BaseUserDropdown.ts:359, `onProfileClick`/`profileContent`). Nuxt inherits Vue's via its own - * `UserDropdown` wrapper, which delegates to the same `@thunderid/vue` component. */ + /** Opens the SDK-provided profile dialog — `UserDropdown`'s built-in profile action, under the + * plain "Profile" label. Nuxt inherits this via its own `UserDropdown` wrapper, which delegates + * to the same `@thunderid/vue` component; nextjs likewise inherits React's "Manage Profile" + * label and behavior. react/quickstart and vue/quickstart's own Nav components now override + * this action to redirect to a full Account page instead — see + * {@link ThunderIDAccountPageSamplePage} below for their variant of the methods in this + * section. */ async openManageProfile(): Promise { const profileButton = this.page.getByRole('button', {name: /^(Manage Profile|Profile)$/}); await this.openDropdown(profileButton); @@ -151,3 +152,95 @@ export class ThunderIDWebSamplePage extends GateLoginPage { await expect(row).toContainText(value, {timeout: Timeouts.ELEMENT_VISIBILITY}); } } + +/** + * Variant of {@link ThunderIDWebSamplePage} for react/quickstart and vue/quickstart, whose Nav + * components redirect `UserDropdown`'s profile action to a full "Manage Account" page (Home / + * Personal info / Security tabs) instead of opening the SDK's built-in profile popup. nuxt and + * nextjs are unaffected by that change — their own Nav components still use the base class's + * dialog-based behavior — so this exists as a separate subclass rather than a change to the + * shared base. + */ +export class ThunderIDAccountPageSamplePage extends ThunderIDWebSamplePage { + /** Opens the Account page via the nav dropdown's "Manage Account" item. Lands on the Home + * tab, same as the sidebar's own default. */ + private async openManageAccount(): Promise { + const manageAccountButton = this.page.getByRole('button', {name: 'Manage Account'}); + await this.openDropdown(manageAccountButton); + await manageAccountButton.click(); + } + + /** Opens the Account page and switches to its Personal info tab. */ + async openManageProfile(): Promise { + await this.openManageAccount(); + + const sidebar = this.page.getByRole('navigation'); + await sidebar.getByRole('button', {name: 'Personal info'}).click(); + await expect(this.page.getByRole('heading', {name: 'Personal info', level: 2})).toBeVisible({ + timeout: Timeouts.ELEMENT_VISIBILITY, + }); + } + + /** Opens the Account page and switches to its Security tab, where each credential + * (`ChangeCredential`) renders as a collapsed row that expands into the real form — see + * {@link changeCredential}. */ + async openSecurityTab(): Promise { + await this.openManageAccount(); + + const sidebar = this.page.getByRole('navigation'); + await sidebar.getByRole('button', {name: 'Security'}).click(); + await expect(this.page.getByRole('heading', {name: 'Security', level: 2})).toBeVisible({ + timeout: Timeouts.ELEMENT_VISIBILITY, + }); + } + + /** Expands or collapses the named credential's row (`cta` is the row's own toggle button + * text, e.g. "Change password") — the same button does both. Call {@link openSecurityTab} + * first. */ + async toggleCredential(cta: string): Promise { + await this.page.getByRole('button', {name: cta}).click(); + } + + /** Fills the currently-open credential form's new-value and confirmation fields, without + * submitting. */ + async fillCredentialFields(newValue: string, confirmValue: string): Promise { + await this.page.locator('input[name="newPassword"]').fill(newValue); + await this.page.locator('input[name="confirmPassword"]').fill(confirmValue); + } + + /** Whether the currently-open credential form's submit button is disabled. */ + async isCredentialSubmitDisabled(): Promise { + return this.page.getByRole('button', {name: /^Update /}).isDisabled(); + } + + /** Expands the named credential's row, fills the new value and its confirmation, and + * submits. Waits for the row to collapse back afterward — `ChangeCredential`'s `onSuccess` + * closes it in this sample, so that collapse is this method's proof the write actually + * succeeded server-side rather than just that the button was clicked. Call + * {@link openSecurityTab} first. */ + async changeCredential(cta: string, newValue: string): Promise { + const toggle = this.page.getByRole('button', {name: cta}); + await toggle.click(); + + await this.fillCredentialFields(newValue, newValue); + await this.page.getByRole('button', {name: /^Update /}).click(); + + await expect(toggle).toHaveAttribute('aria-expanded', 'false', {timeout: Timeouts.ELEMENT_VISIBILITY}); + } + + /** Same row structure as the base class's dialog variant (`BaseUserProfile` is unchanged — + * only where it's mounted changed), scoped to the page's `
    ` landmark instead of a + * dialog. */ + async editProfileField(label: RegExp, value: string): Promise { + const row = this.page.getByRole('main').getByText(label).locator('../..'); + await row.getByRole('button', {name: 'Edit'}).click(); + await row.locator('input').fill(value); + await row.getByRole('button', {name: 'Save'}).click(); + await expect(row.locator('input')).toHaveCount(0, {timeout: Timeouts.DEFAULT_ACTION}); + } + + async verifyProfileFieldValue(label: RegExp, value: string): Promise { + const row = this.page.getByRole('main').getByText(label).locator('../..'); + await expect(row).toContainText(value, {timeout: Timeouts.ELEMENT_VISIBILITY}); + } +} diff --git a/tests/e2e/tests/browser-quickstart/change-credential.spec.ts b/tests/e2e/tests/browser-quickstart/change-credential.spec.ts new file mode 100644 index 00000000..8e76217e --- /dev/null +++ b/tests/e2e/tests/browser-quickstart/change-credential.spec.ts @@ -0,0 +1,92 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +/** + * browser/quickstart — changing a credential (password) via the Account page's Security tab. + * See browser-quickstart/sign-in-out.spec.ts for the rest of the prerequisites. + * + * Signs in as a dedicated credential-test user (see constants/credential-test-users.ts), + * distinct from the shared TEST_USER_USERNAME every sign-in-out spec uses: TC001 below changes + * this user's password and changes it back (see the `finally` block), and the suite runs + * `fullyParallel`, so mutating the shared user's password would race every other + * concurrently-running spec's login across every app. The two tests in this file are run + * serially (below) so they don't race each other over their own shared dedicated user either. + */ + +import {credentialTestUser} from '../../constants/credential-test-users'; +import {SampleApps, sampleAppUrl} from '../../constants/sample-apps'; +import {expect, test} from '../../fixtures/sample-apps'; + +const appUrl = sampleAppUrl(SampleApps.BROWSER); +const {username, password} = credentialTestUser('BROWSER'); +// Derived from the real password rather than hardcoded, so this works regardless of what +// policy the schema's password attribute is configured with in a given environment. +const tempPassword = `${password}-Tmp1!`; + +test.describe.configure({mode: 'serial'}); + +test.describe('browser/quickstart - Change credential', () => { + test('TC001: a password change via the Security tab takes effect and can be reverted', async ({ + browserQuickstartPage, + }) => { + await browserQuickstartPage.goto(appUrl); + await browserQuickstartPage.verifyHomePageLoaded(); + await browserQuickstartPage.clickSignInButton(); + await browserQuickstartPage.verifyLoginPageLoaded(); + await browserQuickstartPage.login(username, password); + await browserQuickstartPage.verifyLoggedIn(); + + await browserQuickstartPage.openSecurityTab(); + + try { + await browserQuickstartPage.changeCredential('password', tempPassword); + + // Prove the change landed server-side, not just that the UI collapsed the form: sign + // out and back in using the new password. + await browserQuickstartPage.logout(); + await browserQuickstartPage.verifyLoggedOut(); + + await browserQuickstartPage.clickSignInButton(); + await browserQuickstartPage.verifyLoginPageLoaded(); + await browserQuickstartPage.login(username, tempPassword); + await browserQuickstartPage.verifyLoggedIn(); + + await browserQuickstartPage.openSecurityTab(); + } finally { + // Always attempt to restore the shared test user's original password, even if an + // assertion above failed. + await browserQuickstartPage.changeCredential('password', password); + } + + // The restore itself also has to have actually worked, or the next test to sign in with + // the original password would fail. + await browserQuickstartPage.logout(); + await browserQuickstartPage.verifyLoggedOut(); + await browserQuickstartPage.clickSignInButton(); + await browserQuickstartPage.verifyLoginPageLoaded(); + await browserQuickstartPage.login(username, password); + await browserQuickstartPage.verifyLoggedIn(); + }); + + test('TC002: the submit button stays disabled until the new value and confirmation match', async ({ + browserQuickstartPage, + }) => { + await browserQuickstartPage.goto(appUrl); + await browserQuickstartPage.verifyHomePageLoaded(); + await browserQuickstartPage.clickSignInButton(); + await browserQuickstartPage.verifyLoginPageLoaded(); + await browserQuickstartPage.login(username, password); + await browserQuickstartPage.verifyLoggedIn(); + + await browserQuickstartPage.openSecurityTab(); + await browserQuickstartPage.toggleCredential('password'); + + expect(await browserQuickstartPage.isCredentialSubmitDisabled()).toBe(true); + + await browserQuickstartPage.fillCredentialFields('mismatch-one', 'mismatch-two'); + expect(await browserQuickstartPage.isCredentialSubmitDisabled()).toBe(true); + + // Collapse without submitting — nothing was written, so there is nothing to restore. + await browserQuickstartPage.toggleCredential('password'); + }); +}); diff --git a/tests/e2e/tests/react-quickstart/change-credential.spec.ts b/tests/e2e/tests/react-quickstart/change-credential.spec.ts new file mode 100644 index 00000000..a3bb29e7 --- /dev/null +++ b/tests/e2e/tests/react-quickstart/change-credential.spec.ts @@ -0,0 +1,92 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +/** + * react/quickstart — changing a credential (password) via the Account page's Security tab. + * See react-quickstart/sign-in-out.spec.ts for the rest of the prerequisites. + * + * Signs in as a dedicated credential-test user (see constants/credential-test-users.ts), + * distinct from the shared TEST_USER_USERNAME every sign-in-out spec uses: TC001 below changes + * this user's password and changes it back (see the `finally` block), and the suite runs + * `fullyParallel`, so mutating the shared user's password would race every other + * concurrently-running spec's login across every app. The two tests in this file are run + * serially (below) so they don't race each other over their own shared dedicated user either. + */ + +import {credentialTestUser} from '../../constants/credential-test-users'; +import {SampleApps, sampleAppUrl} from '../../constants/sample-apps'; +import {expect, test} from '../../fixtures/sample-apps'; + +const appUrl = sampleAppUrl(SampleApps.REACT); +const {username, password} = credentialTestUser('REACT'); +// Derived from the real password rather than hardcoded, so this works regardless of what +// policy the schema's password attribute is configured with in a given environment. +const tempPassword = `${password}-Tmp1!`; + +test.describe.configure({mode: 'serial'}); + +test.describe('react/quickstart - Change credential', () => { + test('TC001: a password change via the Security tab takes effect and can be reverted', async ({ + reactQuickstartPage, + }) => { + await reactQuickstartPage.goto(appUrl); + await reactQuickstartPage.verifyHomePageLoaded(); + await reactQuickstartPage.clickSignInButton(); + await reactQuickstartPage.verifyLoginPageLoaded(); + await reactQuickstartPage.login(username, password); + await reactQuickstartPage.verifyLoggedIn(); + + await reactQuickstartPage.openSecurityTab(); + + try { + await reactQuickstartPage.changeCredential('Change password', tempPassword); + + // Prove the change landed server-side, not just that the UI collapsed the form: sign + // out and back in using the new password. + await reactQuickstartPage.logout(); + await reactQuickstartPage.verifyLoggedOut(); + + await reactQuickstartPage.clickSignInButton(); + await reactQuickstartPage.verifyLoginPageLoaded(); + await reactQuickstartPage.login(username, tempPassword); + await reactQuickstartPage.verifyLoggedIn(); + + await reactQuickstartPage.openSecurityTab(); + } finally { + // Always attempt to restore the shared test user's original password, even if an + // assertion above failed. + await reactQuickstartPage.changeCredential('Change password', password); + } + + // The restore itself also has to have actually worked, or the next test to sign in with + // the original password would fail. + await reactQuickstartPage.logout(); + await reactQuickstartPage.verifyLoggedOut(); + await reactQuickstartPage.clickSignInButton(); + await reactQuickstartPage.verifyLoginPageLoaded(); + await reactQuickstartPage.login(username, password); + await reactQuickstartPage.verifyLoggedIn(); + }); + + test('TC002: the submit button stays disabled until the new value and confirmation match', async ({ + reactQuickstartPage, + }) => { + await reactQuickstartPage.goto(appUrl); + await reactQuickstartPage.verifyHomePageLoaded(); + await reactQuickstartPage.clickSignInButton(); + await reactQuickstartPage.verifyLoginPageLoaded(); + await reactQuickstartPage.login(username, password); + await reactQuickstartPage.verifyLoggedIn(); + + await reactQuickstartPage.openSecurityTab(); + await reactQuickstartPage.toggleCredential('Change password'); + + expect(await reactQuickstartPage.isCredentialSubmitDisabled()).toBe(true); + + await reactQuickstartPage.fillCredentialFields('mismatch-one', 'mismatch-two'); + expect(await reactQuickstartPage.isCredentialSubmitDisabled()).toBe(true); + + // Collapse without submitting — nothing was written, so there is nothing to restore. + await reactQuickstartPage.toggleCredential('Change password'); + }); +}); diff --git a/tests/e2e/tests/vue-quickstart/change-credential.spec.ts b/tests/e2e/tests/vue-quickstart/change-credential.spec.ts new file mode 100644 index 00000000..420bb6ac --- /dev/null +++ b/tests/e2e/tests/vue-quickstart/change-credential.spec.ts @@ -0,0 +1,92 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +/** + * vue/quickstart — changing a credential (password) via the Account page's Security tab. See + * react-quickstart/sign-in-out.spec.ts for prerequisites; identical shape, different app. + * + * Signs in as a dedicated credential-test user (see constants/credential-test-users.ts), + * distinct from the shared TEST_USER_USERNAME every sign-in-out spec uses: TC001 below changes + * this user's password and changes it back (see the `finally` block), and the suite runs + * `fullyParallel`, so mutating the shared user's password would race every other + * concurrently-running spec's login across every app. The two tests in this file are run + * serially (below) so they don't race each other over their own shared dedicated user either. + */ + +import {credentialTestUser} from '../../constants/credential-test-users'; +import {SampleApps, sampleAppUrl} from '../../constants/sample-apps'; +import {expect, test} from '../../fixtures/sample-apps'; + +const appUrl = sampleAppUrl(SampleApps.VUE); +const {username, password} = credentialTestUser('VUE'); +// Derived from the real password rather than hardcoded, so this works regardless of what +// policy the schema's password attribute is configured with in a given environment. +const tempPassword = `${password}-Tmp1!`; + +test.describe.configure({mode: 'serial'}); + +test.describe('vue/quickstart - Change credential', () => { + test('TC001: a password change via the Security tab takes effect and can be reverted', async ({ + vueQuickstartPage, + }) => { + await vueQuickstartPage.goto(appUrl); + await vueQuickstartPage.verifyHomePageLoaded(); + await vueQuickstartPage.clickSignInButton(); + await vueQuickstartPage.verifyLoginPageLoaded(); + await vueQuickstartPage.login(username, password); + await vueQuickstartPage.verifyLoggedIn(); + + await vueQuickstartPage.openSecurityTab(); + + try { + await vueQuickstartPage.changeCredential('Change password', tempPassword); + + // Prove the change landed server-side, not just that the UI collapsed the form: sign + // out and back in using the new password. + await vueQuickstartPage.logout(); + await vueQuickstartPage.verifyLoggedOut(); + + await vueQuickstartPage.clickSignInButton(); + await vueQuickstartPage.verifyLoginPageLoaded(); + await vueQuickstartPage.login(username, tempPassword); + await vueQuickstartPage.verifyLoggedIn(); + + await vueQuickstartPage.openSecurityTab(); + } finally { + // Always attempt to restore the shared test user's original password, even if an + // assertion above failed. + await vueQuickstartPage.changeCredential('Change password', password); + } + + // The restore itself also has to have actually worked, or the next test to sign in with + // the original password would fail. + await vueQuickstartPage.logout(); + await vueQuickstartPage.verifyLoggedOut(); + await vueQuickstartPage.clickSignInButton(); + await vueQuickstartPage.verifyLoginPageLoaded(); + await vueQuickstartPage.login(username, password); + await vueQuickstartPage.verifyLoggedIn(); + }); + + test('TC002: the submit button stays disabled until the new value and confirmation match', async ({ + vueQuickstartPage, + }) => { + await vueQuickstartPage.goto(appUrl); + await vueQuickstartPage.verifyHomePageLoaded(); + await vueQuickstartPage.clickSignInButton(); + await vueQuickstartPage.verifyLoginPageLoaded(); + await vueQuickstartPage.login(username, password); + await vueQuickstartPage.verifyLoggedIn(); + + await vueQuickstartPage.openSecurityTab(); + await vueQuickstartPage.toggleCredential('Change password'); + + expect(await vueQuickstartPage.isCredentialSubmitDisabled()).toBe(true); + + await vueQuickstartPage.fillCredentialFields('mismatch-one', 'mismatch-two'); + expect(await vueQuickstartPage.isCredentialSubmitDisabled()).toBe(true); + + // Collapse without submitting — nothing was written, so there is nothing to restore. + await vueQuickstartPage.toggleCredential('Change password'); + }); +});