Skip to content
Closed
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
1 change: 1 addition & 0 deletions packages/clerk-js/src/core/sessionTasks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { buildURL, forwardClerkQueryParams } from '../utils';
*/
export const INTERNAL_SESSION_TASK_ROUTE_BY_KEY: Record<SessionTask['key'], string> = {
'choose-organization': 'choose-organization',
'reset-password': 'reset-password',
} as const;

/**
Expand Down
9 changes: 9 additions & 0 deletions packages/clerk-js/src/ui/components/SessionTasks/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -60,6 +62,13 @@ function SessionTasksRoutes(): JSX.Element {
<TaskChooseOrganization />
</TaskChooseOrganizationContext.Provider>
</Route>
<Route path={INTERNAL_SESSION_TASK_ROUTE_BY_KEY['reset-password']}>
<TaskResetPasswordContext.Provider
value={{ componentName: 'TaskResetPassword', redirectUrlComplete: ctx.redirectUrlComplete }}
>
<TaskResetPassword />
</TaskResetPasswordContext.Provider>
</Route>
<Route index>
<SessionTasksStart />
</Route>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
@@ -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(<TaskResetPassword />, { wrapper });

expect(queryByText('New password')).not.toBeInTheDocument();
expect(queryByText('Confirm password')).not.toBeInTheDocument();
expect(queryByRole('button', { name: /sign out/i })).not.toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
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<UserResource['updatePassword']>) => 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, [
{
currentPassword: currentPasswordRequired ? currentPasswordField.value : undefined,
newPassword: passwordField.value,
signOutOfOtherSessions: sessionsField.checked,
},
]);
} catch (e) {
return handleError(e, [currentPasswordField, passwordField, confirmField], card.setError);
}
};

const identifier = user?.primaryEmailAddress?.emailAddress ?? user?.username;

return (
<Flow.Root flow='taskResetPassword'>
<Flow.Part part='resetPassword'>
<Card.Root>
<Card.Content>
<Header.Root showLogo>
<Header.Title localizationKey={localizationKeys('signIn.resetPassword.title')} />
</Header.Root>
<Card.Alert>{card.error}</Card.Alert>
<Col
elementDescriptor={descriptors.main}
gap={8}
>
<Form.Root
onSubmit={() => {
void resetPassword();
}}
onBlur={validateForm}
gap={8}
>
<Col gap={6}>
{/* For password managers */}
<input
readOnly
data-testid='hidden-identifier'
id='identifier-field'
name='identifier'
value={session?.publicUserData.identifier || ''}
style={{ display: 'none' }}
/>
{currentPasswordRequired && (
<Form.ControlRow elementId={currentPasswordField.id}>
<Form.PasswordInput
{...currentPasswordField.props}
minLength={6}
isRequired
autoFocus
/>
</Form.ControlRow>
)}
<Form.ControlRow elementId={passwordField.id}>
<Form.PasswordInput
{...passwordField.props}
isRequired
minLength={6}
/>
</Form.ControlRow>
<Form.ControlRow elementId={confirmField.id}>
<Form.PasswordInput
{...confirmField.props}
onChange={e => {
if (e.target.value) {
setConfirmPasswordFeedback(e.target.value);
}
return confirmField.props.onChange(e);
}}
/>
</Form.ControlRow>
<Form.ControlRow elementId={sessionsField.id}>
<Form.Checkbox {...sessionsField.props} />
</Form.ControlRow>
</Col>
<Col gap={3}>
<Form.SubmitButton
isDisabled={!canSubmit}
localizationKey={localizationKeys('signIn.resetPassword.formButtonPrimary')}
/>
</Col>
</Form.Root>
</Col>
</Card.Content>

<Card.Footer>
<Card.Action
elementId='signOut'
gap={2}
justify='center'
sx={() => ({ width: '100%' })}
>
{identifier && (
<Card.ActionText
truncate
localizationKey={localizationKeys('taskChooseOrganization.signOut.actionText', {
identifier,
})}
/>
)}
<Card.ActionLink
sx={() => ({ flexShrink: 0 })}
onClick={() => {
void handleSignOut();
}}
localizationKey={localizationKeys('taskChooseOrganization.signOut.actionLink')}
/>
</Card.Action>
</Card.Footer>
</Card.Root>
</Flow.Part>
</Flow.Root>
);
};

export const TaskResetPassword = withCoreSessionSwitchGuard(
withTaskGuard(withCardStateProvider(TaskResetPasswordInternal)),
);
Original file line number Diff line number Diff line change
@@ -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 = <P extends AvailableComponentProps>(Component: ComponentType<P>) => {
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;
};
14 changes: 13 additions & 1 deletion packages/clerk-js/src/ui/contexts/components/SessionTasks.ts
Original file line number Diff line number Diff line change
@@ -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<SessionTasksCtx | null>(null);

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

return context;
};

export const TaskResetPasswordContext = createContext<TaskResetPasswordCtx | null>(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;
};
3 changes: 2 additions & 1 deletion packages/clerk-js/src/ui/elements/contexts/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,8 @@ export type FlowMetadata = {
| 'oauthConsent'
| 'subscriptionDetails'
| 'tasks'
| 'taskChooseOrganization';
| 'taskChooseOrganization'
| 'taskResetPassword';
part?:
| 'start'
| 'emailCode'
Expand Down
8 changes: 7 additions & 1 deletion packages/clerk-js/src/ui/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import type {
SignUpForceRedirectUrl,
SignUpProps,
TaskChooseOrganizationProps,
TaskResetPasswordProps,
UserAvatarProps,
UserButtonProps,
UserProfileProps,
Expand Down Expand Up @@ -151,6 +152,10 @@ export type TaskChooseOrganizationCtx = TaskChooseOrganizationProps & {
componentName: 'TaskChooseOrganization';
};

export type TaskResetPasswordCtx = TaskResetPasswordProps & {
componentName: 'TaskResetPassword';
};

export type OAuthConsentCtx = __internal_OAuthConsentProps & {
componentName: 'OAuthConsent';
};
Expand Down Expand Up @@ -182,5 +187,6 @@ export type AvailableComponentCtx =
| OAuthConsentCtx
| SubscriptionDetailsCtx
| PlanDetailsCtx
| TaskChooseOrganizationCtx;
| TaskChooseOrganizationCtx
| TaskResetPasswordCtx;
export type AvailableComponentName = AvailableComponentCtx['componentName'];
1 change: 1 addition & 0 deletions packages/shared/src/types/appearance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
/**
Expand Down
Loading