From cdd04106c7ebdeb8055eb9c6c39cee6f824f20c8 Mon Sep 17 00:00:00 2001 From: Vaggelis Yfantis Date: Tue, 4 Nov 2025 13:54:44 +0200 Subject: [PATCH 1/2] feat(clerk-js): Implement reset password task --- packages/clerk-js/src/core/sessionTasks.ts | 1 + .../src/ui/components/SessionTasks/index.tsx | 9 + .../tasks/TaskChooseOrganization/index.tsx | 2 +- .../withTaskGuard.ts | 0 .../__tests__/TaskResetPassword.test.tsx | 26 +++ .../tasks/TaskResetPassword/index.tsx | 207 ++++++++++++++++++ .../tasks/TaskResetPassword/withTaskGuard.ts | 26 +++ .../ui/contexts/components/SessionTasks.ts | 14 +- .../src/ui/elements/contexts/index.tsx | 3 +- packages/clerk-js/src/ui/types.ts | 8 +- packages/shared/src/types/appearance.ts | 1 + packages/shared/src/types/clerk.ts | 9 + packages/shared/src/types/session.ts | 3 +- 13 files changed, 304 insertions(+), 5 deletions(-) rename packages/clerk-js/src/ui/components/SessionTasks/tasks/{ => TaskChooseOrganization}/withTaskGuard.ts (100%) create mode 100644 packages/clerk-js/src/ui/components/SessionTasks/tasks/TaskResetPassword/__tests__/TaskResetPassword.test.tsx create mode 100644 packages/clerk-js/src/ui/components/SessionTasks/tasks/TaskResetPassword/index.tsx create mode 100644 packages/clerk-js/src/ui/components/SessionTasks/tasks/TaskResetPassword/withTaskGuard.ts diff --git a/packages/clerk-js/src/core/sessionTasks.ts b/packages/clerk-js/src/core/sessionTasks.ts index 7713ee53eb3..7c775022840 100644 --- a/packages/clerk-js/src/core/sessionTasks.ts +++ b/packages/clerk-js/src/core/sessionTasks.ts @@ -8,6 +8,7 @@ import { buildURL, forwardClerkQueryParams } from '../utils'; */ export const INTERNAL_SESSION_TASK_ROUTE_BY_KEY: Record = { 'choose-organization': 'choose-organization', + 'reset-password': 'reset-password', } as const; /** diff --git a/packages/clerk-js/src/ui/components/SessionTasks/index.tsx b/packages/clerk-js/src/ui/components/SessionTasks/index.tsx index a5c8ca20ebb..1ab3184b079 100644 --- a/packages/clerk-js/src/ui/components/SessionTasks/index.tsx +++ b/packages/clerk-js/src/ui/components/SessionTasks/index.tsx @@ -12,10 +12,12 @@ import { INTERNAL_SESSION_TASK_ROUTE_BY_KEY } from '../../../core/sessionTasks'; import { SessionTasksContext, TaskChooseOrganizationContext, + TaskResetPasswordContext, useSessionTasksContext, } from '../../contexts/components/SessionTasks'; import { Route, Switch, useRouter } from '../../router'; import { TaskChooseOrganization } from './tasks/TaskChooseOrganization'; +import { TaskResetPassword } from './tasks/TaskResetPassword'; const SessionTasksStart = () => { const clerk = useClerk(); @@ -60,6 +62,13 @@ function SessionTasksRoutes(): JSX.Element { + + + + + diff --git a/packages/clerk-js/src/ui/components/SessionTasks/tasks/TaskChooseOrganization/index.tsx b/packages/clerk-js/src/ui/components/SessionTasks/tasks/TaskChooseOrganization/index.tsx index 60b9442bdd5..d44d33f19ca 100644 --- a/packages/clerk-js/src/ui/components/SessionTasks/tasks/TaskChooseOrganization/index.tsx +++ b/packages/clerk-js/src/ui/components/SessionTasks/tasks/TaskChooseOrganization/index.tsx @@ -8,9 +8,9 @@ import { withCardStateProvider } from '@/ui/elements/contexts'; import { useMultipleSessions } from '@/ui/hooks/useMultipleSessions'; import { useOrganizationListInView } from '@/ui/hooks/useOrganizationListInView'; -import { withTaskGuard } from '../withTaskGuard'; import { ChooseOrganizationScreen } from './ChooseOrganizationScreen'; import { CreateOrganizationScreen } from './CreateOrganizationScreen'; +import { withTaskGuard } from './withTaskGuard'; const TaskChooseOrganizationInternal = () => { const { signOut } = useClerk(); diff --git a/packages/clerk-js/src/ui/components/SessionTasks/tasks/withTaskGuard.ts b/packages/clerk-js/src/ui/components/SessionTasks/tasks/TaskChooseOrganization/withTaskGuard.ts similarity index 100% rename from packages/clerk-js/src/ui/components/SessionTasks/tasks/withTaskGuard.ts rename to packages/clerk-js/src/ui/components/SessionTasks/tasks/TaskChooseOrganization/withTaskGuard.ts diff --git a/packages/clerk-js/src/ui/components/SessionTasks/tasks/TaskResetPassword/__tests__/TaskResetPassword.test.tsx b/packages/clerk-js/src/ui/components/SessionTasks/tasks/TaskResetPassword/__tests__/TaskResetPassword.test.tsx new file mode 100644 index 00000000000..93352c33d19 --- /dev/null +++ b/packages/clerk-js/src/ui/components/SessionTasks/tasks/TaskResetPassword/__tests__/TaskResetPassword.test.tsx @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest'; + +import { bindCreateFixtures } from '@/test/create-fixtures'; +import { render } from '@/test/utils'; + +import { TaskResetPassword } from '..'; + +const { createFixtures } = bindCreateFixtures('TaskResetPassword'); + +describe('TaskResetPassword', () => { + it('does not render component without existing session task', async () => { + const { wrapper } = await createFixtures(f => { + f.withOrganizations(); + f.withForceOrganizationSelection(); + f.withUser({ + email_addresses: ['test@clerk.com'], + }); + }); + + const { queryByText, queryByRole } = render(, { wrapper }); + + expect(queryByText('New password')).not.toBeInTheDocument(); + expect(queryByText('Confirm password')).not.toBeInTheDocument(); + expect(queryByRole('button', { name: /sign out/i })).not.toBeInTheDocument(); + }); +}); diff --git a/packages/clerk-js/src/ui/components/SessionTasks/tasks/TaskResetPassword/index.tsx b/packages/clerk-js/src/ui/components/SessionTasks/tasks/TaskResetPassword/index.tsx new file mode 100644 index 00000000000..e73a46f0d68 --- /dev/null +++ b/packages/clerk-js/src/ui/components/SessionTasks/tasks/TaskResetPassword/index.tsx @@ -0,0 +1,207 @@ +import { useClerk, useReverification, useSession } from '@clerk/shared/react'; +import type { UserResource } from '@clerk/shared/types'; + +import { useEnvironment, useSignOutContext, withCoreSessionSwitchGuard } from '@/ui/contexts'; +import { Col, descriptors, Flow, localizationKeys, useLocalizations } from '@/ui/customizables'; +import { Card } from '@/ui/elements/Card'; +import { useCardState, withCardStateProvider } from '@/ui/elements/contexts'; +import { Form } from '@/ui/elements/Form'; +import { Header } from '@/ui/elements/Header'; +import { useConfirmPassword } from '@/ui/hooks'; +import { useMultipleSessions } from '@/ui/hooks/useMultipleSessions'; +import { handleError } from '@/ui/utils/errorHandler'; +import { createPasswordError } from '@/ui/utils/passwordUtils'; +import { useFormControl } from '@/ui/utils/useFormControl'; + +import { withTaskGuard } from './withTaskGuard'; + +const TaskResetPasswordInternal = () => { + const { signOut, user } = useClerk(); + const { session } = useSession(); + const card = useCardState(); + const { + userSettings: { passwordSettings }, + authConfig: { reverification }, + } = useEnvironment(); + + const { t, locale } = useLocalizations(); + const { otherSessions } = useMultipleSessions({ user }); + const { navigateAfterSignOut, navigateAfterMultiSessionSingleSignOutUrl } = useSignOutContext(); + const updatePasswordWithReverification = useReverification( + (user: UserResource, opts: Parameters) => user.updatePassword(...opts), + ); + + const currentPasswordRequired = user && user.passwordEnabled && !reverification; + + const handleSignOut = () => { + if (otherSessions.length === 0) { + return signOut(navigateAfterSignOut); + } + + return signOut(navigateAfterMultiSessionSingleSignOutUrl, { sessionId: session?.id }); + }; + + const currentPasswordField = useFormControl('currentPassword', '', { + type: 'password', + label: localizationKeys('formFieldLabel__currentPassword'), + isRequired: true, + }); + + const passwordField = useFormControl('newPassword', '', { + type: 'password', + label: localizationKeys('formFieldLabel__newPassword'), + isRequired: true, + validatePassword: true, + buildErrorMessage: errors => createPasswordError(errors, { t, locale, passwordSettings }), + }); + + const confirmField = useFormControl('confirmPassword', '', { + type: 'password', + label: localizationKeys('formFieldLabel__confirmPassword'), + isRequired: true, + }); + + const sessionsField = useFormControl('signOutOfOtherSessions', '', { + type: 'checkbox', + label: localizationKeys('formFieldLabel__signOutOfOtherSessions'), + defaultChecked: true, + }); + + const { setConfirmPasswordFeedback, isPasswordMatch } = useConfirmPassword({ + passwordField, + confirmPasswordField: confirmField, + }); + + const canSubmit = isPasswordMatch; + + const validateForm = () => { + if (passwordField.value) { + setConfirmPasswordFeedback(confirmField.value); + } + }; + + const resetPassword = async () => { + if (!user) { + return; + } + passwordField.clearFeedback(); + confirmField.clearFeedback(); + try { + await updatePasswordWithReverification(user, [ + { + newPassword: passwordField.value, + signOutOfOtherSessions: sessionsField.checked, + }, + ]); + } catch (e) { + return handleError(e, [currentPasswordField, passwordField, confirmField], card.setError); + } + }; + + const identifier = user?.primaryEmailAddress?.emailAddress ?? user?.username; + + return ( + + + + + + + + {card.error} + + { + void resetPassword(); + }} + onBlur={validateForm} + gap={8} + > + + {/* For password managers */} + + {currentPasswordRequired && ( + + + + )} + + + + + { + if (e.target.value) { + setConfirmPasswordFeedback(e.target.value); + } + return confirmField.props.onChange(e); + }} + /> + + + + + + + + + + + + + + ({ width: '100%' })} + > + {identifier && ( + + )} + ({ flexShrink: 0 })} + onClick={() => { + void handleSignOut(); + }} + localizationKey={localizationKeys('taskChooseOrganization.signOut.actionLink')} + /> + + + + + + ); +}; + +export const TaskResetPassword = withCoreSessionSwitchGuard( + withTaskGuard(withCardStateProvider(TaskResetPasswordInternal)), +); diff --git a/packages/clerk-js/src/ui/components/SessionTasks/tasks/TaskResetPassword/withTaskGuard.ts b/packages/clerk-js/src/ui/components/SessionTasks/tasks/TaskResetPassword/withTaskGuard.ts new file mode 100644 index 00000000000..8545c2b1ffc --- /dev/null +++ b/packages/clerk-js/src/ui/components/SessionTasks/tasks/TaskResetPassword/withTaskGuard.ts @@ -0,0 +1,26 @@ +import type { ComponentType } from 'react'; + +import { warnings } from '@/core/warnings'; +import { withRedirect } from '@/ui/common'; +import { useTaskResetPasswordContext } from '@/ui/contexts/components/SessionTasks'; +import type { AvailableComponentProps } from '@/ui/types'; + +export const withTaskGuard =

