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
23 changes: 23 additions & 0 deletions packages/clerk-js/src/core/clerk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ import type {
SignUpResource,
TaskChooseOrganizationProps,
TaskResetPasswordProps,
TaskSetupMFAProps,
TasksRedirectOptions,
UnsubscribeCallback,
UserAvatarProps,
Expand Down Expand Up @@ -1439,6 +1440,28 @@ export class Clerk implements ClerkInterface {
void this.#clerkUi?.then(ui => ui.ensureMounted()).then(controls => controls.unmountComponent({ node }));
};

public mountTaskSetupMfa = (node: HTMLDivElement, props?: TaskSetupMFAProps) => {
this.assertComponentsReady(this.#clerkUi);

const component = 'TaskSetupMFA';
void this.#clerkUi
.then(ui => ui.ensureMounted())
.then(controls =>
controls.mountComponent({
name: component,
appearanceKey: 'taskSetupMfa',
node,
props,
}),
);

this.telemetry?.record(eventPrebuiltComponentMounted('TaskSetupMfa', props));
};

public unmountTaskSetupMfa = (node: HTMLDivElement) => {
void this.#clerkUi?.then(ui => ui.ensureMounted()).then(controls => controls.unmountComponent({ node }));
};

/**
* `setActive` can be used to set the active session and/or organization.
*/
Expand Down
70 changes: 70 additions & 0 deletions packages/localizations/src/en-US.ts
Original file line number Diff line number Diff line change
Expand Up @@ -898,6 +898,76 @@ export const enUS: LocalizationResource = {
subtitle: 'Your account requires a new password before you can continue',
title: 'Reset your password',
},
taskSetupMfa: {
badge: 'Two-step verification setup',
start: {
title: 'Set up two-step verification',
subtitle: 'Choose which method you prefer to protect your account with an extra layer of security',
methodSelection: {
totp: 'Authenticator application',
phoneCode: 'SMS code',
},
},
smsCode: {
title: 'Add sms code verification',
subtitle: 'Choose phone number you want to use for SMS code two-step verification',
addPhoneNumber: 'Add phone number',
cancel: 'Cancel',
verifyPhone: {
title: 'Verify your phone number',
subtitle: 'Enter the verification code sent to',
formTitle: 'Verification code',
resendButton: "Didn't receive a code? Resend",
formButtonPrimary: 'Continue',
},
addPhone: {
infoText:
'A text message containing a verification code will be sent to this phone number. Message and data rates may apply.',
formButtonPrimary: 'Continue',
},
success: {
title: 'SMS code verification enabled',
message1:
'Two-step verification is now enabled. When signing in, you will need to enter a verification code sent to this phone number as an additional step.',
message2:
'Save these backup codes and store them somewhere safe. If you lose access to your authentication device, you can use backup codes to sign in.',
finishButton: 'Continue',
},
},
totpCode: {
title: 'Add authenticator application',
addAuthenticatorApp: {
infoText__ableToScan:
'Set up a new sign-in method in your authenticator app and scan the following QR code to link it to your account.',
infoText__unableToScan: 'Set up a new sign-in method in your authenticator and enter the Key provided below.',
inputLabel__unableToScan1:
'Make sure Time-based or One-time passwords is enabled, then finish linking your account.',
buttonUnableToScan__nonPrimary: "Can't scan QR code?",
buttonAbleToScan__nonPrimary: 'Scan QR code instead',
formButtonPrimary: 'Continue',
formButtonReset: 'Cancel',
},
verifyTotp: {
title: 'Add authenticator application',
subtitle: 'Enter verification code generated by your authenticator',
formTitle: 'Verification code',
formButtonPrimary: 'Continue',
formButtonReset: 'Cancel',
},
success: {
title: 'Authenticator application verification enabled',
message1:
'Two-step verification is now enabled. When signing in, you will need to enter a verification code from this authenticator as an additional step.',
message2:
'Save these backup codes and store them somewhere safe. If you lose access to your authentication device, you can use backup codes to sign in.',
finishButton: 'Continue',
},
},
signOut: {
actionText: 'Signed in as {{identifier}}',
actionLink: 'Sign out',
},
},
unstable__errors: {
already_a_member_in_organization: '{{email}} is already a member of the organization.',
avatar_file_size_exceeded: 'File size exceeds the maximum limit of 10MB. Please choose a smaller file.',
Expand Down
1 change: 1 addition & 0 deletions packages/shared/src/internal/clerk-js/sessionTasks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { buildURL } from './url';
export const INTERNAL_SESSION_TASK_ROUTE_BY_KEY: Record<SessionTask['key'], string> = {
'choose-organization': 'choose-organization',
'reset-password': 'reset-password',
'setup-mfa': 'setup-mfa',
} as const;

/**
Expand Down
25 changes: 25 additions & 0 deletions packages/shared/src/types/clerk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -672,6 +672,23 @@ export interface Clerk {
*/
unmountTaskResetPassword: (targetNode: HTMLDivElement) => void;

/**
* Mounts a TaskSetupMFA component at the target element.
* This component allows users to set up multi-factor authentication.
*
* @param targetNode - Target node to mount the TaskSetupMFA component.
* @param props - configuration parameters.
*/
mountTaskSetupMfa: (targetNode: HTMLDivElement, props?: TaskSetupMFAProps) => void;

/**
* Unmount a TaskSetupMFA component from the target element.
* If there is no component mounted at the target node, results in a noop.
*
* @param targetNode - Target node to unmount the TaskSetupMFA component from.
*/
unmountTaskSetupMfa: (targetNode: HTMLDivElement) => void;

/**
* @internal
* Loads Stripe libraries for commerce functionality
Expand Down Expand Up @@ -2284,6 +2301,14 @@ export type TaskResetPasswordProps = {
appearance?: ClerkAppearanceTheme;
};

export type TaskSetupMFAProps = {
/**
* Full URL or path to navigate to after successfully resolving all tasks
*/
redirectUrlComplete: string;
appearance?: ClerkAppearanceTheme;
};

export type CreateOrganizationInvitationParams = {
emailAddress: string;
role: OrganizationCustomRoleKey;
Expand Down
63 changes: 63 additions & 0 deletions packages/shared/src/types/localization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1327,6 +1327,69 @@ export type __internal_LocalizationResource = {
};
formButtonPrimary: LocalizationValue;
};
taskSetupMfa: {
badge: LocalizationValue;
start: {
title: LocalizationValue;
subtitle: LocalizationValue;
methodSelection: {
totp: LocalizationValue;
phoneCode: LocalizationValue;
};
};
smsCode: {
title: LocalizationValue;
subtitle: LocalizationValue;
addPhoneNumber: LocalizationValue;
cancel: LocalizationValue;
verifyPhone: {
title: LocalizationValue;
subtitle: LocalizationValue;
formTitle: LocalizationValue;
resendButton: LocalizationValue;
formButtonPrimary: LocalizationValue;
};
addPhone: {
infoText: LocalizationValue;
formButtonPrimary: LocalizationValue;
};
success: {
title: LocalizationValue;
message1: LocalizationValue;
message2: LocalizationValue;
finishButton: LocalizationValue;
};
};
totpCode: {
title: LocalizationValue;
addAuthenticatorApp: {
infoText__ableToScan: LocalizationValue;
infoText__unableToScan: LocalizationValue;
inputLabel__unableToScan1: LocalizationValue;
buttonUnableToScan__nonPrimary: LocalizationValue;
buttonAbleToScan__nonPrimary: LocalizationValue;
formButtonPrimary: LocalizationValue;
formButtonReset: LocalizationValue;
};
verifyTotp: {
title: LocalizationValue;
subtitle: LocalizationValue;
formTitle: LocalizationValue;
formButtonPrimary: LocalizationValue;
formButtonReset: LocalizationValue;
};
success: {
title: LocalizationValue;
message1: LocalizationValue;
message2: LocalizationValue;
finishButton: LocalizationValue;
};
};
signOut: {
actionText: LocalizationValue<'identifier'>;
actionLink: LocalizationValue;
};
};
web3SolanaWalletButtons: {
connect: LocalizationValue<'walletName'>;
continue: LocalizationValue<'walletName'>;
Expand Down
2 changes: 1 addition & 1 deletion packages/shared/src/types/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -335,7 +335,7 @@ export interface SessionTask {
/**
* A unique identifier for the task
*/
key: 'choose-organization' | 'reset-password';
key: 'choose-organization' | 'reset-password' | 'setup-mfa';
}

export type GetTokenOptions = {
Expand Down
7 changes: 6 additions & 1 deletion packages/ui/src/common/Wizard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { Animated } from '../elements/Animated';

type WizardProps = React.PropsWithChildren<{
step: number;
animate?: boolean;
}>;

type UseWizardProps = {
Expand All @@ -26,7 +27,11 @@ export const useWizard = (params: UseWizardProps = {}) => {
};

export const Wizard = (props: WizardProps) => {
const { step, children } = props;
const { step, children, animate = true } = props;

if (!animate) {
return React.Children.toArray(children)[step];
}

return <Animated>{React.Children.toArray(children)[step]}</Animated>;
};
85 changes: 48 additions & 37 deletions packages/ui/src/components/SessionTasks/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,13 @@ import {
SessionTasksContext,
TaskChooseOrganizationContext,
TaskResetPasswordContext,
TaskSetupMFAContext,
useSessionTasksContext,
} from '../../contexts/components/SessionTasks';
import { Route, Switch, useRouter } from '../../router';
import { TaskChooseOrganization } from './tasks/TaskChooseOrganization';
import { TaskResetPassword } from './tasks/TaskResetPassword';
import { TaskSetupMFA } from './tasks/TaskSetupMfa';

const SessionTasksStart = () => {
const clerk = useClerk();
Expand Down Expand Up @@ -50,6 +52,43 @@ const SessionTasksStart = () => {

function SessionTasksRoutes(): JSX.Element {
const ctx = useSessionTasksContext();
const clerk = useClerk();
const { navigate, currentPath } = useRouter();

const currentTaskContainer = useRef<HTMLDivElement>(null);

// If there are no pending tasks, navigate away from the tasks flow.
// This handles cases where a user with an active session returns to the tasks URL,
// for example by using browser back navigation. Since there are no pending tasks,
// we redirect them to their intended destination.
useEffect(() => {
// Tasks can only exist on pending sessions, but we check both conditions
// here to be defensive and ensure proper redirection
const task = clerk.session?.currentTask;
if (!task || clerk.session?.status === 'active') {
if (ctx.shouldAutoNavigateAway.current) {
void navigate(ctx.redirectUrlComplete);
}
return;
}

clerk.telemetry?.record(eventComponentMounted('SessionTask', { task: task.key }));
}, [clerk, currentPath, navigate, ctx.redirectUrlComplete, ctx.shouldAutoNavigateAway]);

if (!clerk.session?.currentTask && ctx.shouldAutoNavigateAway.current) {
return (
<Card.Root
sx={() => ({
minHeight: currentTaskContainer ? currentTaskContainer.current?.offsetHeight : undefined,
})}
>
<Card.Content sx={() => ({ flex: 1 })}>
<LoadingCardContainer />
</Card.Content>
<Card.Footer />
</Card.Root>
);
}

return (
<Flow.Root flow='tasks'>
Expand All @@ -68,6 +107,13 @@ function SessionTasksRoutes(): JSX.Element {
<TaskResetPassword />
</TaskResetPasswordContext.Provider>
</Route>
<Route path={INTERNAL_SESSION_TASK_ROUTE_BY_KEY['setup-mfa']}>
<TaskSetupMFAContext.Provider
value={{ componentName: 'TaskSetupMFA', redirectUrlComplete: ctx.redirectUrlComplete }}
>
<TaskSetupMFA />
</TaskSetupMFAContext.Provider>
</Route>
<Route index>
<SessionTasksStart />
</Route>
Expand All @@ -84,44 +130,9 @@ type SessionTasksProps = {
* @internal
*/
export const SessionTasks = withCardStateProvider(({ redirectUrlComplete }: SessionTasksProps) => {
const clerk = useClerk();
const { navigate } = useRouter();

const currentTaskContainer = useRef<HTMLDivElement>(null);

// If there are no pending tasks, navigate away from the tasks flow.
// This handles cases where a user with an active session returns to the tasks URL,
// for example by using browser back navigation. Since there are no pending tasks,
// we redirect them to their intended destination.
useEffect(() => {
// Tasks can only exist on pending sessions, but we check both conditions
// here to be defensive and ensure proper redirection
const task = clerk.session?.currentTask;
if (!task || clerk.session?.status === 'active') {
void navigate(redirectUrlComplete);
return;
}

clerk.telemetry?.record(eventComponentMounted('SessionTask', { task: task.key }));
}, [clerk, navigate, redirectUrlComplete]);

if (!clerk.session?.currentTask) {
return (
<Card.Root
sx={() => ({
minHeight: currentTaskContainer ? currentTaskContainer.current?.offsetHeight : undefined,
})}
>
<Card.Content sx={() => ({ flex: 1 })}>
<LoadingCardContainer />
</Card.Content>
<Card.Footer />
</Card.Root>
);
}

const shouldAutoNavigateAwayRef = useRef<boolean>(true);
return (
<SessionTasksContext.Provider value={{ redirectUrlComplete }}>
<SessionTasksContext.Provider value={{ redirectUrlComplete, shouldAutoNavigateAway: shouldAutoNavigateAwayRef }}>
<SessionTasksRoutes />
</SessionTasksContext.Provider>
);
Expand Down
Loading