Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
191 changes: 191 additions & 0 deletions packages/javascript/src/api/__tests__/updateMeCredentials.test.ts
Original file line number Diff line number Diff line change
@@ -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<void> => {
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<string, string>)['Content-Type']).toBe('application/json');
expect((init.headers as Record<string, string>)['Accept']).toBe('application/json');

const parsed = JSON.parse(init.body as string) as Record<string, unknown>;
expect(parsed['attributes']).toEqual({password: 'n3wP@ssword!'});
});

it('should send currentPassword as a top-level field, not inside attributes', async (): Promise<void> => {
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<string, unknown>;

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<void> => {
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<string, unknown>;

expect(parsed).not.toHaveProperty('currentPassword');
});

it('should never read the response body on success', async (): Promise<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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',
});
});
});
144 changes: 144 additions & 0 deletions packages/javascript/src/api/updateMeCredentials.ts
Original file line number Diff line number Diff line change
@@ -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<RequestInit, 'method' | 'body'> {
/**
* 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<Response>;
/**
* The credential attributes to write, keyed by credential type (e.g. `{password: '...'}`).
*/
payload: Record<string, string>;
/**
* 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<void> => {
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<string, unknown> = {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;
29 changes: 29 additions & 0 deletions packages/javascript/src/constants/CredentialConstants.ts
Original file line number Diff line number Diff line change
@@ -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;
22 changes: 22 additions & 0 deletions packages/javascript/src/i18n/models/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 | */
/* |---------------------------------------------------------------| */
Expand Down
Loading
Loading