From 25f43196fe495789f6409935fec2580e1fd90245 Mon Sep 17 00:00:00 2001 From: janithjay Date: Fri, 4 Sep 2026 10:33:43 +0530 Subject: [PATCH] Implement change password functionality in JavaScript, React and Vue Signed-off-by: janithjay --- .../api/__tests__/updateMeCredentials.test.ts | 191 ++++++++++ .../javascript/src/api/updateMeCredentials.ts | 144 ++++++++ .../src/constants/CredentialConstants.ts | 29 ++ packages/javascript/src/i18n/models/i18n.ts | 22 ++ .../javascript/src/i18n/translations/en-US.ts | 22 ++ .../javascript/src/i18n/translations/fr-FR.ts | 25 ++ .../javascript/src/i18n/translations/hi-IN.ts | 23 ++ .../javascript/src/i18n/translations/ja-JP.ts | 23 ++ .../javascript/src/i18n/translations/pt-BR.ts | 22 ++ .../javascript/src/i18n/translations/pt-PT.ts | 23 ++ .../javascript/src/i18n/translations/si-LK.ts | 22 ++ .../javascript/src/i18n/translations/ta-IN.ts | 24 ++ .../javascript/src/i18n/translations/te-IN.ts | 22 ++ packages/javascript/src/index.ts | 11 + packages/javascript/src/models/config.ts | 6 + .../evaluateChangePasswordForm.test.ts | 75 ++++ .../__tests__/evaluatePasswordPolicy.test.ts | 35 ++ .../mapCredentialUpdateError.test.ts | 63 ++++ .../resolveChangePasswordPolicy.test.ts | 35 ++ .../__tests__/resolveResourceEndpoint.test.ts | 8 +- .../supportsPasswordCredential.test.ts | 37 ++ .../src/utils/evaluateChangePasswordForm.ts | 97 +++++ .../src/utils/evaluatePasswordPolicy.ts | 86 +++++ .../src/utils/mapCredentialUpdateError.ts | 77 ++++ .../src/utils/resolveChangePasswordPolicy.ts | 39 ++ .../src/utils/resolveResourceEndpoint.ts | 3 +- .../src/utils/supportsPasswordCredential.ts | 42 +++ .../api/__tests__/updateMeCredentials.test.ts | 67 ++++ packages/react/src/api/updateMeCredentials.ts | 91 +++++ .../BaseChangePassword.styles.ts | 124 +++++++ .../ChangePassword/BaseChangePassword.tsx | 335 ++++++++++++++++++ .../ChangePassword/ChangePassword.tsx | 123 +++++++ .../__tests__/ChangePassword.test.tsx | 286 +++++++++++++++ .../PasswordField/PasswordField.tsx | 9 +- packages/react/src/index.ts | 9 + .../api/update-me-credentials.test.ts | 67 ++++ .../components/change-password.test.ts | 261 ++++++++++++++ packages/vue/src/api/updateMeCredentials.ts | 63 ++++ .../change-password/BaseChangePassword.ts | 309 ++++++++++++++++ .../change-password/ChangePassword.css.ts | 106 ++++++ .../change-password/ChangePassword.ts | 129 +++++++ .../vue/src/components/primitives/Icons.ts | 218 +++++++----- .../primitives/PasswordField/PasswordField.ts | 8 + packages/vue/src/index.ts | 5 + packages/vue/src/styles/injectStyles.ts | 2 + 45 files changed, 3328 insertions(+), 90 deletions(-) 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__/resolveChangePasswordPolicy.test.ts create mode 100644 packages/javascript/src/utils/__tests__/supportsPasswordCredential.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/resolveChangePasswordPolicy.ts create mode 100644 packages/javascript/src/utils/supportsPasswordCredential.ts create mode 100644 packages/react/src/api/__tests__/updateMeCredentials.test.ts create mode 100644 packages/react/src/api/updateMeCredentials.ts create mode 100644 packages/react/src/components/presentation/ChangePassword/BaseChangePassword.styles.ts create mode 100644 packages/react/src/components/presentation/ChangePassword/BaseChangePassword.tsx create mode 100644 packages/react/src/components/presentation/ChangePassword/ChangePassword.tsx create mode 100644 packages/react/src/components/presentation/ChangePassword/__tests__/ChangePassword.test.tsx create mode 100644 packages/vue/src/__tests__/api/update-me-credentials.test.ts create mode 100644 packages/vue/src/__tests__/components/change-password.test.ts create mode 100644 packages/vue/src/api/updateMeCredentials.ts create mode 100644 packages/vue/src/components/presentation/change-password/BaseChangePassword.ts create mode 100644 packages/vue/src/components/presentation/change-password/ChangePassword.css.ts create mode 100644 packages/vue/src/components/presentation/change-password/ChangePassword.ts 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..ef6ba005 --- /dev/null +++ b/packages/javascript/src/api/__tests__/updateMeCredentials.test.ts @@ -0,0 +1,191 @@ +// 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 post the new credential 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['attributes']).toEqual({password: 'n3wP@ssword!'}); + }); + + it('should send currentPassword as a top-level field, not inside attributes', async (): Promise => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + status: 204, + text: () => Promise.resolve(''), + }); + + await updateMeCredentials({ + currentPassword: '0ldP@ssword!', + payload: {password: 'n3wP@ssword!'}, + 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['currentPassword']).toBe('0ldP@ssword!'); + expect(parsed['attributes']).toEqual({password: 'n3wP@ssword!'}); + expect(parsed['attributes']).not.toHaveProperty('currentPassword'); + }); + + it('should omit currentPassword when it is not provided', async (): Promise => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + status: 204, + text: () => Promise.resolve(''), + }); + + await updateMeCredentials({ + payload: {password: 'n3wP@ssword!'}, + 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).not.toHaveProperty('currentPassword'); + }); + + 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: 403, + statusText: 'Forbidden', + text: () => + Promise.resolve( + JSON.stringify({ + code: 'USR-1029', + message: {defaultValue: 'Invalid current password', key: 'error.userservice.invalid_current_password'}, + }), + ), + }); + + await expect( + updateMeCredentials({ + currentPassword: 'wrong', + payload: {password: 'n3wP@ssword!'}, + url: 'https://localhost:8090/users/me/update-credentials', + }), + ).rejects.toMatchObject({ + code: 'updateMeCredentials-ResponseError-001', + statusCode: 403, + }); + }); + + 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..78210f36 --- /dev/null +++ b/packages/javascript/src/api/updateMeCredentials.ts @@ -0,0 +1,144 @@ +// 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; + /** + * The user's existing password, sent for server-side verification before the new + * credential is written. Sent as a top-level request field rather than inside + * `payload`, because the server's credential allowlist only accepts attributes the + * user type schema declares as credentials. + */ + currentPassword?: string; + /** + * Optional custom fetcher function. + * If not provided, native fetch will be used + */ + fetcher?: (url: string, config: RequestInit) => Promise; + /** + * The credential attributes to write, keyed by credential type (e.g. `{password: '...'}`). + */ + payload: Record; + /** + * The absolute API endpoint. + */ + url?: string; +} + +/** + * Updates the signed-in user's credentials at the specified /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", + * currentPassword: "0ldP@ssword!", + * 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, + currentPassword, + 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 data: Record = {attributes: payload}; + + if (currentPassword) { + data['currentPassword'] = currentPassword; + } + + 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(data), + 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..cd4f74d9 --- /dev/null +++ b/packages/javascript/src/constants/CredentialConstants.ts @@ -0,0 +1,29 @@ +// 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, and `password` is the only non system-managed + * type a user can set for themselves. + * + * @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..2e15ad1d 100644 --- a/packages/javascript/src/i18n/models/i18n.ts +++ b/packages/javascript/src/i18n/models/i18n.ts @@ -103,6 +103,28 @@ export interface I18nTranslations { 'user.profile.heading': string; 'user.profile.update.generic.error': string; + /* |---------------------------------------------------------------| */ + /* | Change Password | */ + /* |---------------------------------------------------------------| */ + + 'user.change_password.heading': string; + 'user.change_password.current.label': string; + 'user.change_password.current.placeholder': 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.same.as.current.error': string; + 'user.change_password.current.invalid.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..60ce17f7 100644 --- a/packages/javascript/src/i18n/translations/en-US.ts +++ b/packages/javascript/src/i18n/translations/en-US.ts @@ -103,6 +103,28 @@ 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 Password', + 'user.change_password.current.label': 'Current Password', + 'user.change_password.current.placeholder': 'Enter your current password', + 'user.change_password.new.label': 'New Password', + 'user.change_password.new.placeholder': 'Enter your new password', + 'user.change_password.confirm.label': 'Confirm New Password', + 'user.change_password.confirm.placeholder': 'Re-enter your new password', + 'user.change_password.requirements.heading': 'Your password must have:', + 'user.change_password.submit': 'Update Password', + 'user.change_password.success': 'Your password has been updated.', + 'user.change_password.mismatch.error': 'Passwords do not match.', + 'user.change_password.same.as.current.error': 'Your new password must be different from your current password.', + 'user.change_password.current.invalid.error': 'Your current password is incorrect.', + 'user.change_password.generic.error': 'An error occurred while updating your password. Please try again.', + 'user.change_password.unavailable.heading': 'Password changes unavailable', + 'user.change_password.unavailable.description': 'This account does not use a password, so it cannot be changed here.', + '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..7060b6d1 100644 --- a/packages/javascript/src/i18n/translations/fr-FR.ts +++ b/packages/javascript/src/i18n/translations/fr-FR.ts @@ -105,6 +105,31 @@ 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': 'Changer le mot de passe', + 'user.change_password.current.label': 'Mot de passe actuel', + 'user.change_password.current.placeholder': 'Saisissez votre mot de passe actuel', + 'user.change_password.new.label': 'Nouveau mot de passe', + 'user.change_password.new.placeholder': 'Saisissez votre nouveau mot de passe', + 'user.change_password.confirm.label': 'Confirmer le nouveau mot de passe', + 'user.change_password.confirm.placeholder': 'Saisissez a nouveau votre nouveau mot de passe', + 'user.change_password.requirements.heading': 'Votre mot de passe doit contenir :', + 'user.change_password.submit': 'Mettre a jour le mot de passe', + 'user.change_password.success': 'Votre mot de passe a ete mis a jour.', + 'user.change_password.mismatch.error': 'Les mots de passe ne correspondent pas.', + 'user.change_password.same.as.current.error': + 'Votre nouveau mot de passe doit etre different du mot de passe actuel.', + 'user.change_password.current.invalid.error': 'Votre mot de passe actuel est incorrect.', + 'user.change_password.generic.error': + 'Une erreur est survenue lors de la mise a jour de votre mot de passe. Veuillez reessayer.', + 'user.change_password.unavailable.heading': 'Modification du mot de passe indisponible', + 'user.change_password.unavailable.description': + "Ce compte n'utilise pas de mot de passe, il ne peut donc pas être modifié ici.", + '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..531d50b6 100644 --- a/packages/javascript/src/i18n/translations/hi-IN.ts +++ b/packages/javascript/src/i18n/translations/hi-IN.ts @@ -103,6 +103,29 @@ const translations: I18nTranslations = { 'user.profile.heading': 'प्रोफ़ाइल', 'user.profile.update.generic.error': 'प्रोफ़ाइल अपडेट करते समय त्रुटि हुई। कृपया पुनः प्रयास करें।', + /* |---------------------------------------------------------------| */ + /* | Change Password | */ + /* |---------------------------------------------------------------| */ + + 'user.change_password.heading': 'पासवर्ड बदलें', + 'user.change_password.current.label': 'वर्तमान पासवर्ड', + 'user.change_password.current.placeholder': 'अपना वर्तमान पासवर्ड दर्ज करें', + 'user.change_password.new.label': 'नया पासवर्ड', + 'user.change_password.new.placeholder': 'अपना नया पासवर्ड दर्ज करें', + 'user.change_password.confirm.label': 'नए पासवर्ड की पुष्टि करें', + 'user.change_password.confirm.placeholder': 'अपना नया पासवर्ड फिर से दर्ज करें', + 'user.change_password.requirements.heading': 'आपके पासवर्ड में होना चाहिए:', + 'user.change_password.submit': 'पासवर्ड अपडेट करें', + 'user.change_password.success': 'आपका पासवर्ड अपडेट कर दिया गया है।', + 'user.change_password.mismatch.error': 'पासवर्ड मेल नहीं खाते।', + 'user.change_password.same.as.current.error': 'आपका नया पासवर्ड वर्तमान पासवर्ड से अलग होना चाहिए।', + 'user.change_password.current.invalid.error': 'आपका वर्तमान पासवर्ड गलत है।', + 'user.change_password.generic.error': 'पासवर्ड अपडेट करते समय त्रुटि हुई। कृपया पुनः प्रयास करें।', + 'user.change_password.unavailable.heading': 'पासवर्ड बदलना उपलब्ध नहीं है', + '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..84eeffd5 100644 --- a/packages/javascript/src/i18n/translations/ja-JP.ts +++ b/packages/javascript/src/i18n/translations/ja-JP.ts @@ -103,6 +103,29 @@ const translations: I18nTranslations = { 'user.profile.heading': 'プロフィール', 'user.profile.update.generic.error': 'プロフィール更新中にエラーが発生しました。もう一度お試しください。', + /* |---------------------------------------------------------------| */ + /* | Change Password | */ + /* |---------------------------------------------------------------| */ + + 'user.change_password.heading': 'パスワードの変更', + 'user.change_password.current.label': '現在のパスワード', + 'user.change_password.current.placeholder': '現在のパスワードを入力してください', + 'user.change_password.new.label': '新しいパスワード', + 'user.change_password.new.placeholder': '新しいパスワードを入力してください', + 'user.change_password.confirm.label': '新しいパスワードの確認', + 'user.change_password.confirm.placeholder': '新しいパスワードをもう一度入力してください', + 'user.change_password.requirements.heading': 'パスワードの要件:', + 'user.change_password.submit': 'パスワードを更新', + 'user.change_password.success': 'パスワードを更新しました。', + 'user.change_password.mismatch.error': 'パスワードが一致しません。', + 'user.change_password.same.as.current.error': '新しいパスワードは現在のパスワードと異なる必要があります。', + 'user.change_password.current.invalid.error': '現在のパスワードが正しくありません。', + 'user.change_password.generic.error': 'パスワードの更新中にエラーが発生しました。もう一度お試しください。', + 'user.change_password.unavailable.heading': 'パスワードの変更は利用できません', + '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..b4970b8c 100644 --- a/packages/javascript/src/i18n/translations/pt-BR.ts +++ b/packages/javascript/src/i18n/translations/pt-BR.ts @@ -103,6 +103,28 @@ 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 senha', + 'user.change_password.current.label': 'Senha atual', + 'user.change_password.current.placeholder': 'Digite sua senha atual', + 'user.change_password.new.label': 'Nova senha', + 'user.change_password.new.placeholder': 'Digite sua nova senha', + 'user.change_password.confirm.label': 'Confirmar nova senha', + 'user.change_password.confirm.placeholder': 'Digite novamente sua nova senha', + 'user.change_password.requirements.heading': 'Sua senha deve ter:', + 'user.change_password.submit': 'Atualizar senha', + 'user.change_password.success': 'Sua senha foi atualizada.', + 'user.change_password.mismatch.error': 'As senhas não coincidem.', + 'user.change_password.same.as.current.error': 'Sua nova senha deve ser diferente da senha atual.', + 'user.change_password.current.invalid.error': 'Sua senha atual está incorreta.', + 'user.change_password.generic.error': 'Ocorreu um erro ao atualizar sua senha. Tente novamente.', + 'user.change_password.unavailable.heading': 'Alteração de senha indisponível', + 'user.change_password.unavailable.description': 'Esta conta não usa senha, portanto ela não pode ser alterada aqui.', + '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..3cca40be 100644 --- a/packages/javascript/src/i18n/translations/pt-PT.ts +++ b/packages/javascript/src/i18n/translations/pt-PT.ts @@ -103,6 +103,29 @@ 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 palavra-passe', + 'user.change_password.current.label': 'Palavra-passe actual', + 'user.change_password.current.placeholder': 'Introduza a sua palavra-passe actual', + 'user.change_password.new.label': 'Nova palavra-passe', + 'user.change_password.new.placeholder': 'Introduza a sua nova palavra-passe', + 'user.change_password.confirm.label': 'Confirmar nova palavra-passe', + 'user.change_password.confirm.placeholder': 'Introduza novamente a sua nova palavra-passe', + 'user.change_password.requirements.heading': 'A sua palavra-passe deve ter:', + 'user.change_password.submit': 'Actualizar palavra-passe', + 'user.change_password.success': 'A sua palavra-passe foi actualizada.', + 'user.change_password.mismatch.error': 'As palavras-passe não coincidem.', + 'user.change_password.same.as.current.error': 'A sua nova palavra-passe deve ser diferente da actual.', + 'user.change_password.current.invalid.error': 'A sua palavra-passe actual está incorrecta.', + 'user.change_password.generic.error': 'Ocorreu um erro ao actualizar a sua palavra-passe. Tente novamente.', + 'user.change_password.unavailable.heading': 'Alteração de palavra-passe indisponível', + 'user.change_password.unavailable.description': + 'Esta conta não utiliza palavra-passe, pelo que não pode ser alterada aqui.', + '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..9969cc99 100644 --- a/packages/javascript/src/i18n/translations/si-LK.ts +++ b/packages/javascript/src/i18n/translations/si-LK.ts @@ -103,6 +103,28 @@ const translations: I18nTranslations = { 'user.profile.heading': 'පැතිකඩ', 'user.profile.update.generic.error': 'ඔබේ පැතිකඩ යාවත්කාලීන කිරීමේදී දෝෂයක් ඇතිවිය.කරුණාකර නැවත උත්සාහ කරන්න', + /* |---------------------------------------------------------------| */ + /* | Change Password | */ + /* |---------------------------------------------------------------| */ + + 'user.change_password.heading': 'මුරපදය වෙනස් කරන්න', + 'user.change_password.current.label': 'වත්මන් මුරපදය', + 'user.change_password.current.placeholder': 'ඔබේ වත්මන් මුරපදය ඇතුළත් කරන්න', + 'user.change_password.new.label': 'නව මුරපදය', + 'user.change_password.new.placeholder': 'ඔබේ නව මුරපදය ඇතුළත් කරන්න', + 'user.change_password.confirm.label': 'නව මුරපදය තහවුරු කරන්න', + 'user.change_password.confirm.placeholder': 'ඔබේ නව මුරපදය නැවත ඇතුළත් කරන්න', + 'user.change_password.requirements.heading': 'ඔබේ මුරපදයේ තිබිය යුතුය:', + 'user.change_password.submit': 'මුරපදය යාවත්කාලීන කරන්න', + 'user.change_password.success': 'ඔබේ මුරපදය යාවත්කාලීන කර ඇත.', + 'user.change_password.mismatch.error': 'මුරපද නොගැලපේ.', + 'user.change_password.same.as.current.error': 'ඔබේ නව මුරපදය වත්මන් මුරපදයට වඩා වෙනස් විය යුතුය.', + 'user.change_password.current.invalid.error': 'ඔබේ වත්මන් මුරපදය වැරදියි.', + 'user.change_password.generic.error': 'ඔබේ මුරපදය යාවත්කාලීන කිරීමේදී දෝෂයක් ඇතිවිය. කරුණාකර නැවත උත්සාහ කරන්න.', + 'user.change_password.unavailable.heading': 'මුරපදය වෙනස් කිරීම නොමැත', + '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..4b256610 100644 --- a/packages/javascript/src/i18n/translations/ta-IN.ts +++ b/packages/javascript/src/i18n/translations/ta-IN.ts @@ -104,6 +104,30 @@ const translations: I18nTranslations = { 'user.profile.update.generic.error': 'உங்கள் சுயவிவரத்தை புதுப்பிக்கும் போது பிழை ஏற்பட்டது. மீண்டும் முயற்சிக்கவும்.', + /* |---------------------------------------------------------------| */ + /* | Change Password | */ + /* |---------------------------------------------------------------| */ + + 'user.change_password.heading': 'கடவுச்சொல்லை மாற்று', + 'user.change_password.current.label': 'தற்போதைய கடவுச்சொல்', + 'user.change_password.current.placeholder': 'உங்கள் தற்போதைய கடவுச்சொல்லை உள்ளிடவும்', + 'user.change_password.new.label': 'புதிய கடவுச்சொல்', + 'user.change_password.new.placeholder': 'உங்கள் புதிய கடவுச்சொல்லை உள்ளிடவும்', + 'user.change_password.confirm.label': 'புதிய கடவுச்சொல்லை உறுதிப்படுத்தவும்', + 'user.change_password.confirm.placeholder': 'உங்கள் புதிய கடவுச்சொல்லை மீண்டும் உள்ளிடவும்', + 'user.change_password.requirements.heading': 'உங்கள் கடவுச்சொல்லில் இருக்க வேண்டியவை:', + 'user.change_password.submit': 'கடவுச்சொல்லைப் புதுப்பி', + 'user.change_password.success': 'உங்கள் கடவுச்சொல் புதுப்பிக்கப்பட்டது.', + 'user.change_password.mismatch.error': 'கடவுச்சொற்கள் பொருந்தவில்லை.', + 'user.change_password.same.as.current.error': 'உங்கள் புதிய கடவுச்சொல் தற்போதையதிலிருந்து வேறுபட்டிருக்க வேண்டும்.', + 'user.change_password.current.invalid.error': 'உங்கள் தற்போதைய கடவுச்சொல் தவறானது.', + 'user.change_password.generic.error': + 'உங்கள் கடவுச்சொல்லைப் புதுப்பிக்கும்போது பிழை ஏற்பட்டது. மீண்டும் முயற்சிக்கவும்.', + 'user.change_password.unavailable.heading': 'கடவுச்சொல் மாற்றம் கிடைக்கவில்லை', + '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..a12c3f12 100644 --- a/packages/javascript/src/i18n/translations/te-IN.ts +++ b/packages/javascript/src/i18n/translations/te-IN.ts @@ -104,6 +104,28 @@ const translations: I18nTranslations = { 'user.profile.heading': 'ప్రొఫైల్', 'user.profile.update.generic.error': 'ప్రొఫైల్ అప్‌డేట్ చేస్తూ లోపం వచ్చింది. దయచేసి మళ్లీ ప్రయత్నించండి.', + /* |---------------------------------------------------------------| */ + /* | Change Password | */ + /* |---------------------------------------------------------------| */ + + 'user.change_password.heading': 'పాస్‌వర్డ్ మార్చండి', + 'user.change_password.current.label': 'ప్రస్తుత పాస్‌వర్డ్', + 'user.change_password.current.placeholder': 'మీ ప్రస్తుత పాస్‌వర్డ్‌ను నమోదు చేయండి', + 'user.change_password.new.label': 'కొత్త పాస్‌వర్డ్', + 'user.change_password.new.placeholder': 'మీ కొత్త పాస్‌వర్డ్‌ను నమోదు చేయండి', + 'user.change_password.confirm.label': 'కొత్త పాస్‌వర్డ్‌ను నిర్ధారించండి', + 'user.change_password.confirm.placeholder': 'మీ కొత్త పాస్‌వర్డ్‌ను మళ్లీ నమోదు చేయండి', + 'user.change_password.requirements.heading': 'మీ పాస్‌వర్డ్‌లో ఉండవలసినవి:', + 'user.change_password.submit': 'పాస్‌వర్డ్ నవీకరించండి', + 'user.change_password.success': 'మీ పాస్‌వర్డ్ నవీకరించబడింది.', + 'user.change_password.mismatch.error': 'పాస్‌వర్డ్‌లు సరిపోలడం లేదు.', + 'user.change_password.same.as.current.error': 'మీ కొత్త పాస్‌వర్డ్ ప్రస్తుత పాస్‌వర్డ్‌కు భిన్నంగా ఉండాలి.', + 'user.change_password.current.invalid.error': 'మీ ప్రస్తుత పాస్‌వర్డ్ తప్పు.', + 'user.change_password.generic.error': 'మీ పాస్‌వర్డ్ నవీకరిస్తూ లోపం వచ్చింది. దయచేసి మళ్లీ ప్రయత్నించండి.', + 'user.change_password.unavailable.heading': 'పాస్‌వర్డ్ మార్పు అందుబాటులో లేదు', + '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..c51a7efd 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'; @@ -175,6 +178,14 @@ export { } from './utils/substituteTranslationParams'; export {default as removeTrailingSlash} from './utils/removeTrailingSlash'; export {default as resolveFieldName} from './utils/resolveFieldName'; +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 resolveChangePasswordPolicy} from './utils/resolveChangePasswordPolicy'; +export {default as supportsPasswordCredential} from './utils/supportsPasswordCredential'; +export {default as mapCredentialUpdateError} from './utils/mapCredentialUpdateError'; +export type {CredentialUpdateErrorField, CredentialUpdateErrorResult} from './utils/mapCredentialUpdateError'; export {default as resolveResourceEndpoint} from './utils/resolveResourceEndpoint'; export type {ResourceEndpointKey, ResourceEndpointConfig} from './utils/resolveResourceEndpoint'; export {default as resolveMeta} from './utils/resolveMeta'; diff --git a/packages/javascript/src/models/config.ts b/packages/javascript/src/models/config.ts index 594b039b..cbe85a22 100644 --- a/packages/javascript/src/models/config.ts +++ b/packages/javascript/src/models/config.ts @@ -234,6 +234,7 @@ export interface BaseConfig extends WithPreferences, WithExtensions * flowMeta: "https://rs.example.com/flow/meta", * usersMe: "https://rs.example.com/users/me", * usersMeMeta: "https://rs.example.com/users/me/meta", + * usersMeCredentials: "https://rs.example.com/users/me/update-credentials", * } */ endpoints?: { @@ -283,6 +284,11 @@ export interface BaseConfig extends WithPreferences, WithExtensions * If not provided, defaults to `{baseUrl}/users/me`. */ usersMe?: string; + /** + * The current-user credential endpoint URL used to change the signed-in user's password. + * 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__/evaluateChangePasswordForm.test.ts b/packages/javascript/src/utils/__tests__/evaluateChangePasswordForm.test.ts new file mode 100644 index 00000000..7612b979 --- /dev/null +++ b/packages/javascript/src/utils/__tests__/evaluateChangePasswordForm.test.ts @@ -0,0 +1,75 @@ +// 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!', + currentPassword: '0ldPassw0rd!', + 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.reusesCurrent).toBe(false); + 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 accept the form when the current password is empty', (): void => { + const result = evaluateChangePasswordForm(values({currentPassword: ''}), {}); + + expect(result.isValid).toBe(true); + expect(result.reusesCurrent).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 reusing the current password', (): void => { + const result = evaluateChangePasswordForm( + values({confirmPassword: '0ldPassw0rd!', newPassword: '0ldPassw0rd!'}), + {}, + ); + + expect(result.reusesCurrent).toBe(true); + expect(result.isValid).toBe(false); + }); + + it('should not flag reuse when both current and new are empty', (): void => { + const result = evaluateChangePasswordForm(values({currentPassword: '', newPassword: ''}), {}); + + expect(result.reusesCurrent).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..d7b465ff --- /dev/null +++ b/packages/javascript/src/utils/__tests__/mapCredentialUpdateError.test.ts @@ -0,0 +1,63 @@ +// 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 current password on 403', (): void => { + const result = mapCredentialUpdateError(apiError(403)); + + expect(result.field).toBe('currentPassword'); + expect(result.messageKey).toBe('user.change_password.current.invalid.error'); + expect(result.message).toBeUndefined(); + }); + + 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 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__/resolveChangePasswordPolicy.test.ts b/packages/javascript/src/utils/__tests__/resolveChangePasswordPolicy.test.ts new file mode 100644 index 00000000..101217c5 --- /dev/null +++ b/packages/javascript/src/utils/__tests__/resolveChangePasswordPolicy.test.ts @@ -0,0 +1,35 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {describe, expect, it} from 'vitest'; +import {AttributeSchema} from '../../api/getUsersMeMeta'; +import resolveChangePasswordPolicy from '../resolveChangePasswordPolicy'; + +const schemaWith = (regex?: string): Record => + ({password: {regex}}) as unknown as Record; + +describe('resolveChangePasswordPolicy', (): void => { + it('should derive the policy from the schema password regex', (): void => { + expect(resolveChangePasswordPolicy(schemaWith('^.{8,}$'))).toEqual({regex: '^.{8,}$'}); + }); + + it('should return an empty policy when the schema carries no regex', (): void => { + expect(resolveChangePasswordPolicy(schemaWith(undefined))).toEqual({}); + }); + + it('should return an empty policy when the schema has no password attribute', (): void => { + expect(resolveChangePasswordPolicy({} as Record)).toEqual({}); + }); + + it.each([[null], [undefined]])('should tolerate a %s schema', (schema: null | undefined): void => { + expect(resolveChangePasswordPolicy(schema)).toEqual({}); + }); + + it('should let an explicit override win over the schema', (): void => { + expect(resolveChangePasswordPolicy(schemaWith('^.{8,}$'), {regex: '^.{12,}$'})).toEqual({regex: '^.{12,}$'}); + }); + + it('should honour an override that deliberately configures no rules', (): void => { + expect(resolveChangePasswordPolicy(schemaWith('^.{8,}$'), {})).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__/supportsPasswordCredential.test.ts b/packages/javascript/src/utils/__tests__/supportsPasswordCredential.test.ts new file mode 100644 index 00000000..b53095f8 --- /dev/null +++ b/packages/javascript/src/utils/__tests__/supportsPasswordCredential.test.ts @@ -0,0 +1,37 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {describe, expect, it} from 'vitest'; +import {AttributeSchema} from '../../api/getUsersMeMeta'; +import supportsPasswordCredential from '../supportsPasswordCredential'; + +describe('supportsPasswordCredential', (): void => { + it('should accept a schema that defines a password attribute', (): void => { + expect(supportsPasswordCredential({password: {credential: true, type: 'string'}})).toBe(true); + }); + + it('should accept a password attribute that carries no metadata', (): void => { + expect(supportsPasswordCredential({password: {}})).toBe(true); + }); + + it('should accept a password attribute the schema marks optional', (): void => { + expect(supportsPasswordCredential({password: {credential: true, required: false}})).toBe(true); + }); + + it('should reject a schema that declares another credential but no password', (): void => { + const schema: Record = { + email: {type: 'string', unique: true}, + pin: {credential: true, type: 'string'}, + }; + + expect(supportsPasswordCredential(schema)).toBe(false); + }); + + it('should reject an empty schema', (): void => { + expect(supportsPasswordCredential({})).toBe(false); + }); + + it.each([[null], [undefined]])('should accept an unresolved schema (%s)', (schema): void => { + expect(supportsPasswordCredential(schema)).toBe(true); + }); +}); diff --git a/packages/javascript/src/utils/evaluateChangePasswordForm.ts b/packages/javascript/src/utils/evaluateChangePasswordForm.ts new file mode 100644 index 00000000..a5dbc2e2 --- /dev/null +++ b/packages/javascript/src/utils/evaluateChangePasswordForm.ts @@ -0,0 +1,97 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import evaluatePasswordPolicy, {PasswordPolicy, PasswordRuleResult} from './evaluatePasswordPolicy'; + +/** + * The three values a change-password form collects. + */ +export interface ChangePasswordFormValues { + /** + * The re-typed new password, used only to catch typos client-side. + */ + confirmPassword: string; + /** + * The user's existing password, or an empty string on an account that has none yet. + * Deliberately not required for the form to be submittable, since only the server knows + * whether this account has a password to verify against. + */ + currentPassword: 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. + * + * `currentPassword` is not part of this check. A user whose account has no password yet has + * nothing to type there, and the client cannot tell that case apart from a user who simply + * left the field blank, so gating submission on it would lock the first group out of setting + * a password at all. The server verifies it when the account has one, and answers with a + * `403` otherwise. + */ + isValid: boolean; + /** + * Whether the new password satisfies every configured rule. + */ + meetsPolicy: boolean; + /** + * Whether the new password is the one already in use. Rejected client-side because the + * server treats it as a valid write, leaving the user believing something changed. + */ + reusesCurrent: 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, currentPassword, newPassword}, + * {regex: '^.{12,}$'}, + * ); + * const canSubmit = !loading && isValid; + * ``` + */ +const evaluateChangePasswordForm = ( + values: ChangePasswordFormValues, + policy: PasswordPolicy, +): ChangePasswordFormEvaluation => { + const {confirmPassword, currentPassword, newPassword} = values; + + const ruleResults: PasswordRuleResult[] = evaluatePasswordPolicy(newPassword, policy); + const meetsPolicy: boolean = ruleResults.every((rule: PasswordRuleResult) => rule.passed); + const confirmMatches: boolean = newPassword === confirmPassword; + const reusesCurrent: boolean = newPassword !== '' && newPassword === currentPassword; + + const isValid: boolean = + newPassword !== '' && confirmPassword !== '' && meetsPolicy && confirmMatches && !reusesCurrent; + + return {confirmMatches, isValid, meetsPolicy, reusesCurrent, 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..1e68b689 --- /dev/null +++ b/packages/javascript/src/utils/mapCredentialUpdateError.ts @@ -0,0 +1,77 @@ +// 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 = 'currentPassword' | '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. + * + * `403` is the server rejecting the supplied current password; `400` is the new password + * failing a server-side check. Anything else has no single field to blame. + * + * @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 === 403) { + return {field: 'currentPassword', messageKey: 'user.change_password.current.invalid.error'}; + } + + 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/resolveChangePasswordPolicy.ts b/packages/javascript/src/utils/resolveChangePasswordPolicy.ts new file mode 100644 index 00000000..2ed889a9 --- /dev/null +++ b/packages/javascript/src/utils/resolveChangePasswordPolicy.ts @@ -0,0 +1,39 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {PasswordPolicy} from './evaluatePasswordPolicy'; +import {AttributeSchema} from '../api/getUsersMeMeta'; +import CredentialConstants from '../constants/CredentialConstants'; + +/** + * Resolves the password rules a change-password form should enforce. + * + * The organization's own password policy, expressed as the `password` 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 password the + * organization's policy accepts. When the schema carries no regex, 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 override - An explicit policy supplied by the caller, which wins outright. + * @returns The policy to hand to {@link evaluatePasswordPolicy}. + * @example + * ```typescript + * const policy = resolveChangePasswordPolicy(userSchema, undefined); + * const results = evaluatePasswordPolicy(candidate, policy); + * ``` + */ +const resolveChangePasswordPolicy = ( + userSchema: Record | null | undefined, + override?: PasswordPolicy, +): PasswordPolicy => { + if (override) { + return override; + } + + const schemaRegex: string | undefined = userSchema?.[CredentialConstants.PASSWORD]?.regex; + + return schemaRegex ? {regex: schemaRegex} : {}; +}; + +export default resolveChangePasswordPolicy; 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/supportsPasswordCredential.ts b/packages/javascript/src/utils/supportsPasswordCredential.ts new file mode 100644 index 00000000..505f3cf3 --- /dev/null +++ b/packages/javascript/src/utils/supportsPasswordCredential.ts @@ -0,0 +1,42 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {AttributeSchema} from '../api/getUsersMeMeta'; +import CredentialConstants from '../constants/CredentialConstants'; + +/** + * Whether the signed-in user's type allows them to set a password for themselves. + * + * The user type schema from `GET /users/me/meta` is the only thing a client can check before + * rendering. It says whether `password` is a credential this type has 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: it verifies the current password + * when the account has one, and accepts a first-time set when it does not. + * + * Without this check the form would submit into a guaranteed failure. `POST + * /users/me/update-credentials` rejects a `password` write on a type that declares no password + * attribute, 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-password + * 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. + * @returns `false` only when the schema is known and defines no `password` attribute. + * @example + * ```typescript + * if (!supportsPasswordCredential(userSchema)) { + * return null; + * } + * ``` + */ +const supportsPasswordCredential = (userSchema: Record | null | undefined): boolean => { + if (!userSchema) { + return true; + } + + return userSchema[CredentialConstants.PASSWORD] !== undefined; +}; + +export default supportsPasswordCredential; diff --git a/packages/react/src/api/__tests__/updateMeCredentials.test.ts b/packages/react/src/api/__tests__/updateMeCredentials.test.ts new file mode 100644 index 00000000..2b96d4c6 --- /dev/null +++ b/packages/react/src/api/__tests__/updateMeCredentials.test.ts @@ -0,0 +1,67 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {afterEach, describe, expect, it, vi} from 'vitest'; +import updateMeCredentials from '../updateMeCredentials'; + +const mockRequest = vi.fn(); + +vi.mock('@thunderid/browser', async () => { + const actual = await vi.importActual('@thunderid/browser'); + return { + ...actual, + FetchHttpClient: {getInstance: () => ({request: mockRequest})}, + }; +}); + +describe('updateMeCredentials (react)', () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it('surfaces the real status code when the current password is rejected', async () => { + // httpClient.request throws (rather than resolving) on a non-2xx response, carrying the + // response on the error. The default fetcher must convert that back into a resolved, + // non-ok Response so the caller sees the real 403, not a generic network error. + mockRequest.mockRejectedValueOnce( + Object.assign(new Error('Forbidden'), { + response: { + data: {code: 'USR-1029', message: {defaultValue: 'Invalid current password'}}, + status: 403, + statusText: 'Forbidden', + }, + }), + ); + + await expect( + updateMeCredentials({ + currentPassword: 'wrong', + payload: {password: 'n3wP@ssword!'}, + url: 'https://localhost:8090/users/me/update-credentials', + }), + ).rejects.toMatchObject({statusCode: 403}); + }); + + it('still throws a network error when the request never reaches the server', async () => { + mockRequest.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 () => { + mockRequest.mockResolvedValueOnce({data: undefined, status: 204, statusText: 'No Content'}); + + await expect( + updateMeCredentials({ + currentPassword: '0ldP@ssword!', + payload: {password: 'n3wP@ssword!'}, + url: 'https://localhost:8090/users/me/update-credentials', + }), + ).resolves.toBeUndefined(); + }); +}); diff --git a/packages/react/src/api/updateMeCredentials.ts b/packages/react/src/api/updateMeCredentials.ts new file mode 100644 index 00000000..97e3c9f8 --- /dev/null +++ b/packages/react/src/api/updateMeCredentials.ts @@ -0,0 +1,91 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import { + HttpError, + HttpResponse, + FetchHttpClient, + HttpRequestConfig, + 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 attaches the access token to the request. + */ + fetcher?: (url: string, config: RequestInit) => Promise; + /** + * Optional instance ID for multi-instance support. Defaults to 0. + */ + instanceId?: number; +} + +/** + * Updates the signed-in user's credentials at the specified /users/me/update-credentials endpoint. + * This function uses the ThunderID SPA client's httpClient by default, but allows for custom fetchers. + * + * The endpoint responds with `204 No Content`, so this resolves with `void`. + * + * @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 ThunderID SPA client httpClient + * await updateMeCredentials({ + * url: "https://localhost:8090/users/me/update-credentials", + * currentPassword: "0ldP@ssword!", + * payload: { password: "n3wP@ssword!" } + * }); + * ``` + */ +const updateMeCredentials = async ({ + fetcher, + instanceId = 0, + ...requestConfig +}: UpdateMeCredentialsConfig): Promise => { + const defaultFetcher = 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) { + // httpClient.request throws on a non-2xx response rather than resolving it, so an error + // that carries a real HTTP response is converted back into one here. That lets the core + // updateMeCredentials see the actual status and body instead of treating it as a network + // failure. A genuine network error (no response) still propagates. + const httpError: HttpError = error as HttpError; + if (httpError?.response) { + return toResponse(httpError.response.data, httpError.response.status, httpError.response.statusText ?? ''); + } + throw error; + } + }; + + return baseUpdateMeCredentials({ + ...requestConfig, + fetcher: fetcher || defaultFetcher, + }); +}; + +export default updateMeCredentials; diff --git a/packages/react/src/components/presentation/ChangePassword/BaseChangePassword.styles.ts b/packages/react/src/components/presentation/ChangePassword/BaseChangePassword.styles.ts new file mode 100644 index 00000000..130b8135 --- /dev/null +++ b/packages/react/src/components/presentation/ChangePassword/BaseChangePassword.styles.ts @@ -0,0 +1,124 @@ +// 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 BaseChangePassword 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; + `; + + 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/ChangePassword/BaseChangePassword.tsx b/packages/react/src/components/presentation/ChangePassword/BaseChangePassword.tsx new file mode 100644 index 00000000..891eabf8 --- /dev/null +++ b/packages/react/src/components/presentation/ChangePassword/BaseChangePassword.tsx @@ -0,0 +1,335 @@ +// 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 './BaseChangePassword.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`. + */ +export interface ChangePasswordValues { + /** + * The user's existing password. + */ + currentPassword: string; + /** + * The password to set. + */ + newPassword: string; +} + +export interface BaseChangePasswordProps { + /** + * Whether to wrap the form in a bordered card. + */ + cardLayout?: boolean; + /** + * Additional CSS class names + */ + className?: 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 (`currentPassword` or `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 password 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 ChangePassword}) 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; + /** + * Whether the account cannot have a password changed at all, because the user type's schema + * defines no `password` attribute. 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-password form. + * + * Holds no context and performs no network calls: it renders the fields, evaluates the + * password policy for the checklist and the submit gate, and hands validated values to + * `onSubmit`. Use {@link ChangePassword} for the context-wired variant. + * + * @example + * ```tsx + * save(currentPassword, newPassword)} + * /> + * ``` + */ +const BaseChangePassword: FC = ({ + cardLayout = false, + className = '', + error = null, + fieldErrors = {}, + loading = false, + onSubmit = undefined, + policy = {}, + preferences = undefined, + showRequirements = true, + success = false, + unavailable = false, +}: BaseChangePasswordProps) => { + const {theme, colorScheme}: ReturnType = useTheme(); + const styles: Record = useStyles(theme, colorScheme); + const {t} = useTranslation(preferences?.i18n); + + const [currentPassword, setCurrentPassword] = useState(''); + const [newPassword, setNewPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [submitted, setSubmitted] = useState(false); + + // Clear the entered passwords 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) { + setCurrentPassword(''); + setNewPassword(''); + setConfirmPassword(''); + setSubmitted(false); + } + } + + const {confirmMatches, isValid, reusesCurrent, ruleResults}: ChangePasswordFormEvaluation = useMemo( + () => evaluateChangePasswordForm({confirmPassword, currentPassword, newPassword}, policy), + [confirmPassword, currentPassword, newPassword, policy], + ); + + // A schema with no password attribute 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?.({currentPassword, newPassword}); + }; + + const renderRequirement = (rule: PasswordRuleResult): ReactElement => ( +
  • + + {rule.passed ? : } + + + {t(rule.messageKey, rule.params)} + +
  • + ); + + // Only surface the local errors 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') : undefined; + const newPasswordError: string | undefined = + fieldErrors['newPassword'] ?? + (submitted && reusesCurrent ? t('user.change_password.same.as.current.error') : undefined); + + const form: ReactElement = ( +
    + + {t('user.change_password.heading')} + + + {error && ( + + {t('errors.heading') || 'Error'} + {error} + + )} + + {success && ( + + {t('user.change_password.success')} + + )} + +
    + + + + + {showRequirements && ruleResults.length > 0 && ( +
    + + {t('user.change_password.requirements.heading')} + +
      {ruleResults.map(renderRequirement)}
    +
    + )} + + +
    + +
    + +
    +
    + ); + + if (!unavailable) { + return form; + } + + return ( +
    + + +
    + + {t('user.change_password.unavailable.heading')} + {t('user.change_password.unavailable.description')} + +
    +
    + ); +}; + +export default BaseChangePassword; diff --git a/packages/react/src/components/presentation/ChangePassword/ChangePassword.tsx b/packages/react/src/components/presentation/ChangePassword/ChangePassword.tsx new file mode 100644 index 00000000..60453162 --- /dev/null +++ b/packages/react/src/components/presentation/ChangePassword/ChangePassword.tsx @@ -0,0 +1,123 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import { + CredentialConstants, + CredentialUpdateErrorResult, + PasswordPolicy, + mapCredentialUpdateError, + resolveChangePasswordPolicy, + resolveResourceEndpoint, + supportsPasswordCredential, +} from '@thunderid/browser'; +import {FC, useMemo, useState} from 'react'; +import BaseChangePassword, {BaseChangePasswordProps, ChangePasswordValues} from './BaseChangePassword'; +import updateMeCredentials from '../../../api/updateMeCredentials'; +import useThunderID from '../../../contexts/ThunderID/useThunderID'; +import useUser from '../../../contexts/User/useUser'; +import useTranslation from '../../../hooks/useTranslation'; + +export interface ChangePasswordProps + extends Omit { + /** + * Called after the password has been changed successfully. + */ + onSuccess?: () => void; +} + +/** + * ChangePassword lets the signed-in user set a new password for their own account. + * + * It reads the password 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. + * + * @example + * ```tsx + * // Basic usage + * toast('Password updated')} /> + * + * // With an explicit rule instead of the schema-derived policy + * + * ``` + */ +const ChangePassword: FC = ({ + onSuccess = undefined, + policy = undefined, + preferences = undefined, + ...rest +}: ChangePasswordProps) => { + const {baseUrl, endpoints, instanceId, preferences: contextPreferences} = useThunderID(); + const {userSchema} = useUser(); + + 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( + () => resolveChangePasswordPolicy(userSchema, policy), + [userSchema, policy], + ); + + const handleSubmit = async ({currentPassword, newPassword}: ChangePasswordValues): Promise => { + setError(null); + setFieldErrors({}); + setSuccess(false); + setLoading(true); + + try { + await updateMeCredentials({ + baseUrl, + currentPassword: currentPassword || undefined, + instanceId, + payload: {[CredentialConstants.PASSWORD]: newPassword}, + url: resolveResourceEndpoint('usersMeCredentials', {endpoints}), + }); + + setSuccess(true); + onSuccess?.(); + } catch (caughtError: unknown) { + const {field, message, messageKey}: CredentialUpdateErrorResult = mapCredentialUpdateError(caughtError); + const text: string = message ?? t(messageKey); + + if (field) { + setFieldErrors({[field]: text}); + } else { + setError(text); + } + } finally { + setLoading(false); + } + }; + + return ( + { + void handleSubmit(values); + }} + /> + ); +}; + +export default ChangePassword; diff --git a/packages/react/src/components/presentation/ChangePassword/__tests__/ChangePassword.test.tsx b/packages/react/src/components/presentation/ChangePassword/__tests__/ChangePassword.test.tsx new file mode 100644 index 00000000..65e6ec75 --- /dev/null +++ b/packages/react/src/components/presentation/ChangePassword/__tests__/ChangePassword.test.tsx @@ -0,0 +1,286 @@ +// 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 ChangePassword from '../ChangePassword'; + +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 renderChangePassword = ( + 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}}); +}; + +const submitButton = (): HTMLButtonElement => screen.getByRole('button', {name: /update password/i}); + +describe('ChangePassword', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + cleanup(); + }); + + it('renders the three fields by default', () => { + renderChangePassword(); + + expect(fieldByName('currentPassword')).toBeTruthy(); + expect(fieldByName('newPassword')).toBeTruthy(); + expect(fieldByName('confirmPassword')).toBeTruthy(); + }); + + it('marks the new password fields as new-password for password managers', () => { + renderChangePassword(); + + expect(fieldByName('currentPassword').getAttribute('autocomplete')).toBe('current-password'); + 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', () => { + renderChangePassword({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', () => { + renderChangePassword({policy: {regex: '^.{8,}$'}}); + + expect(submitButton().disabled).toBe(true); + + setField('currentPassword', '0ldP@ssword!'); + 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', () => { + renderChangePassword({policy: {regex: '^.{8,}$'}}); + + setField('currentPassword', '0ldP@ssword!'); + setField('newPassword', 'longenough1'); + setField('confirmPassword', 'different99'); + + expect(submitButton().disabled).toBe(true); + expect(mockUpdateMeCredentials).not.toHaveBeenCalled(); + }); + + it('blocks reusing the current password as the new one', () => { + renderChangePassword({policy: {regex: '^.{8,}$'}}); + + setField('currentPassword', 'longenough1'); + setField('newPassword', 'longenough1'); + setField('confirmPassword', 'longenough1'); + + expect(submitButton().disabled).toBe(true); + }); + + it('sends the current password alongside the new one', async () => { + mockUpdateMeCredentials.mockResolvedValueOnce(undefined); + renderChangePassword({policy: {regex: '^.{8,}$'}}); + + setField('currentPassword', '0ldP@ssword!'); + 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', + currentPassword: '0ldP@ssword!', + payload: {password: 'n3wP@ssword'}, + }), + ); + }); + + it('shows the success alert and fires onSuccess', async () => { + mockUpdateMeCredentials.mockResolvedValueOnce(undefined); + const onSuccess = vi.fn(); + renderChangePassword({onSuccess, policy: {regex: '^.{8,}$'}}); + + setField('currentPassword', '0ldP@ssword!'); + 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); + renderChangePassword({policy: {regex: '^.{8,}$'}}); + + setField('currentPassword', '0ldP@ssword!'); + setField('newPassword', 'n3wP@ssword'); + setField('confirmPassword', 'n3wP@ssword'); + fireEvent.click(submitButton()); + + await waitFor(() => expect(fieldByName('newPassword').value).toBe('')); + + expect(fieldByName('currentPassword').value).toBe(''); + expect(fieldByName('confirmPassword').value).toBe(''); + }); + + it('maps a 403 onto the current password field', async () => { + const {ThunderIDAPIError} = await import('@thunderid/browser'); + + mockUpdateMeCredentials.mockRejectedValueOnce( + new ThunderIDAPIError('Invalid current password', 'x-001', 'react', 403, 'Forbidden'), + ); + renderChangePassword({policy: {regex: '^.{8,}$'}}); + + setField('currentPassword', 'wrong-but-long'); + setField('newPassword', 'n3wP@ssword'); + setField('confirmPassword', 'n3wP@ssword'); + fireEvent.click(submitButton()); + + await waitFor(() => expect(screen.getByText(/your current password is incorrect/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'), + ); + renderChangePassword({policy: {regex: '^.{8,}$'}}); + + setField('currentPassword', '0ldP@ssword!'); + 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'}, + }, + }); + + renderChangePassword({}, 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('applies no client-side rules when the schema has no password regex', () => { + renderChangePassword({}, 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', () => { + renderChangePassword({}, 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', () => { + renderChangePassword({}, buildUserContext({userSchema: schemaWithoutPassword})); + + expect(fieldByName('newPassword')).toBeTruthy(); + }); + + it('disables every control so nothing is reachable behind the overlay', () => { + renderChangePassword({}, buildUserContext({userSchema: schemaWithoutPassword})); + + expect(fieldByName('currentPassword').disabled).toBe(true); + 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', () => { + renderChangePassword({}, buildUserContext({userSchema: schemaWithoutPassword})); + + fireEvent.submit(document.querySelector('form')!); + + expect(mockUpdateMeCredentials).not.toHaveBeenCalled(); + }); + + it('renders the usable form when the schema does define a password', () => { + renderChangePassword({}, buildUserContext({userSchema: {password: {credential: true, type: 'string'}}})); + + expect(screen.queryByRole('status')).toBeNull(); + expect(fieldByName('newPassword').disabled).toBe(false); + }); + }); +}); 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..79c7b395 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -133,6 +133,12 @@ export * from './components/presentation/User/BaseUser'; export {default as User} from './components/presentation/User/User'; export * from './components/presentation/User/User'; +export {default as BaseChangePassword} from './components/presentation/ChangePassword/BaseChangePassword'; +export * from './components/presentation/ChangePassword/BaseChangePassword'; + +export {default as ChangePassword} from './components/presentation/ChangePassword/ChangePassword'; +export * from './components/presentation/ChangePassword/ChangePassword'; + export {default as BaseUserProfile} from './components/presentation/UserProfile/BaseUserProfile'; export * from './components/presentation/UserProfile/BaseUserProfile'; @@ -226,6 +232,9 @@ export {createField, FieldFactory, validateFieldValue} from './components/factor export {default as BuildingAlt} from './components/primitives/Icons/BuildingAlt'; +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'; export {default as getMeProfile} from './api/getUsersMe'; 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..505b0753 --- /dev/null +++ b/packages/vue/src/__tests__/api/update-me-credentials.test.ts @@ -0,0 +1,67 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {afterEach, describe, expect, it, vi} from 'vitest'; +import updateMeCredentials from '../../api/updateMeCredentials'; + +const mockRequest = vi.fn(); + +vi.mock('@thunderid/browser', async () => { + const actual = await vi.importActual('@thunderid/browser'); + return { + ...actual, + FetchHttpClient: {getInstance: () => ({request: mockRequest})}, + }; +}); + +describe('updateMeCredentials (vue)', () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it('surfaces the real status code when the current password is rejected', async () => { + // httpClient.request throws (rather than resolving) on a non-2xx response, carrying the + // response on the error. The default fetcher must convert that back into a resolved, + // non-ok Response so the caller sees the real 403, not a generic network error. + mockRequest.mockRejectedValueOnce( + Object.assign(new Error('Forbidden'), { + response: { + data: {code: 'USR-1029', message: {defaultValue: 'Invalid current password'}}, + status: 403, + statusText: 'Forbidden', + }, + }), + ); + + await expect( + updateMeCredentials({ + currentPassword: 'wrong', + payload: {password: 'n3wP@ssword!'}, + url: 'https://localhost:8090/users/me/update-credentials', + }), + ).rejects.toMatchObject({statusCode: 403}); + }); + + it('still throws a network error when the request never reaches the server', async () => { + mockRequest.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 () => { + mockRequest.mockResolvedValueOnce({data: undefined, status: 204, statusText: 'No Content'}); + + await expect( + updateMeCredentials({ + currentPassword: '0ldP@ssword!', + payload: {password: 'n3wP@ssword!'}, + url: 'https://localhost:8090/users/me/update-credentials', + }), + ).resolves.toBeUndefined(); + }); +}); diff --git a/packages/vue/src/__tests__/components/change-password.test.ts b/packages/vue/src/__tests__/components/change-password.test.ts new file mode 100644 index 00000000..1d5a0dfd --- /dev/null +++ b/packages/vue/src/__tests__/components/change-password.test.ts @@ -0,0 +1,261 @@ +// 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 ChangePassword from '../../components/presentation/change-password/ChangePassword'; +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 mountChangePassword = (props: Record = {}, userSchema: Record | null = null) => + mount(ChangePassword, { + 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('ChangePassword', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders the three fields by default', () => { + const wrapper = mountChangePassword(); + + expect(inputByName(wrapper, 'currentPassword').exists()).toBe(true); + expect(inputByName(wrapper, 'newPassword').exists()).toBe(true); + expect(inputByName(wrapper, 'confirmPassword').exists()).toBe(true); + }); + + it('marks the new password fields as new-password for password managers', () => { + const wrapper = mountChangePassword(); + + expect(inputByName(wrapper, 'currentPassword').attributes('autocomplete')).toBe('current-password'); + 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 = mountChangePassword({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 = mountChangePassword({policy: {regex: '^.{8,}$'}}); + const submit = (): DOMWrapper => wrapper.find('button[type="submit"]'); + + expect(submit().attributes('disabled')).toBeDefined(); + + await fill(wrapper, {confirmPassword: 'sh0rt', currentPassword: '0ldP@ssword!', newPassword: 'sh0rt'}); + expect(submit().attributes('disabled')).toBeDefined(); + + await fill(wrapper, {confirmPassword: 'longenough1', newPassword: 'longenough1'}); + expect(submit().attributes('disabled')).toBeUndefined(); + }); + + it('blocks reusing the current password as the new one', async () => { + const wrapper = mountChangePassword({policy: {regex: '^.{8,}$'}}); + + await fill(wrapper, { + confirmPassword: 'longenough1', + currentPassword: 'longenough1', + newPassword: 'longenough1', + }); + + expect(wrapper.find('button[type="submit"]').attributes('disabled')).toBeDefined(); + }); + + it('sends the current password alongside the new one and emits success', async () => { + mockUpdateMeCredentials.mockResolvedValueOnce(undefined); + const wrapper = mountChangePassword({policy: {regex: '^.{8,}$'}}); + + await fill(wrapper, { + confirmPassword: 'n3wP@ssword', + currentPassword: '0ldP@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', + currentPassword: '0ldP@ssword!', + payload: {password: 'n3wP@ssword'}, + }), + ); + expect(wrapper.emitted('success')).toBeTruthy(); + }); + + it('clears the entered passwords after a successful change', async () => { + mockUpdateMeCredentials.mockResolvedValueOnce(undefined); + const wrapper = mountChangePassword({policy: {regex: '^.{8,}$'}}); + + await fill(wrapper, { + confirmPassword: 'n3wP@ssword', + currentPassword: '0ldP@ssword!', + newPassword: 'n3wP@ssword', + }); + await wrapper.find('form').trigger('submit'); + await nextTick(); + await nextTick(); + + expect(inputByName(wrapper, 'currentPassword').element.value).toBe(''); + expect(inputByName(wrapper, 'newPassword').element.value).toBe(''); + expect(inputByName(wrapper, 'confirmPassword').element.value).toBe(''); + }); + + it('maps a 403 onto the current password field', async () => { + const {ThunderIDAPIError} = await import('@thunderid/browser'); + + mockUpdateMeCredentials.mockRejectedValueOnce( + new ThunderIDAPIError('Invalid current password', 'x-001', 'vue', 403, 'Forbidden'), + ); + const wrapper = mountChangePassword({policy: {regex: '^.{8,}$'}}); + + await fill(wrapper, { + confirmPassword: 'n3wP@ssword', + currentPassword: 'wrong-but-long', + newPassword: 'n3wP@ssword', + }); + await wrapper.find('form').trigger('submit'); + await nextTick(); + await nextTick(); + + expect(wrapper.text()).toMatch(/current password is incorrect/i); + }); + + it('derives the policy regex from the user schema', async () => { + const wrapper = mountChangePassword({}, {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('applies no client-side rules when the schema has no password regex', async () => { + const wrapper = mountChangePassword({}, {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 = mountChangePassword({}, 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 = mountChangePassword({}, 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 = mountChangePassword({}, schemaWithoutPassword); + + expect(inputByName(wrapper, 'currentPassword').attributes('disabled')).toBeDefined(); + 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 = mountChangePassword({}, 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 = mountChangePassword({}, {password: {credential: true}}); + + expect(wrapper.find('[role="status"]').exists()).toBe(false); + expect(inputByName(wrapper, 'newPassword').attributes('disabled')).toBeUndefined(); + }); + }); +}); diff --git a/packages/vue/src/api/updateMeCredentials.ts b/packages/vue/src/api/updateMeCredentials.ts new file mode 100644 index 00000000..5598c15f --- /dev/null +++ b/packages/vue/src/api/updateMeCredentials.ts @@ -0,0 +1,63 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import { + FetchHttpClient, + HttpError, + HttpRequestConfig, + HttpResponse, + 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 => { + const defaultFetcher = 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) { + // httpClient.request throws on a non-2xx response rather than resolving it, so an error + // that carries a real HTTP response is converted back into one here. That lets the core + // updateMeCredentials see the actual status and body instead of treating it as a network + // failure. A genuine network error (no response) still propagates. + const httpError: HttpError = error as HttpError; + if (httpError?.response) { + return toResponse(httpError.response.data, httpError.response.status, httpError.response.statusText ?? ''); + } + throw error; + } + }; + + return baseUpdateMeCredentials({ + ...requestConfig, + fetcher: fetcher || defaultFetcher, + }); +}; + +export default updateMeCredentials; diff --git a/packages/vue/src/components/presentation/change-password/BaseChangePassword.ts b/packages/vue/src/components/presentation/change-password/BaseChangePassword.ts new file mode 100644 index 00000000..219db312 --- /dev/null +++ b/packages/vue/src/components/presentation/change-password/BaseChangePassword.ts @@ -0,0 +1,309 @@ +// 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`. + */ +export interface ChangePasswordValues { + currentPassword: string; + newPassword: string; +} + +type BaseChangePasswordProps = Readonly<{ + cardLayout: boolean; + className: string; + error: string | null; + fieldErrors: Record; + loading: boolean; + policy: PasswordPolicy; + preferences?: Preferences; + showRequirements: boolean; + success: boolean; + t?: (key: string, params?: Record) => string; + unavailable: boolean; +}>; + +const RULE_ICON_SIZE = 14; + +const ruleIcon = (passed: boolean): VNode => + passed ? CheckIcon({size: RULE_ICON_SIZE}) : XIcon({size: RULE_ICON_SIZE}); + +/** + * Presentational change-password form. + * + * Holds no context and performs no network calls: it renders the fields, evaluates the + * password policy for the checklist and the submit gate, and emits `submit` with the + * validated values. Use `ChangePassword` for the context-wired variant. + */ +const BaseChangePassword: Component = defineComponent({ + name: 'BaseChangePassword', + 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}, + /** 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 (`currentPassword` or `newPassword`). */ + fieldErrors: {default: () => ({}), type: Object as PropType>}, + /** Whether a submission is in flight. */ + loading: {default: false, type: Boolean}, + /** + * The rules the new password 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 `ChangePassword`) 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>, + }, + /** + * Whether the account cannot have a password changed at all, because the user type's schema + * defines no `password` attribute. The form is rendered inert behind an explanatory message + * rather than hidden. + */ + unavailable: {default: false, type: Boolean}, + }, + emits: ['submit'], + setup(props: BaseChangePasswordProps, {emit}: SetupContext): () => VNode { + const {t: fallbackT} = useI18n(); + const translate = (key: string, params?: Record): string => + (props.t ?? fallbackT)(key, params); + + const currentPassword: Ref = ref(''); + const newPassword: Ref = ref(''); + const confirmPassword: Ref = ref(''); + const submitted: Ref = ref(false); + + // Clear the entered passwords 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; + + currentPassword.value = ''; + newPassword.value = ''; + confirmPassword.value = ''; + submitted.value = false; + }, + ); + + const evaluation: ComputedRef = computed(() => + evaluateChangePasswordForm( + { + confirmPassword: confirmPassword.value, + currentPassword: currentPassword.value, + newPassword: newPassword.value, + }, + props.policy, + ), + ); + const ruleResults: ComputedRef = computed(() => evaluation.value.ruleResults); + const confirmMatches: ComputedRef = computed(() => evaluation.value.confirmMatches); + const reusesCurrent: ComputedRef = computed(() => evaluation.value.reusesCurrent); + // A schema with no password attribute 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', {currentPassword: currentPassword.value, newPassword: newPassword.value}); + } + + return (): VNode => { + const rootClass: string = [ + withVendorCSSClassPrefix('change-password'), + props.cardLayout ? withVendorCSSClassPrefix('change-password--card') : '', + props.className, + ] + .filter(Boolean) + .join(' '); + + // Only surface the local errors 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'] ?? + (submitted.value && reusesCurrent.value ? translate('user.change_password.same.as.current.error') : undefined); + + const form: VNode = h('form', {class: rootClass, novalidate: true, onSubmit: handleSubmit}, [ + h( + 'h3', + {class: withVendorCSSClassPrefix('change-password__heading')}, + translate('user.change_password.heading'), + ), + + 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-password__fields')}, [ + h(PasswordField, { + autocomplete: 'current-password', + disabled: interactionDisabled.value, + error: props.fieldErrors['currentPassword'], + label: translate('user.change_password.current.label'), + modelValue: currentPassword.value, + name: 'currentPassword', + 'onUpdate:modelValue': (value: string): void => { + currentPassword.value = value; + }, + placeholder: translate('user.change_password.current.placeholder'), + }), + + 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-password__requirements-heading')}, + translate('user.change_password.requirements.heading'), + ), + h( + 'ul', + {class: withVendorCSSClassPrefix('change-password__requirements')}, + ruleResults.value.map((rule: PasswordRuleResult) => + h( + 'li', + { + class: [ + withVendorCSSClassPrefix('change-password__requirement'), + rule.passed ? withVendorCSSClassPrefix('change-password__requirement--passed') : '', + ] + .filter(Boolean) + .join(' '), + 'data-passed': String(rule.passed), + key: rule.key, + }, + [ + h('span', {class: withVendorCSSClassPrefix('change-password__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-password__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-password__unavailable')}, [ + h('div', {'aria-hidden': 'true', class: withVendorCSSClassPrefix('change-password__unavailable-content')}, [ + form, + ]), + h('div', {class: withVendorCSSClassPrefix('change-password__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 BaseChangePassword; diff --git a/packages/vue/src/components/presentation/change-password/ChangePassword.css.ts b/packages/vue/src/components/presentation/change-password/ChangePassword.css.ts new file mode 100644 index 00000000..4c451aef --- /dev/null +++ b/packages/vue/src/components/presentation/change-password/ChangePassword.css.ts @@ -0,0 +1,106 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +/** + * Styles for the ChangePassword presentation component. + * Parity target: `@thunderid/react` BaseChangePassword.styles.ts + */ +const CHANGE_PASSWORD_CSS = ` +/* ============================================================ + ChangePassword (React Parity) + ============================================================ */ + +.thunderid-change-password { + 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-password--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-password__heading { + margin: 0; + font-size: 1.125rem; + font-weight: 600; +} + +.thunderid-change-password__fields { + display: flex; + flex-direction: column; + gap: calc(var(--thunderid-spacing-unit) * 2); +} + +.thunderid-change-password__requirements-heading { + margin: 0 0 calc(var(--thunderid-spacing-unit) / 2) 0; + font-size: 0.8125rem; + opacity: 0.8; +} + +.thunderid-change-password__requirements { + display: flex; + flex-direction: column; + gap: calc(var(--thunderid-spacing-unit) / 2); + margin: 0; + padding: 0; + list-style: none; +} + +.thunderid-change-password__requirement { + display: flex; + align-items: center; + gap: var(--thunderid-spacing-unit); + font-size: 0.8125rem; + opacity: 0.7; +} + +.thunderid-change-password__requirement--passed { + color: var(--thunderid-color-success-main); + opacity: 1; +} + +.thunderid-change-password__requirement-icon { + display: inline-flex; + flex-shrink: 0; + width: 14px; + height: 14px; +} + +.thunderid-change-password__actions { + display: flex; + align-items: center; + gap: var(--thunderid-spacing-unit); +} + +.thunderid-change-password__unavailable { + position: relative; + display: flex; + width: 100%; +} + +.thunderid-change-password__unavailable-content { + width: 100%; + filter: blur(3px); + opacity: 0.55; + pointer-events: none; + user-select: none; +} + +.thunderid-change-password__unavailable-overlay { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + padding: calc(var(--thunderid-spacing-unit) * 2); +} +`; + +export default CHANGE_PASSWORD_CSS; diff --git a/packages/vue/src/components/presentation/change-password/ChangePassword.ts b/packages/vue/src/components/presentation/change-password/ChangePassword.ts new file mode 100644 index 00000000..c26da230 --- /dev/null +++ b/packages/vue/src/components/presentation/change-password/ChangePassword.ts @@ -0,0 +1,129 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import { + CredentialConstants, + CredentialUpdateErrorResult, + PasswordPolicy, + Preferences, + mapCredentialUpdateError, + resolveChangePasswordPolicy, + resolveResourceEndpoint, + supportsPasswordCredential, + withVendorCSSClassPrefix, +} from '@thunderid/browser'; +import { + type Component, + type ComputedRef, + type PropType, + type Ref, + type SetupContext, + type VNode, + computed, + defineComponent, + h, + ref, +} from 'vue'; +import BaseChangePassword, {type ChangePasswordValues} from './BaseChangePassword'; +import updateMeCredentials from '../../../api/updateMeCredentials'; +import useI18n from '../../../composables/useI18n'; +import useThunderID from '../../../composables/useThunderID'; +import useUser from '../../../composables/useUser'; + +type ChangePasswordProps = Readonly<{ + cardLayout: boolean; + className: string; + policy?: PasswordPolicy; + preferences?: Preferences; + showRequirements: boolean; +}>; + +const ChangePassword: Component = defineComponent({ + name: 'ChangePassword', + 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}, + /** Explicit password 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}, + }, + emits: ['success'], + setup(props: ChangePasswordProps, {emit}: SetupContext): () => VNode { + const {baseUrl, endpoints, instanceId, preferences: contextPreferences} = useThunderID(); + const {userSchema} = useUser(); + const {t} = useI18n(); + + 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(() => + resolveChangePasswordPolicy(userSchema?.value, props.policy), + ); + + async function handleSubmit({currentPassword, newPassword}: ChangePasswordValues): Promise { + error.value = null; + fieldErrors.value = {}; + success.value = false; + loading.value = true; + + try { + await updateMeCredentials({ + baseUrl, + currentPassword: currentPassword || undefined, + instanceId, + payload: {[CredentialConstants.PASSWORD]: 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); + + if (field) { + fieldErrors.value = {[field]: text}; + } else { + error.value = text; + } + } finally { + loading.value = false; + } + } + + return (): VNode => + h(BaseChangePassword, { + cardLayout: props.cardLayout, + class: withVendorCSSClassPrefix('change-password--styled'), + className: props.className, + error: error.value, + fieldErrors: fieldErrors.value, + loading: loading.value, + onSubmit: handleSubmit, + policy: resolvedPolicy.value, + preferences: resolvedPreferences.value, + showRequirements: props.showRequirements, + success: success.value, + t, + unavailable: !supportsPasswordCredential(userSchema?.value), + }); + }, +}); + +export default ChangePassword; diff --git a/packages/vue/src/components/primitives/Icons.ts b/packages/vue/src/components/primitives/Icons.ts index 6673a1c0..28036849 100644 --- a/packages/vue/src/components/primitives/Icons.ts +++ b/packages/vue/src/components/primitives/Icons.ts @@ -3,99 +3,143 @@ import {h, type VNode} from 'vue'; -const defaultProps: Record = { +/** + * Props accepted by every icon in this module. + * + * Each icon is a Vue functional component, so it can be used either as + * `h(CheckIcon, {size: 14})` or called directly as `CheckIcon({size: 14})`. + */ +export interface IconProps { + /** + * Rendered width and height in pixels. Defaults to 16. + */ + size?: number; +} + +const DEFAULT_ICON_SIZE = 16; + +const baseProps: Record = { fill: 'none', - height: '16', stroke: 'currentColor', 'stroke-linecap': 'round', 'stroke-linejoin': 'round', 'stroke-width': '2', viewBox: '0 0 24 24', - width: '16', xmlns: 'http://www.w3.org/2000/svg', }; -const icon = (paths: VNode[]): VNode => h('svg', {...defaultProps}, paths); - -export const CheckIcon = (): VNode => icon([h('polyline', {points: '20 6 9 17 4 12'})]); - -export const XIcon = (): VNode => - icon([h('line', {x1: '18', x2: '6', y1: '6', y2: '18'}), h('line', {x1: '6', x2: '18', y1: '6', y2: '18'})]); - -export const EyeIcon = (): VNode => - icon([h('path', {d: 'M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z'}), h('circle', {cx: '12', cy: '12', r: '3'})]); - -export const EyeOffIcon = (): VNode => - icon([ - h('path', {d: 'M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94'}), - h('path', {d: 'M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19'}), - h('line', {x1: '1', x2: '23', y1: '1', y2: '23'}), - ]); - -export const CircleAlertIcon = (): VNode => - icon([ - h('circle', {cx: '12', cy: '12', r: '10'}), - h('line', {x1: '12', x2: '12', y1: '8', y2: '12'}), - h('line', {x1: '12', x2: '12.01', y1: '16', y2: '16'}), - ]); - -export const CircleCheckIcon = (): VNode => - icon([h('path', {d: 'M22 11.08V12a10 10 0 1 1-5.93-9.14'}), h('polyline', {points: '22 4 12 14.01 9 11.01'})]); - -export const InfoIcon = (): VNode => - icon([ - h('circle', {cx: '12', cy: '12', r: '10'}), - h('line', {x1: '12', x2: '12', y1: '16', y2: '12'}), - h('line', {x1: '12', x2: '12.01', y1: '8', y2: '8'}), - ]); - -export const TriangleAlertIcon = (): VNode => - icon([ - h('path', {d: 'M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z'}), - h('line', {x1: '12', x2: '12', y1: '9', y2: '13'}), - h('line', {x1: '12', x2: '12.01', y1: '17', y2: '17'}), - ]); - -export const PlusIcon = (): VNode => - icon([h('line', {x1: '12', x2: '12', y1: '5', y2: '19'}), h('line', {x1: '5', x2: '19', y1: '12', y2: '12'})]); - -export const LogOutIcon = (): VNode => - icon([ - h('path', {d: 'M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4'}), - h('polyline', {points: '16 17 21 12 16 7'}), - h('line', {x1: '21', x2: '9', y1: '12', y2: '12'}), - ]); - -export const UserIcon = (): VNode => - icon([h('path', {d: 'M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2'}), h('circle', {cx: '12', cy: '7', r: '4'})]); - -export const ArrowLeftRightIcon = (): VNode => - icon([ - h('polyline', {points: '7 16 3 12 7 8'}), - h('line', {x1: '21', x2: '3', y1: '12', y2: '12'}), - h('polyline', {points: '17 8 21 12 17 16'}), - ]); - -export const BuildingIcon = (): VNode => - icon([ - h('rect', {height: '20', rx: '2', ry: '2', width: '16', x: '4', y: '2'}), - h('line', {x1: '9', x2: '9', y1: '6', y2: '6.01'}), - h('line', {x1: '15', x2: '15', y1: '6', y2: '6.01'}), - h('line', {x1: '9', x2: '9', y1: '10', y2: '10.01'}), - h('line', {x1: '15', x2: '15', y1: '10', y2: '10.01'}), - h('line', {x1: '9', x2: '9', y1: '14', y2: '14.01'}), - h('line', {x1: '15', x2: '15', y1: '14', y2: '14.01'}), - h('line', {x1: '9', x2: '15', y1: '18', y2: '18'}), - ]); - -export const ChevronDownIcon = (): VNode => icon([h('polyline', {points: '6 9 12 15 18 9'})]); - -export const GlobeIcon = (): VNode => - icon([ - h('circle', {cx: '12', cy: '12', r: '10'}), - h('line', {x1: '2', x2: '22', y1: '12', y2: '12'}), - h('path', {d: 'M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z'}), - ]); - -export const PencilIcon = (): VNode => - icon([h('path', {d: 'M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z'})]); +const icon = (paths: VNode[], props?: IconProps): VNode => { + const size = String(props?.size ?? DEFAULT_ICON_SIZE); + + return h('svg', {...baseProps, height: size, width: size}, paths); +}; + +export const CheckIcon = (props?: IconProps): VNode => icon([h('polyline', {points: '20 6 9 17 4 12'})], props); + +export const XIcon = (props?: IconProps): VNode => + icon([h('line', {x1: '18', x2: '6', y1: '6', y2: '18'}), h('line', {x1: '6', x2: '18', y1: '6', y2: '18'})], props); + +export const EyeIcon = (props?: IconProps): VNode => + icon( + [h('path', {d: 'M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z'}), h('circle', {cx: '12', cy: '12', r: '3'})], + props, + ); + +export const EyeOffIcon = (props?: IconProps): VNode => + icon( + [ + h('path', {d: 'M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94'}), + h('path', {d: 'M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19'}), + h('line', {x1: '1', x2: '23', y1: '1', y2: '23'}), + ], + props, + ); + +export const CircleAlertIcon = (props?: IconProps): VNode => + icon( + [ + h('circle', {cx: '12', cy: '12', r: '10'}), + h('line', {x1: '12', x2: '12', y1: '8', y2: '12'}), + h('line', {x1: '12', x2: '12.01', y1: '16', y2: '16'}), + ], + props, + ); + +export const CircleCheckIcon = (props?: IconProps): VNode => + icon([h('path', {d: 'M22 11.08V12a10 10 0 1 1-5.93-9.14'}), h('polyline', {points: '22 4 12 14.01 9 11.01'})], props); + +export const InfoIcon = (props?: IconProps): VNode => + icon( + [ + h('circle', {cx: '12', cy: '12', r: '10'}), + h('line', {x1: '12', x2: '12', y1: '16', y2: '12'}), + h('line', {x1: '12', x2: '12.01', y1: '8', y2: '8'}), + ], + props, + ); + +export const TriangleAlertIcon = (props?: IconProps): VNode => + icon( + [ + h('path', {d: 'M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z'}), + h('line', {x1: '12', x2: '12', y1: '9', y2: '13'}), + h('line', {x1: '12', x2: '12.01', y1: '17', y2: '17'}), + ], + props, + ); + +export const PlusIcon = (props?: IconProps): VNode => + icon([h('line', {x1: '12', x2: '12', y1: '5', y2: '19'}), h('line', {x1: '5', x2: '19', y1: '12', y2: '12'})], props); + +export const LogOutIcon = (props?: IconProps): VNode => + icon( + [ + h('path', {d: 'M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4'}), + h('polyline', {points: '16 17 21 12 16 7'}), + h('line', {x1: '21', x2: '9', y1: '12', y2: '12'}), + ], + props, + ); + +export const UserIcon = (props?: IconProps): VNode => + icon([h('path', {d: 'M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2'}), h('circle', {cx: '12', cy: '7', r: '4'})], props); + +export const ArrowLeftRightIcon = (props?: IconProps): VNode => + icon( + [ + h('polyline', {points: '7 16 3 12 7 8'}), + h('line', {x1: '21', x2: '3', y1: '12', y2: '12'}), + h('polyline', {points: '17 8 21 12 17 16'}), + ], + props, + ); + +export const BuildingIcon = (props?: IconProps): VNode => + icon( + [ + h('rect', {height: '20', rx: '2', ry: '2', width: '16', x: '4', y: '2'}), + h('line', {x1: '9', x2: '9', y1: '6', y2: '6.01'}), + h('line', {x1: '15', x2: '15', y1: '6', y2: '6.01'}), + h('line', {x1: '9', x2: '9', y1: '10', y2: '10.01'}), + h('line', {x1: '15', x2: '15', y1: '10', y2: '10.01'}), + h('line', {x1: '9', x2: '9', y1: '14', y2: '14.01'}), + h('line', {x1: '15', x2: '15', y1: '14', y2: '14.01'}), + h('line', {x1: '9', x2: '15', y1: '18', y2: '18'}), + ], + props, + ); + +export const ChevronDownIcon = (props?: IconProps): VNode => icon([h('polyline', {points: '6 9 12 15 18 9'})], props); + +export const GlobeIcon = (props?: IconProps): VNode => + icon( + [ + h('circle', {cx: '12', cy: '12', r: '10'}), + h('line', {x1: '2', x2: '22', y1: '12', y2: '12'}), + h('path', {d: 'M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z'}), + ], + props, + ); + +export const PencilIcon = (props?: IconProps): VNode => + icon([h('path', {d: 'M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z'})], props); diff --git a/packages/vue/src/components/primitives/PasswordField/PasswordField.ts b/packages/vue/src/components/primitives/PasswordField/PasswordField.ts index 41c02a6d..cb889021 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: { + /** + * 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..052e0ba2 100644 --- a/packages/vue/src/index.ts +++ b/packages/vue/src/index.ts @@ -104,6 +104,9 @@ export {default as Loading} from './components/control/Loading'; // ── UI Components — Presentation ── export {default as User} from './components/presentation/user/User'; +export {default as ChangePassword} from './components/presentation/change-password/ChangePassword'; +export {default as BaseChangePassword} from './components/presentation/change-password/BaseChangePassword'; +export type {ChangePasswordValues} from './components/presentation/change-password/BaseChangePassword'; export {default as UserProfile} from './components/presentation/user-profile/UserProfile'; export {default as BaseUserProfile} from './components/presentation/user-profile/BaseUserProfile'; export {default as UserDropdown} from './components/presentation/user-dropdown/UserDropdown'; @@ -164,6 +167,8 @@ export {initiateOAuthRedirect} from './utils/oauth'; export {extractErrorMessage, normalizeFlowResponse} from './utils/flowTransformer'; export type {FlowErrorResponse, FlowTransformOptions} from './utils/flowTransformer'; export {handlePasskeyAuthentication, handlePasskeyRegistration} from './utils/passkey'; +export {default as updateMeCredentials} from './api/updateMeCredentials'; +export * from './api/updateMeCredentials'; export {default as getUsersMeMeta} from './api/getUsersMeMeta'; export * from './api/getUsersMeMeta'; diff --git a/packages/vue/src/styles/injectStyles.ts b/packages/vue/src/styles/injectStyles.ts index f5622353..a505ce9d 100644 --- a/packages/vue/src/styles/injectStyles.ts +++ b/packages/vue/src/styles/injectStyles.ts @@ -24,6 +24,7 @@ import ANIMATIONS_CSS from './animations.css'; import DEFAULTS_CSS from './defaults.css'; // Primitives +import CHANGE_PASSWORD_CSS from '../components/presentation/change-password/ChangePassword.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'; @@ -77,6 +78,7 @@ const STYLES: string = [ LANGUAGE_SWITCHER_CSS, USER_DROPDOWN_CSS, USER_PROFILE_CSS, + CHANGE_PASSWORD_CSS, ].join('\n'); /**