Skip to content

Commit f94eb42

Browse files
committed
feat(clerk-js): Implement reset password task
1 parent 5b85ea9 commit f94eb42

File tree

13 files changed

+461
-70
lines changed

13 files changed

+461
-70
lines changed

packages/clerk-js/src/core/sessionTasks.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { buildURL, forwardClerkQueryParams } from '../utils';
88
*/
99
export const INTERNAL_SESSION_TASK_ROUTE_BY_KEY: Record<SessionTask['key'], string> = {
1010
'choose-organization': 'choose-organization',
11+
'reset-password': 'reset-password',
1112
} as const;
1213

1314
/**

packages/clerk-js/src/ui/components/SessionTasks/index.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,12 @@ import { INTERNAL_SESSION_TASK_ROUTE_BY_KEY } from '../../../core/sessionTasks';
1212
import {
1313
SessionTasksContext,
1414
TaskChooseOrganizationContext,
15+
TaskResetPasswordContext,
1516
useSessionTasksContext,
1617
} from '../../contexts/components/SessionTasks';
1718
import { Route, Switch, useRouter } from '../../router';
1819
import { TaskChooseOrganization } from './tasks/TaskChooseOrganization';
20+
import { TaskResetPassword } from './tasks/TaskResetPassword';
1921

2022
const SessionTasksStart = () => {
2123
const clerk = useClerk();
@@ -60,6 +62,13 @@ function SessionTasksRoutes(): JSX.Element {
6062
<TaskChooseOrganization />
6163
</TaskChooseOrganizationContext.Provider>
6264
</Route>
65+
<Route path={INTERNAL_SESSION_TASK_ROUTE_BY_KEY['reset-password']}>
66+
<TaskResetPasswordContext.Provider
67+
value={{ componentName: 'TaskResetPassword', redirectUrlComplete: ctx.redirectUrlComplete }}
68+
>
69+
<TaskResetPassword />
70+
</TaskResetPasswordContext.Provider>
71+
</Route>
6372
<Route index>
6473
<SessionTasksStart />
6574
</Route>

packages/clerk-js/src/ui/components/SessionTasks/tasks/TaskChooseOrganization/index.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,9 @@ import { withCardStateProvider } from '@/ui/elements/contexts';
88
import { useMultipleSessions } from '@/ui/hooks/useMultipleSessions';
99
import { useOrganizationListInView } from '@/ui/hooks/useOrganizationListInView';
1010

11-
import { withTaskGuard } from '../withTaskGuard';
1211
import { ChooseOrganizationScreen } from './ChooseOrganizationScreen';
1312
import { CreateOrganizationScreen } from './CreateOrganizationScreen';
13+
import { withTaskGuard } from './withTaskGuard';
1414

1515
const TaskChooseOrganizationInternal = () => {
1616
const { signOut } = useClerk();
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { describe, expect, it } from 'vitest';
2+
3+
import { bindCreateFixtures } from '@/test/create-fixtures';
4+
import { render } from '@/test/utils';
5+
6+
import { TaskResetPassword } from '..';
7+
8+
const { createFixtures } = bindCreateFixtures('TaskResetPassword');
9+
10+
describe('TaskResetPassword', () => {
11+
it('does not render component without existing session task', async () => {
12+
const { wrapper } = await createFixtures(f => {
13+
f.withOrganizations();
14+
f.withForceOrganizationSelection();
15+
f.withUser({
16+
email_addresses: ['test@clerk.com'],
17+
});
18+
});
19+
20+
const { queryByText, queryByRole } = render(<TaskResetPassword />, { wrapper });
21+
22+
expect(queryByText('New password')).not.toBeInTheDocument();
23+
expect(queryByText('Confirm password')).not.toBeInTheDocument();
24+
expect(queryByRole('button', { name: /sign out/i })).not.toBeInTheDocument();
25+
});
26+
});
Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
import { useClerk, useReverification, useSession } from '@clerk/shared/react';
2+
import type { UserResource } from '@clerk/shared/types';
3+
4+
import { useEnvironment, useSignOutContext, withCoreSessionSwitchGuard } from '@/ui/contexts';
5+
import { Col, descriptors, Flow, localizationKeys, useLocalizations } from '@/ui/customizables';
6+
import { Card } from '@/ui/elements/Card';
7+
import { useCardState, withCardStateProvider } from '@/ui/elements/contexts';
8+
import { Form } from '@/ui/elements/Form';
9+
import { Header } from '@/ui/elements/Header';
10+
import { useConfirmPassword } from '@/ui/hooks';
11+
import { useMultipleSessions } from '@/ui/hooks/useMultipleSessions';
12+
import { handleError } from '@/ui/utils/errorHandler';
13+
import { createPasswordError } from '@/ui/utils/passwordUtils';
14+
import { useFormControl } from '@/ui/utils/useFormControl';
15+
16+
import { withTaskGuard } from './withTaskGuard';
17+
18+
const TaskResetPasswordInternal = () => {
19+
const { signOut, user } = useClerk();
20+
const { session } = useSession();
21+
const card = useCardState();
22+
const {
23+
userSettings: { passwordSettings },
24+
authConfig: { reverification },
25+
} = useEnvironment();
26+
27+
const { t, locale } = useLocalizations();
28+
const { otherSessions } = useMultipleSessions({ user });
29+
const { navigateAfterSignOut, navigateAfterMultiSessionSingleSignOutUrl } = useSignOutContext();
30+
const updatePasswordWithReverification = useReverification(
31+
(user: UserResource, opts: Parameters<UserResource['updatePassword']>) => user.updatePassword(...opts),
32+
);
33+
34+
const currentPasswordRequired = user && user.passwordEnabled && !reverification;
35+
36+
const handleSignOut = () => {
37+
if (otherSessions.length === 0) {
38+
return signOut(navigateAfterSignOut);
39+
}
40+
41+
return signOut(navigateAfterMultiSessionSingleSignOutUrl, { sessionId: session?.id });
42+
};
43+
44+
const currentPasswordField = useFormControl('currentPassword', '', {
45+
type: 'password',
46+
label: localizationKeys('formFieldLabel__currentPassword'),
47+
isRequired: true,
48+
});
49+
50+
const passwordField = useFormControl('newPassword', '', {
51+
type: 'password',
52+
label: localizationKeys('formFieldLabel__newPassword'),
53+
isRequired: true,
54+
validatePassword: true,
55+
buildErrorMessage: errors => createPasswordError(errors, { t, locale, passwordSettings }),
56+
});
57+
58+
const confirmField = useFormControl('confirmPassword', '', {
59+
type: 'password',
60+
label: localizationKeys('formFieldLabel__confirmPassword'),
61+
isRequired: true,
62+
});
63+
64+
const sessionsField = useFormControl('signOutOfOtherSessions', '', {
65+
type: 'checkbox',
66+
label: localizationKeys('formFieldLabel__signOutOfOtherSessions'),
67+
defaultChecked: true,
68+
});
69+
70+
const { setConfirmPasswordFeedback, isPasswordMatch } = useConfirmPassword({
71+
passwordField,
72+
confirmPasswordField: confirmField,
73+
});
74+
75+
const canSubmit = isPasswordMatch;
76+
77+
const validateForm = () => {
78+
if (passwordField.value) {
79+
setConfirmPasswordFeedback(confirmField.value);
80+
}
81+
};
82+
83+
const resetPassword = async () => {
84+
if (!user) {
85+
return;
86+
}
87+
passwordField.clearFeedback();
88+
confirmField.clearFeedback();
89+
try {
90+
await updatePasswordWithReverification(user, [
91+
{
92+
newPassword: passwordField.value,
93+
signOutOfOtherSessions: sessionsField.checked,
94+
},
95+
]);
96+
} catch (e) {
97+
return handleError(e, [currentPasswordField, passwordField, confirmField], card.setError);
98+
}
99+
};
100+
101+
const identifier = user?.primaryEmailAddress?.emailAddress ?? user?.username;
102+
103+
return (
104+
<Flow.Root flow='taskResetPassword'>
105+
<Flow.Part part='resetPassword'>
106+
<Card.Root>
107+
<Card.Content>
108+
<Header.Root showLogo>
109+
<Header.Title localizationKey={localizationKeys('signIn.resetPassword.title')} />
110+
</Header.Root>
111+
<Card.Alert>{card.error}</Card.Alert>
112+
<Col
113+
elementDescriptor={descriptors.main}
114+
gap={8}
115+
>
116+
<Form.Root
117+
onSubmit={() => {
118+
void resetPassword();
119+
}}
120+
onBlur={validateForm}
121+
gap={8}
122+
>
123+
<Col gap={6}>
124+
{/* For password managers */}
125+
<input
126+
readOnly
127+
data-testid='hidden-identifier'
128+
id='identifier-field'
129+
name='identifier'
130+
value={session?.publicUserData.identifier || ''}
131+
style={{ display: 'none' }}
132+
/>
133+
{currentPasswordRequired && (
134+
<Form.ControlRow elementId={currentPasswordField.id}>
135+
<Form.PasswordInput
136+
{...currentPasswordField.props}
137+
minLength={6}
138+
isRequired
139+
autoFocus
140+
/>
141+
</Form.ControlRow>
142+
)}
143+
<Form.ControlRow elementId={passwordField.id}>
144+
<Form.PasswordInput
145+
{...passwordField.props}
146+
isRequired
147+
minLength={6}
148+
/>
149+
</Form.ControlRow>
150+
<Form.ControlRow elementId={confirmField.id}>
151+
<Form.PasswordInput
152+
{...confirmField.props}
153+
onChange={e => {
154+
if (e.target.value) {
155+
setConfirmPasswordFeedback(e.target.value);
156+
}
157+
return confirmField.props.onChange(e);
158+
}}
159+
/>
160+
</Form.ControlRow>
161+
<Form.ControlRow elementId={sessionsField.id}>
162+
<Form.Checkbox {...sessionsField.props} />
163+
</Form.ControlRow>
164+
</Col>
165+
<Col gap={3}>
166+
<Form.SubmitButton
167+
isDisabled={!canSubmit}
168+
localizationKey={localizationKeys('signIn.resetPassword.formButtonPrimary')}
169+
/>
170+
</Col>
171+
</Form.Root>
172+
</Col>
173+
</Card.Content>
174+
175+
<Card.Footer>
176+
<Card.Action
177+
elementId='signOut'
178+
gap={2}
179+
justify='center'
180+
sx={() => ({ width: '100%' })}
181+
>
182+
{identifier && (
183+
<Card.ActionText
184+
truncate
185+
localizationKey={localizationKeys('taskChooseOrganization.signOut.actionText', {
186+
identifier,
187+
})}
188+
/>
189+
)}
190+
<Card.ActionLink
191+
sx={() => ({ flexShrink: 0 })}
192+
onClick={() => {
193+
void handleSignOut();
194+
}}
195+
localizationKey={localizationKeys('taskChooseOrganization.signOut.actionLink')}
196+
/>
197+
</Card.Action>
198+
</Card.Footer>
199+
</Card.Root>
200+
</Flow.Part>
201+
</Flow.Root>
202+
);
203+
};
204+
205+
export const TaskResetPassword = withCoreSessionSwitchGuard(
206+
withTaskGuard(withCardStateProvider(TaskResetPasswordInternal)),
207+
);
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import type { ComponentType } from 'react';
2+
3+
import { warnings } from '@/core/warnings';
4+
import { withRedirect } from '@/ui/common';
5+
import { useTaskResetPasswordContext } from '@/ui/contexts/components/SessionTasks';
6+
import type { AvailableComponentProps } from '@/ui/types';
7+
8+
export const withTaskGuard = <P extends AvailableComponentProps>(Component: ComponentType<P>) => {
9+
const displayName = Component.displayName || Component.name || 'Component';
10+
Component.displayName = displayName;
11+
12+
const HOC = (props: P) => {
13+
const ctx = useTaskResetPasswordContext();
14+
return withRedirect(
15+
Component,
16+
clerk => !clerk.session?.currentTask,
17+
({ clerk }) =>
18+
!clerk.session ? clerk.buildSignInUrl() : (ctx.redirectUrlComplete ?? clerk.buildAfterSignInUrl()),
19+
warnings.cannotRenderComponentWhenTaskDoesNotExist,
20+
)(props);
21+
};
22+
23+
HOC.displayName = `withTaskGuard(${displayName})`;
24+
25+
return HOC;
26+
};

