diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index 4678c2266d0..9aecd1bc21f 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -111,6 +111,7 @@ import type { SignUpResource, TaskChooseOrganizationProps, TaskResetPasswordProps, + TaskSetupMFAProps, TasksRedirectOptions, UnsubscribeCallback, UserAvatarProps, @@ -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. */ diff --git a/packages/localizations/src/en-US.ts b/packages/localizations/src/en-US.ts index 15256e3e1c2..a164c7f1b0c 100644 --- a/packages/localizations/src/en-US.ts +++ b/packages/localizations/src/en-US.ts @@ -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.', diff --git a/packages/shared/src/internal/clerk-js/sessionTasks.ts b/packages/shared/src/internal/clerk-js/sessionTasks.ts index eb8a3f3ca99..e0d0fd1e0f8 100644 --- a/packages/shared/src/internal/clerk-js/sessionTasks.ts +++ b/packages/shared/src/internal/clerk-js/sessionTasks.ts @@ -9,6 +9,7 @@ import { buildURL } from './url'; export const INTERNAL_SESSION_TASK_ROUTE_BY_KEY: Record = { 'choose-organization': 'choose-organization', 'reset-password': 'reset-password', + 'setup-mfa': 'setup-mfa', } as const; /** diff --git a/packages/shared/src/types/clerk.ts b/packages/shared/src/types/clerk.ts index 9d3f7945f38..5b64415ba42 100644 --- a/packages/shared/src/types/clerk.ts +++ b/packages/shared/src/types/clerk.ts @@ -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 @@ -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; diff --git a/packages/shared/src/types/localization.ts b/packages/shared/src/types/localization.ts index 7c3b5ae0fc0..d97301630d0 100644 --- a/packages/shared/src/types/localization.ts +++ b/packages/shared/src/types/localization.ts @@ -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'>; diff --git a/packages/shared/src/types/session.ts b/packages/shared/src/types/session.ts index 727c044ad48..f35b1454c24 100644 --- a/packages/shared/src/types/session.ts +++ b/packages/shared/src/types/session.ts @@ -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 = { diff --git a/packages/ui/src/common/Wizard.tsx b/packages/ui/src/common/Wizard.tsx index 77bd735452a..aee4cd4d8a4 100644 --- a/packages/ui/src/common/Wizard.tsx +++ b/packages/ui/src/common/Wizard.tsx @@ -4,6 +4,7 @@ import { Animated } from '../elements/Animated'; type WizardProps = React.PropsWithChildren<{ step: number; + animate?: boolean; }>; type UseWizardProps = { @@ -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 {React.Children.toArray(children)[step]}; }; diff --git a/packages/ui/src/components/SessionTasks/index.tsx b/packages/ui/src/components/SessionTasks/index.tsx index 9cca5b9b9c1..3316a395c37 100644 --- a/packages/ui/src/components/SessionTasks/index.tsx +++ b/packages/ui/src/components/SessionTasks/index.tsx @@ -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(); @@ -50,6 +52,43 @@ const SessionTasksStart = () => { function SessionTasksRoutes(): JSX.Element { const ctx = useSessionTasksContext(); + const clerk = useClerk(); + const { navigate, currentPath } = useRouter(); + + const currentTaskContainer = useRef(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 ( + ({ + minHeight: currentTaskContainer ? currentTaskContainer.current?.offsetHeight : undefined, + })} + > + ({ flex: 1 })}> + + + + + ); + } return ( @@ -68,6 +107,13 @@ function SessionTasksRoutes(): JSX.Element { + + + + + @@ -84,44 +130,9 @@ type SessionTasksProps = { * @internal */ export const SessionTasks = withCardStateProvider(({ redirectUrlComplete }: SessionTasksProps) => { - const clerk = useClerk(); - const { navigate } = useRouter(); - - const currentTaskContainer = useRef(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 ( - ({ - minHeight: currentTaskContainer ? currentTaskContainer.current?.offsetHeight : undefined, - })} - > - ({ flex: 1 })}> - - - - - ); - } - + const shouldAutoNavigateAwayRef = useRef(true); return ( - + ); diff --git a/packages/ui/src/components/SessionTasks/tasks/TaskSetupMfa/SetupMfaStartScreen.tsx b/packages/ui/src/components/SessionTasks/tasks/TaskSetupMfa/SetupMfaStartScreen.tsx new file mode 100644 index 00000000000..3d32f417fc3 --- /dev/null +++ b/packages/ui/src/components/SessionTasks/tasks/TaskSetupMfa/SetupMfaStartScreen.tsx @@ -0,0 +1,107 @@ +import type { VerificationStrategy } from '@clerk/shared/types'; + +import { Actions } from '@/elements/Actions'; +import { useCardState } from '@/elements/contexts'; +import { PreviewButton } from '@/elements/PreviewButton'; +import { AuthApp, Mobile } from '@/icons'; +import { Col, descriptors, Flex, Icon, localizationKeys, Text } from '@/ui/customizables'; +import { Card } from '@/ui/elements/Card'; +import { Header } from '@/ui/elements/Header'; + +import { MFA_METHODS_TO_STEP } from './constants'; +import { SharedFooterActionForSignOut } from './shared'; + +type SetupMfaStartScreenProps = { + availableMethods: VerificationStrategy[]; + goToStep: (step: number) => void; +}; + +const getMethodIconAndLabel = (method: VerificationStrategy) => { + switch (method) { + case 'totp': + return { icon: , label: localizationKeys('taskSetupMfa.start.methodSelection.totp') }; + case 'phone_code': + return { + icon: , + label: localizationKeys('taskSetupMfa.start.methodSelection.phoneCode'), + }; + default: + return { icon: null, label: null }; + } +}; + +export const SetupMfaStartScreen = (props: SetupMfaStartScreenProps) => { + const { availableMethods, goToStep } = props; + const card = useCardState(); + + return ( + + ({ padding: t.space.$none })}> + ({ + paddingTop: t.space.$8, + paddingLeft: t.space.$8, + paddingRight: t.space.$8, + })} + > + + + + {card.error} + ({ + borderTopWidth: t.borderWidths.$normal, + borderTopStyle: t.borderStyles.$solid, + borderTopColor: t.colors.$borderAlpha100, + })} + > + {availableMethods.map(method => { + const { icon, label } = getMethodIconAndLabel(method); + + if (!icon || !label) { + return null; + } + + return ( + { + goToStep(MFA_METHODS_TO_STEP[method as keyof typeof MFA_METHODS_TO_STEP]); + }} + > + ({ gap: t.space.$2, alignItems: 'center' })}> + ({ + borderRadius: t.radii.$circle, + borderWidth: t.borderWidths.$normal, + borderStyle: t.borderStyles.$solid, + borderColor: t.colors.$avatarBorder, + padding: t.space.$2, + backgroundColor: t.colors.$neutralAlpha50, + })} + > + {icon} + + + + + ); + })} + + + + + + + + ); +}; diff --git a/packages/ui/src/components/SessionTasks/tasks/TaskSetupMfa/SmsCodeFlowScreen.tsx b/packages/ui/src/components/SessionTasks/tasks/TaskSetupMfa/SmsCodeFlowScreen.tsx new file mode 100644 index 00000000000..ec4bc8d09fc --- /dev/null +++ b/packages/ui/src/components/SessionTasks/tasks/TaskSetupMfa/SmsCodeFlowScreen.tsx @@ -0,0 +1,395 @@ +import { useReverification, useUser } from '@clerk/shared/react'; +import type { PhoneNumberResource, UserResource } from '@clerk/shared/types'; +import React, { useRef } from 'react'; + +import { useWizard, Wizard } from '@/common'; +import { MfaBackupCodeList } from '@/components/UserProfile/MfaBackupCodeList'; +import { Action, Actions } from '@/elements/Actions'; +import { useCardState } from '@/elements/contexts'; +import { Form } from '@/elements/Form'; +import { FormButtonContainer } from '@/elements/FormButtons'; +import { PreviewButton } from '@/elements/PreviewButton'; +import { type VerificationCodeCardProps, VerificationCodeContent } from '@/elements/VerificationCodeCard'; +import { Add } from '@/icons'; +import { Button, Col, descriptors, Flex, Flow, localizationKeys, Text } from '@/ui/customizables'; +import { Card } from '@/ui/elements/Card'; +import { Header } from '@/ui/elements/Header'; +import { SuccessPage } from '@/ui/elements/SuccessPage'; +import { handleError } from '@/ui/utils/errorHandler'; +import { getFlagEmojiFromCountryIso, parsePhoneString, stringToFormattedPhoneString } from '@/ui/utils/phoneUtils'; +import { useFormControl } from '@/ui/utils/useFormControl'; + +import { SharedFooterActionForSignOut } from './shared'; + +type MFAVerifyPhoneForSessionTasksProps = { + resourceRef: React.MutableRefObject; + onSuccess: () => void; + onReset: () => void; +}; + +const MFAVerifyPhoneForSessionTasks = (props: MFAVerifyPhoneForSessionTasksProps) => { + const { onSuccess, resourceRef, onReset } = props; + const card = useCardState(); + const phone = resourceRef.current; + const setReservedForSecondFactor = useReverification(() => phone?.setReservedForSecondFactor({ reserved: true })); + + const prepare = () => { + return resourceRef.current?.prepareVerification?.()?.catch(err => handleError(err, [], card.setError)); + }; + + const enableMfa = async () => { + card.setLoading(phone?.id); + try { + const result = await setReservedForSecondFactor(); + resourceRef.current = result; + onSuccess(); + } catch (err) { + handleError(err as Error, [], card.setError); + } finally { + card.setIdle(); + } + }; + + React.useEffect(() => { + void prepare(); + }, []); + + const action: VerificationCodeCardProps['onCodeEntryFinishedAction'] = (code, resolve, reject) => { + void resourceRef.current + ?.attemptVerification({ code: code }) + .then(async () => { + await resolve(); + enableMfa(); + }) + .catch(reject); + }; + + return ( + + onReset()} + onBackLinkClicked={() => onReset()} + backLinkLabel={localizationKeys('taskSetupMfa.smsCode.cancel')} + /> + + ); +}; + +type AddPhoneForSessionTasksProps = { + resourceRef: React.MutableRefObject; + onSuccess: () => void; + onReset: () => void; + onUseExistingNumberClick: () => void; +}; + +const AddPhoneForSessionTasks = (props: AddPhoneForSessionTasksProps) => { + const { resourceRef, onSuccess, onReset } = props; + const card = useCardState(); + const { user } = useUser(); + const createPhoneNumber = useReverification( + (user: UserResource, opt: Parameters[0]) => user.createPhoneNumber(opt), + ); + + const phoneField = useFormControl('phoneNumber', '', { + type: 'tel', + label: localizationKeys('formFieldLabel__phoneNumber'), + isRequired: true, + }); + + const canSubmit = phoneField.value.length > 1 && user?.username !== phoneField.value; + + const addPhone = async (e: React.FormEvent) => { + e.preventDefault(); + if (!user) { + return; + } + card.runAsync(async () => { + try { + const res = await createPhoneNumber(user, { phoneNumber: phoneField.value }); + resourceRef.current = res; + onSuccess(); + } catch (e) { + handleError(e as Error, [phoneField], card.setError); + } + }); + }; + + return ( + + + + + + {card.error} + void addPhone(e)}> + + + + ({ + flexDirection: 'column', + gap: theme.space.$4, + })} + > + + + + + + ); +}; + +type SuccessScreenProps = { + resourceRef: React.MutableRefObject; + onFinish: () => void; +}; + +const SuccessScreen = (props: SuccessScreenProps) => { + const { resourceRef, onFinish } = props; + + return ( + + + } + finishLabel={localizationKeys('taskSetupMfa.smsCode.success.finishButton')} + finishButtonProps={{ + block: true, + hasArrow: true, + }} + /> + + ); +}; + +type PhoneItemProps = { + phone: PhoneNumberResource; + onSuccess: () => void; + onUnverifiedPhoneClick: (phone: PhoneNumberResource) => void; + resourceRef: React.MutableRefObject; +}; + +const PhoneItem = ({ phone, onSuccess, onUnverifiedPhoneClick, resourceRef }: PhoneItemProps) => { + const card = useCardState(); + const setReservedForSecondFactor = useReverification(() => phone.setReservedForSecondFactor({ reserved: true })); + + const { iso } = parsePhoneString(phone.phoneNumber); + const flag = getFlagEmojiFromCountryIso(iso); + const formattedPhone = stringToFormattedPhoneString(phone.phoneNumber); + + const handleSelect = async () => { + if (phone.verification.status !== 'verified') { + return onUnverifiedPhoneClick(phone); + } + + card.setLoading(phone.id); + try { + const result = await setReservedForSecondFactor(); + resourceRef.current = result; + onSuccess(); + } catch (err) { + handleError(err as Error, [], card.setError); + } finally { + card.setIdle(); + } + }; + + return ( + ({ + padding: `${t.space.$4} ${t.space.$6}`, + })} + onClick={() => void handleSelect()} + > + ({ gap: t.space.$4, alignItems: 'center' })}> + ({ fontSize: t.fontSizes.$lg })}>{flag} + {formattedPhone} + + + ); +}; + +type SmsCodeScreenProps = { + onSuccess: () => void; + onReset: () => void; + onAddPhoneClick: () => void; + onUnverifiedPhoneClick: (phone: PhoneNumberResource) => void; + resourceRef: React.MutableRefObject; + availablePhones: PhoneNumberResource[]; +}; + +const SmsCodeScreen = (props: SmsCodeScreenProps) => { + const { onSuccess, onReset, onAddPhoneClick, onUnverifiedPhoneClick, resourceRef } = props; + const { user } = useUser(); + const card = useCardState(); + + if (!user) { + return null; + } + + const availablePhones = user.phoneNumbers.filter(p => !p.reservedForSecondFactor); + + return ( + + ({ padding: t.space.$none })}> + ({ + paddingTop: t.space.$8, + paddingLeft: t.space.$8, + paddingRight: t.space.$8, + })} + > + + + + {card.error} + + ({ + borderTopWidth: t.borderWidths.$normal, + borderTopStyle: t.borderStyles.$solid, + borderTopColor: t.colors.$borderAlpha100, + })} + > + {availablePhones?.map(phone => ( + + ))} + ({ + borderTopWidth: t.borderWidths.$normal, + borderTopStyle: t.borderStyles.$solid, + borderTopColor: t.colors.$borderAlpha100, + padding: `${t.space.$4} ${t.space.$4}`, + gap: t.space.$2, + })} + iconSx={t => ({ + width: t.sizes.$8, + height: t.sizes.$6, + })} + /> + + ({ + borderTopWidth: t.borderWidths.$normal, + borderTopStyle: t.borderStyles.$solid, + borderTopColor: t.colors.$borderAlpha100, + padding: t.space.$4, + })} + > +