(Component: ComponentType

) => { + const displayName = Component.displayName || Component.name || 'Component'; + Component.displayName = displayName; + + const HOC = (props: P) => { + const ctx = useTaskResetPasswordContext(); + return withRedirect( + Component, + clerk => !clerk.session?.currentTask, + ({ clerk }) => + !clerk.session ? clerk.buildSignInUrl() : (ctx.redirectUrlComplete ?? clerk.buildAfterSignInUrl()), + warnings.cannotRenderComponentWhenTaskDoesNotExist, + )(props); + }; + + HOC.displayName = `withTaskGuard(${displayName})`; + + return HOC; +}; diff --git a/packages/clerk-js/src/ui/contexts/components/SessionTasks.ts b/packages/clerk-js/src/ui/contexts/components/SessionTasks.ts index e7f15757943..0c9f1abb4a0 100644 --- a/packages/clerk-js/src/ui/contexts/components/SessionTasks.ts +++ b/packages/clerk-js/src/ui/contexts/components/SessionTasks.ts @@ -1,6 +1,6 @@ import { createContext, useContext } from 'react'; -import type { SessionTasksCtx, TaskChooseOrganizationCtx } from '../../types'; +import type { SessionTasksCtx, TaskChooseOrganizationCtx, TaskResetPasswordCtx } from '../../types'; export const SessionTasksContext = createContext(null); @@ -27,3 +27,15 @@ export const useTaskChooseOrganizationContext = (): TaskChooseOrganizationCtx => return context; }; + +export const TaskResetPasswordContext = createContext(null); + +export const useTaskResetPasswordContext = (): TaskResetPasswordCtx => { + const context = useContext(TaskResetPasswordContext); + + if (context === null) { + throw new Error('Clerk: useTaskResetPasswordContext called outside of the mounted TaskResetPassword component.'); + } + + return context; +}; diff --git a/packages/clerk-js/src/ui/elements/contexts/index.tsx b/packages/clerk-js/src/ui/elements/contexts/index.tsx index fb158a9e62c..ce356d1a5ef 100644 --- a/packages/clerk-js/src/ui/elements/contexts/index.tsx +++ b/packages/clerk-js/src/ui/elements/contexts/index.tsx @@ -101,7 +101,8 @@ export type FlowMetadata = { | 'oauthConsent' | 'subscriptionDetails' | 'tasks' - | 'taskChooseOrganization'; + | 'taskChooseOrganization' + | 'taskResetPassword'; part?: | 'start' | 'emailCode' diff --git a/packages/clerk-js/src/ui/types.ts b/packages/clerk-js/src/ui/types.ts index 3f9fe2a27ef..8da54303daa 100644 --- a/packages/clerk-js/src/ui/types.ts +++ b/packages/clerk-js/src/ui/types.ts @@ -20,6 +20,7 @@ import type { SignUpForceRedirectUrl, SignUpProps, TaskChooseOrganizationProps, + TaskResetPasswordProps, UserAvatarProps, UserButtonProps, UserProfileProps, @@ -151,6 +152,10 @@ export type TaskChooseOrganizationCtx = TaskChooseOrganizationProps & { componentName: 'TaskChooseOrganization'; }; +export type TaskResetPasswordCtx = TaskResetPasswordProps & { + componentName: 'TaskResetPassword'; +}; + export type OAuthConsentCtx = __internal_OAuthConsentProps & { componentName: 'OAuthConsent'; }; @@ -182,5 +187,6 @@ export type AvailableComponentCtx = | OAuthConsentCtx | SubscriptionDetailsCtx | PlanDetailsCtx - | TaskChooseOrganizationCtx; + | TaskChooseOrganizationCtx + | TaskResetPasswordCtx; export type AvailableComponentName = AvailableComponentCtx['componentName']; diff --git a/packages/shared/src/types/appearance.ts b/packages/shared/src/types/appearance.ts index 7f0cb4f32fd..b5fff266f62 100644 --- a/packages/shared/src/types/appearance.ts +++ b/packages/shared/src/types/appearance.ts @@ -1041,6 +1041,7 @@ export type SubscriptionDetailsTheme = Theme; export type APIKeysTheme = Theme; export type OAuthConsentTheme = Theme; export type TaskChooseOrganizationTheme = Theme; +export type TaskResetPasswordTheme = Theme; type GlobalAppearanceOptions = { /** diff --git a/packages/shared/src/types/clerk.ts b/packages/shared/src/types/clerk.ts index 7f9259b0103..a6bbaa6b5b5 100644 --- a/packages/shared/src/types/clerk.ts +++ b/packages/shared/src/types/clerk.ts @@ -14,6 +14,7 @@ import type { SignUpTheme, SubscriptionDetailsTheme, TaskChooseOrganizationTheme, + TaskResetPasswordTheme, UserAvatarTheme, UserButtonTheme, UserProfileTheme, @@ -2223,6 +2224,14 @@ export type TaskChooseOrganizationProps = { appearance?: TaskChooseOrganizationTheme; }; +export type TaskResetPasswordProps = { + /** + * Full URL or path to navigate to after successfully resolving all tasks + */ + redirectUrlComplete: string; + appearance?: TaskResetPasswordTheme; +}; + export type CreateOrganizationInvitationParams = { emailAddress: string; role: OrganizationCustomRoleKey; diff --git a/packages/shared/src/types/session.ts b/packages/shared/src/types/session.ts index 11629a838d4..b1fb5711026 100644 --- a/packages/shared/src/types/session.ts +++ b/packages/shared/src/types/session.ts @@ -35,6 +35,7 @@ import type { Autocomplete } from './utils'; export type PendingSessionOptions = { /** * A boolean that indicates whether pending sessions are considered as signed out or not. + * * @default true */ treatPendingAsSignedOut?: boolean; @@ -334,7 +335,7 @@ export interface SessionTask { /** * A unique identifier for the task */ - key: 'choose-organization'; + key: 'choose-organization' | 'reset-password'; } export type GetTokenOptions = { From ea53a8cb90c0a251f4f7c697a6c2c8109d11d4ac Mon Sep 17 00:00:00 2001 From: Vaggelis Yfantis Date: Tue, 4 Nov 2025 14:02:16 +0200 Subject: [PATCH 2/2] fix(clerk-js): Include current password in reset password task for verification --- .../ui/components/SessionTasks/tasks/TaskResetPassword/index.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/clerk-js/src/ui/components/SessionTasks/tasks/TaskResetPassword/index.tsx b/packages/clerk-js/src/ui/components/SessionTasks/tasks/TaskResetPassword/index.tsx index e73a46f0d68..798813e5110 100644 --- a/packages/clerk-js/src/ui/components/SessionTasks/tasks/TaskResetPassword/index.tsx +++ b/packages/clerk-js/src/ui/components/SessionTasks/tasks/TaskResetPassword/index.tsx @@ -89,6 +89,7 @@ const TaskResetPasswordInternal = () => { try { await updatePasswordWithReverification(user, [ { + currentPassword: currentPasswordRequired ? currentPasswordField.value : undefined, newPassword: passwordField.value, signOutOfOtherSessions: sessionsField.checked, },