Skip to content
Draft

wip #7587

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 @@ -110,6 +110,7 @@ import type {
SignUpResource,
TaskChooseOrganizationProps,
TaskResetPasswordProps,
TaskSetupMfaProps,
TasksRedirectOptions,
UnsubscribeCallback,
UserAvatarProps,
Expand Down Expand Up @@ -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.
*/
Expand Down
13 changes: 13 additions & 0 deletions packages/localizations/src/en-US.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
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 @@ -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;
Expand Down
13 changes: 13 additions & 0 deletions packages/shared/src/types/localization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'>;
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
9 changes: 9 additions & 0 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 @@ -68,6 +70,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 Down
Original file line number Diff line number Diff line change
@@ -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 (
<Flow.Root flow='taskSetupMfa'>
<Flow.Part part='methodSelection'>
<Card.Root>
<Card.Content>
<Header.Root showLogo>
<Header.Title localizationKey={localizationKeys('taskSetupMfa.title')} />
<Header.Subtitle localizationKey={localizationKeys('taskSetupMfa.subtitle')} />
</Header.Root>
<Col
gap={3}
sx={t => ({ marginTop: t.space.$4 })}
>
{availableMethods.map(method => (
<Button
key={method}
variant='outline'
colorScheme='neutral'
onClick={() => onMethodSelect(method)}
localizationKey={getMethodLabel(method)}
/>
))}
</Col>
</Card.Content>

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