Skip to content
Merged
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
9 changes: 8 additions & 1 deletion packages/express/src/ThunderIDExpressClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,10 +44,17 @@ class ThunderIDExpressClient<T extends ExpressClientConfig = ExpressClientConfig
}

public async getUserFromRequest(req: express.Request): Promise<User | undefined> {
const sessionId: string | undefined = req.cookies?.[this.getSessionCookieName()];
const cookies = req.cookies as Record<string, string | undefined> | undefined;
const sessionId: string | undefined = cookies?.[this.getSessionCookieName()];
return this.getUser(sessionId);
}

public async updateUserCredentialsFromRequest(req: express.Request, payload: Record<string, string>): Promise<void> {
const cookies = req.cookies as Record<string, string | undefined> | undefined;
const sessionId: string | undefined = cookies?.[this.getSessionCookieName()];
return this.updateUserCredentials(payload, sessionId);
}

public override async signIn(
req: express.Request,
res: express.Response,
Expand Down
32 changes: 32 additions & 0 deletions packages/express/src/__tests__/ThunderIDExpressClient.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
// Copyright 2026 The ThunderID Authors
// SPDX-License-Identifier: Apache-2.0

import express from 'express';
import {describe, it, expect, vi} from 'vitest';
import ThunderIDExpressClient from '../ThunderIDExpressClient';

describe('ThunderIDExpressClient', () => {
describe('updateUserCredentialsFromRequest', () => {
it('extracts session cookie and calls updateUserCredentials', async () => {
const client = new ThunderIDExpressClient();
await client.initialize({
baseUrl: 'https://auth.example.com',
clientId: 'test-client',
});

const updateCredentialsSpy = vi.spyOn(client, 'updateUserCredentials').mockResolvedValue(undefined);

const cookieName = client.getSessionCookieName();
const req = {
cookies: {
[cookieName]: 'session-cookie-123',
},
} as unknown as express.Request;

const payload = {password: 'new-password'};
await client.updateUserCredentialsFromRequest(req, payload);

expect(updateCredentialsSpy).toHaveBeenCalledWith(payload, 'session-cookie-123');
});
});
});
32 changes: 32 additions & 0 deletions packages/nextjs/src/ThunderIDNextClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
generateFlattenedUserProfile,
getUsersMe,
getUsersMeMeta,
updateMeCredentials,
updateMeProfile,
resolveResourceEndpoint,
} from '@thunderid/node';
Expand Down Expand Up @@ -202,6 +203,37 @@ class ThunderIDNextClient<T extends ThunderIDNextConfig = ThunderIDNextConfig> e
}
}

/**
* Updates one or more of the signed-in user's credentials (e.g. `password`, or any other
* attribute the user type schema declares `credential: true`).
*
* Not part of {@link ThunderIDJavaScriptClient}'s base surface — unlike `updateUserProfile`,
* no other SDK routes credential updates through a client method (React/Vue's
* `ChangeCredential` call the core `updateMeCredentials` function directly), so this is a
* Next.js-specific addition rather than an override. Kept here anyway so this SDK's own
* server actions have one consistent way to reach every `/users/me/*` operation.
*
* Deliberately does not catch and rewrap errors the way `updateUserProfile` does: the caller
* (`updateUserCredentialsAction`) needs the real `ThunderIDAPIError` instance, status code
* included, to map a failure onto the right form field before it crosses the server action
* boundary back to the client.
*/
override async updateUserCredentials(payload: Record<string, string>, userId?: string): Promise<void> {
await this.ensureInitialized();

const configData: AuthClientConfig<T> = await this.getStorageManager().getConfigData();
const baseUrl: string | undefined = configData?.baseUrl;

await updateMeCredentials({
baseUrl,
url: resolveResourceEndpoint('usersMeCredentials', configData),
headers: {
Authorization: `Bearer ${await this.getAccessToken(userId)}`,
},
payload,
});
}