packages/clerk-js/src/ui/contexts/components/SessionTasks.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { createContext, useContext } from 'react';
22

3-
import type { SessionTasksCtx, TaskChooseOrganizationCtx } from '../../types';
3+
import type { SessionTasksCtx, TaskChooseOrganizationCtx, TaskResetPasswordCtx } from '../../types';
44

55
export const SessionTasksContext = createContext<SessionTasksCtx | null>(null);
66

@@ -27,3 +27,15 @@ export const useTaskChooseOrganizationContext = (): TaskChooseOrganizationCtx =>
2727

2828
return context;
2929
};
30+
31+
export const TaskResetPasswordContext = createContext<TaskResetPasswordCtx | null>(null);
32+
33+
export const useTaskResetPasswordContext = (): TaskResetPasswordCtx => {
34+
const context = useContext(TaskResetPasswordContext);
35+
36+
if (context === null) {
37+
throw new Error('Clerk: useTaskResetPasswordContext called outside of the mounted TaskResetPassword component.');
38+
}
39+
40+
return context;
41+
};

packages/clerk-js/src/ui/elements/contexts/index.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,8 @@ export type FlowMetadata = {
101101
| 'oauthConsent'
102102
| 'subscriptionDetails'
103103
| 'tasks'
104-
| 'taskChooseOrganization';
104+
| 'taskChooseOrganization'
105+
| 'taskResetPassword';
105106
part?:
106107
| 'start'
107108
| 'emailCode'

packages/clerk-js/src/ui/types.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import type {
2020
SignUpForceRedirectUrl,
2121
SignUpProps,
2222
TaskChooseOrganizationProps,
23+
TaskResetPasswordProps,
2324
UserAvatarProps,
2425
UserButtonProps,
2526
UserProfileProps,
@@ -151,6 +152,10 @@ export type TaskChooseOrganizationCtx = TaskChooseOrganizationProps & {
151152
componentName: 'TaskChooseOrganization';
152153
};
153154

155+
export type TaskResetPasswordCtx = TaskResetPasswordProps & {
156+
componentName: 'TaskResetPassword';
157+
};
158+
154159
export type OAuthConsentCtx = __internal_OAuthConsentProps & {
155160
componentName: 'OAuthConsent';
156161
};
@@ -182,5 +187,6 @@ export type AvailableComponentCtx =
182187
| OAuthConsentCtx
183188
| SubscriptionDetailsCtx
184189
| PlanDetailsCtx
185-
| TaskChooseOrganizationCtx;
190+
| TaskChooseOrganizationCtx
191+
| TaskResetPasswordCtx;
186192
export type AvailableComponentName = AvailableComponentCtx['componentName'];

0 commit comments

Comments
 (0)