diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index 48d108389ce..418197c1644 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -110,6 +110,7 @@ import type { SignUpResource, TaskChooseOrganizationProps, TaskResetPasswordProps, + TaskSetupMfaProps, TasksRedirectOptions, UnsubscribeCallback, UserAvatarProps, @@ -1415,6 +1416,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 8f8cba4e707..6d7294ca6af 100644 --- a/packages/localizations/src/en-US.ts +++ b/packages/localizations/src/en-US.ts @@ -894,6 +894,19 @@ export const enUS: LocalizationResource = { subtitle: 'Your account requires a new password before you can continue', title: 'Reset your password', }, + taskSetupMfa: { + title: 'Set up two-step verification', + subtitle: 'Protect your account with an extra layer of security', + methodSelection: { + totpButton: 'Authenticator app', + phoneCodeButton: 'SMS code', + backupCodeButton: 'Backup codes', + }, + 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 33b15d3bfe8..f359bfaaecd 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 @@ -2236,6 +2253,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 806826143d8..24882539c46 100644 --- a/packages/shared/src/types/localization.ts +++ b/packages/shared/src/types/localization.ts @@ -1324,6 +1324,19 @@ export type __internal_LocalizationResource = { }; formButtonPrimary: LocalizationValue; }; + taskSetupMfa: { + title: LocalizationValue; + subtitle: LocalizationValue; + methodSelection: { + totpButton: LocalizationValue; + phoneCodeButton: LocalizationValue; + backupCodeButton: 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/components/SessionTasks/index.tsx b/packages/ui/src/components/SessionTasks/index.tsx index 9cca5b9b9c1..f51ee921e31 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(); @@ -68,6 +70,13 @@ function SessionTasksRoutes(): JSX.Element { + + + + + diff --git a/packages/ui/src/components/SessionTasks/tasks/TaskSetupMfa/MfaMethodSelectionScreen.tsx b/packages/ui/src/components/SessionTasks/tasks/TaskSetupMfa/MfaMethodSelectionScreen.tsx new file mode 100644 index 00000000000..7f40771d9f0 --- /dev/null +++ b/packages/ui/src/components/SessionTasks/tasks/TaskSetupMfa/MfaMethodSelectionScreen.tsx @@ -0,0 +1,95 @@ +import { useClerk, useSession, useUser } from '@clerk/shared/react'; + +import { useSignOutContext } from '@/ui/contexts'; +import { Button, Col, Flow, localizationKeys } from '@/ui/customizables'; +import { Card } from '@/ui/elements/Card'; +import { Header } from '@/ui/elements/Header'; +import { useMultipleSessions } from '@/ui/hooks/useMultipleSessions'; + +type MfaMethodSelectionScreenProps = { + availableMethods: string[]; + onMethodSelect: (method: string) => void; +}; + +export const MfaMethodSelectionScreen = (props: MfaMethodSelectionScreenProps) => { + const { availableMethods, onMethodSelect } = props; + const { signOut } = useClerk(); + const { user } = useUser(); + const { session } = useSession(); + const { otherSessions } = useMultipleSessions({ user }); + const { navigateAfterSignOut, navigateAfterMultiSessionSingleSignOutUrl } = useSignOutContext(); + + const handleSignOut = () => { + if (otherSessions.length === 0) { + return signOut(navigateAfterSignOut); + } + return signOut(navigateAfterMultiSessionSingleSignOutUrl, { sessionId: session?.id }); + }; + + const getMethodLabel = (method: string) => { + switch (method) { + case 'totp': + return localizationKeys('taskSetupMfa.methodSelection.totpButton'); + case 'phone_code': + return localizationKeys('taskSetupMfa.methodSelection.phoneCodeButton'); + case 'backup_code': + return localizationKeys('taskSetupMfa.methodSelection.backupCodeButton'); + default: + return method; + } + }; + + const identifier = user?.primaryEmailAddress?.emailAddress ?? user?.username; + + return ( + + + + + + + + + ({ marginTop: t.space.$4 })} + > + {availableMethods.map(method => ( +