override isLoading(): boolean {
return false;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
// Copyright 2026 The ThunderID Authors
// SPDX-License-Identifier: Apache-2.0

'use client';

import {CredentialConstants, PasswordPolicy, resolveChangeCredentialPolicy, supportsCredential} from '@thunderid/node';
import {
BaseChangeCredential,
BaseChangeCredentialProps,
ChangePasswordValues,
useTranslation,
useUser,
} from '@thunderid/react';
import {FC, ReactElement, useMemo, useState} from 'react';
import getSessionId from '../../../../server/actions/getSessionId';
import updateUserCredentialsAction, {
UpdateUserCredentialsActionResult,
} from '../../../../server/actions/updateUserCredentialsAction';
import useThunderID from '../../../contexts/ThunderID/useThunderID';

/**
* Title-cases a credential attribute name for use as a display-name fallback when the schema
* declares no `displayName` for it, e.g. `pin` -> `Pin`.
*/
const defaultDisplayName = (attribute: string): string => attribute.charAt(0).toUpperCase() + attribute.slice(1);

/**
* Props for the ChangeCredential component.
* Mirrors `@thunderid/react`'s `ChangeCredentialProps` exactly, so a consumer moving between
* the React and Next.js SDKs doesn't need to change how they call it.
*/
export type ChangeCredentialProps = Omit<
BaseChangeCredentialProps,
'credentialDisplayName' | 'error' | 'fieldErrors' | 'loading' | 'onSubmit' | 'success'
> & {
/**
* The credential attribute this instance manages, any attribute the user's entity type
* schema declares `credential: true` (for example `password` or `pin`). Defaults to
* `password`. Render the component once per credential to let a user manage more than one,
* for example `<ChangeCredential />` for the password and
* `<ChangeCredential attribute="pin" />` for a PIN.
*/
attribute?: string;
/**
* Called after the credential has been changed successfully.
*/
onSuccess?: () => void;
};

/**
* ChangeCredential lets the signed-in user set a new value for one of their own credentials.
*
* This is the Next.js-specific implementation: it uses `BaseChangeCredential` from
* `@thunderid/react` for rendering, but routes the write through
* `updateUserCredentialsAction` (a server action) instead of calling the ThunderID server
* directly from the browser, matching how `UserProfile` reaches `updateUserProfileAction`.
*
* Defaults to managing the `password` credential. To manage a different one (for example a
* PIN declared on the user type schema), set `attribute`; render the component once per
* credential to let a user manage several.
*
* @example
* ```tsx
* // Basic usage, manages the password
* <ChangeCredential onSuccess={() => toast('Password updated')} />
*
* // Managing a different credential declared on the schema
* <ChangeCredential attribute="pin" />
* ```
*/
const ChangeCredential: FC<ChangeCredentialProps> = ({
attribute = CredentialConstants.PASSWORD,
onSuccess,
policy,
preferences,
...rest
}: ChangeCredentialProps): ReactElement => {
const {preferences: contextPreferences} = useThunderID();
const {userSchema} = useUser();
const resolvedDisplayName: string = userSchema?.[attribute]?.displayName ?? defaultDisplayName(attribute);

const resolvedPreferences = useMemo(
() => ({
...contextPreferences,
...preferences,
user: {...contextPreferences?.user, ...preferences?.user},
}),
[contextPreferences, preferences],
);
const {t} = useTranslation(resolvedPreferences?.i18n);

const [error, setError] = useState<string | null>(null);
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
const [loading, setLoading] = useState<boolean>(false);
const [success, setSuccess] = useState<boolean>(false);

const resolvedPolicy: PasswordPolicy = useMemo(
() => resolveChangeCredentialPolicy(userSchema, attribute, policy),
[userSchema, attribute, policy],
);

const handleSubmit = async ({newPassword}: ChangePasswordValues): Promise<void> => {
setError(null);
setFieldErrors({});
setSuccess(false);
setLoading(true);

const result: UpdateUserCredentialsActionResult = await updateUserCredentialsAction(
{[attribute]: newPassword},
await getSessionId(),
);

if (result.success) {
setSuccess(true);
onSuccess?.();
} else {
const text: string =
result.message ??
t(result.messageKey, {credential: resolvedDisplayName, credentialLower: resolvedDisplayName.toLowerCase()});

if (result.field) {
setFieldErrors({[result.field]: text});
} else {
setError(text);
}
}

setLoading(false);
};

return (
<BaseChangeCredential
{...rest}
credentialDisplayName={resolvedDisplayName}
error={error}
fieldErrors={fieldErrors}
loading={loading}
policy={resolvedPolicy}
preferences={resolvedPreferences}
success={success}
unavailable={!supportsCredential(userSchema, attribute)}
onSubmit={(values: ChangePasswordValues): void => {
void handleSubmit(values);
}}
/>
);
};

export default ChangeCredential;
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,18 @@ export type UserDropdownProps = Omit<BaseUserDropdownProps, 'user' | 'onManagePr
* When provided, this completely replaces the default dropdown rendering.
*/
children?: (props: UserDropdownRenderProps) => ReactNode;
/**
* Called instead of opening the built-in "Manage Profile" popup when the "Manage Profile"
* menu item (see `manageProfileLabel`) is clicked. Use this when the app has its own
* profile/account page it wants to navigate to instead, for example with the router's
* `push()`.
*
* @example
* ```tsx
* <UserDropdown manageProfileLabel="Manage Account" onManageProfile={() => router.push('/account')} />
* ```
*/
onManageProfile?: () => void;
/**
* Custom render function for the dropdown content.
* When provided, this replaces just the dropdown content while keeping the trigger.
Expand Down Expand Up @@ -98,13 +110,18 @@ const UserDropdown: FC<UserDropdownProps> = ({
children,
renderTrigger,
renderDropdown,
onManageProfile: onManageProfileOverride,
onSignOut,
...rest
}: UserDropdownProps): ReactElement => {
const {user, isLoading, signOut} = useThunderID();
const [isProfileOpen, setIsProfileOpen] = useState(false);

const handleManageProfile = (): void => {
if (onManageProfileOverride) {
onManageProfileOverride();
return;
}
setIsProfileOpen(true);
};

Expand Down Expand Up @@ -134,7 +151,7 @@ const UserDropdown: FC<UserDropdownProps> = ({
return (
<>
{children(renderProps)}
<UserProfile mode="popup" open={isProfileOpen} onOpenChange={setIsProfileOpen} />
{!onManageProfileOverride && <UserProfile mode="popup" open={isProfileOpen} onOpenChange={setIsProfileOpen} />}
</>
);
}
Expand All @@ -157,7 +174,7 @@ const UserDropdown: FC<UserDropdownProps> = ({
/>
)}
{/* Note: renderDropdown would need BaseUserDropdown modifications to implement properly */}
<UserProfile mode="popup" open={isProfileOpen} onOpenChange={setIsProfileOpen} />
{!onManageProfileOverride && <UserProfile mode="popup" open={isProfileOpen} onOpenChange={setIsProfileOpen} />}
</>
);
}
Expand All @@ -172,7 +189,9 @@ const UserDropdown: FC<UserDropdownProps> = ({
onSignOut={handleSignOut}
{...rest}
/>
{isProfileOpen && <UserProfile mode="popup" open={isProfileOpen} onOpenChange={setIsProfileOpen} />}
{!onManageProfileOverride && isProfileOpen && (
<UserProfile mode="popup" open={isProfileOpen} onOpenChange={setIsProfileOpen} />
)}
</>
);
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ import {
ThunderIDContext as ReactThunderIDContext,
ThunderIDContextProps as ReactThunderIDContextProps,
ThunderIDProviderProps,
getActiveTheme,
} from '@thunderid/react';
import {ReadonlyURLSearchParams} from 'next/dist/client/components/navigation.react-server';
import {AppRouterInstance} from 'next/dist/shared/lib/app-router-context.shared-runtime';
Expand Down Expand Up @@ -405,7 +404,7 @@ const ThunderIDClientProvider: FC<PropsWithChildren<ThunderIDClientProviderProps
initialMeta={initialMeta}
fetchMeta={fetchMeta}
>
<ThemeProvider theme={preferences?.theme?.overrides} mode={getActiveTheme(preferences?.theme?.mode as any)}>
<ThemeProvider theme={preferences?.theme?.overrides} mode={preferences?.theme?.mode}>
<FlowProvider>
<UserProvider
profile={userProfile}
Expand Down
3 changes: 3 additions & 0 deletions packages/nextjs/src/client/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,6 @@ export type {UserAvatarProps} from './components/presentation/UserAvatar/UserAva

export {default as UserProfile} from './components/presentation/UserProfile/UserProfile';
export type {UserProfileProps} from './components/presentation/UserProfile/UserProfile';

export {default as ChangeCredential} from './components/presentation/ChangeCredential/ChangeCredential';
export type {ChangeCredentialProps} from './components/presentation/ChangeCredential/ChangeCredential';
36 changes: 36 additions & 0 deletions packages/nextjs/src/server/actions/updateUserCredentialsAction.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
// Copyright 2026 The ThunderID Authors
// SPDX-License-Identifier: Apache-2.0

'use server';

import {CredentialUpdateErrorResult, mapCredentialUpdateError} from '@thunderid/node';
import getClient from '../getClient';

export interface UpdateUserCredentialsActionResult extends CredentialUpdateErrorResult {
success: boolean;
}

/**
* Server action to update one of the signed-in user's own credentials (e.g. `password`).
*
* Never throws across the server action boundary — a thrown class instance loses its
* prototype chain crossing it, so `error instanceof ThunderIDAPIError` would no longer hold on
* the client. Instead, the real error is mapped to a field/message here, server-side, while it
* is still the genuine `ThunderIDAPIError` the core `updateMeCredentials` call threw, and only
* the plain, serializable result crosses back.
*/
const updateUserCredentialsAction = async (
payload: Record<string, string>,
sessionId?: string,
): Promise<UpdateUserCredentialsActionResult> => {
try {
const client = getClient();
await client.updateUserCredentials(payload, sessionId);
return {field: null, messageKey: '', success: true};
} catch (error) {
const {field, message, messageKey}: CredentialUpdateErrorResult = mapCredentialUpdateError(error);
return {field, message, messageKey, success: false};
}
};

export default updateUserCredentialsAction;
Loading
Loading