Skip to content
Merged
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
25 changes: 25 additions & 0 deletions .changeset/chilled-cougars-type.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
---
'@clerk/elements': minor
---

Add `backup_code` verification strategy

```tsx
<SignIn.Step name='choose-strategy'>
<SignIn.SupportedStrategy name='backup_code'>Use a backup code</SignIn.SupportedStrategy>
<SignIn.Step>
```

```tsx
<SignIn.Step name='verifications'>
<SignIn.Strategy name='backup_code'>
<Clerk.Field name="backup_code">
<Clerk.Label>Code:</Clerk.Label>
<Clerk.Input />
<Clerk.FieldError />
</Clerk.Field>

<Clerk.Action submit>Continue</Clerk.Action>
</SignIn.Strategy>
<SignIn.Step>
```
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,20 @@ export default function SignInPage() {
<Button>Send a code to your phone</Button>
</SignIn.SupportedStrategy>

<SignIn.SupportedStrategy
asChild
name='backup_code'
>
<Button>Use a backup code</Button>
</SignIn.SupportedStrategy>

<SignIn.SupportedStrategy
asChild
name='totp'
>
<Button>MFA</Button>
</SignIn.SupportedStrategy>

<SignIn.SupportedStrategy
asChild
name='passkey'
Expand Down Expand Up @@ -354,6 +368,36 @@ export default function SignInPage() {
<CustomSubmit>Verify</CustomSubmit>
</SignIn.Strategy>

<SignIn.Strategy name='totp'>
<P className='text-sm'>Please enter your authenticator code...</P>

<CustomField
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus
label='Authenticator Code'
name='code'
/>

<Clerk.FieldError className='block w-full font-mono text-red-400' />

<CustomSubmit>Verify</CustomSubmit>
</SignIn.Strategy>

<SignIn.Strategy name='backup_code'>
<P className='text-sm'>Please enter your backup code...</P>

<CustomField
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus
label='Backup Code'
name='backup_code'
/>

<Clerk.FieldError className='block w-full font-mono text-red-400' />

<CustomSubmit>Verify</CustomSubmit>
</SignIn.Strategy>

<SignIn.Strategy name='reset_password_email_code'>
<H3>Verify your email</H3>

Expand Down
27 changes: 27 additions & 0 deletions packages/elements/src/internals/machines/sign-in/router.machine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -460,6 +460,33 @@ export const SignInRouterMachine = setup({
target: 'ResetPassword',
},
],
'STRATEGY.UPDATE': {
description: 'Send event to verification machine to update the current strategy.',
actions: sendTo('secondFactor', ({ event }) => event),
target: '.Idle',
},
},
initial: 'Idle',
states: {
Idle: {
on: {
'NAVIGATE.CHOOSE_STRATEGY': {
description: 'Navigate to choose strategy screen.',
actions: sendTo('secondFactor', ({ event }) => event),
target: 'ChoosingStrategy',
},
},
},
ChoosingStrategy: {
tags: ['route:choose-strategy'],
on: {
'NAVIGATE.PREVIOUS': {
description: 'Go to Idle, and also tell firstFactor to go to Pending',
target: 'Idle',
actions: sendTo('secondFactor', { type: 'NAVIGATE.PREVIOUS' }),
},
},
},
},
},
ResetPassword: {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { isClerkAPIResponseError } from '@clerk/shared/error';
import type {
AttemptFirstFactorParams,
EmailCodeAttempt,
Expand Down Expand Up @@ -168,14 +169,17 @@ const SignInVerificationMachine = setup({
},
),
setConsoleError: ({ event }) => {
if (process.env.NODE_ENV === 'development') {
assertActorEventError(event);
if (process.env.NODE_ENV !== 'development') {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed throw as it puts users into a completely irrecoverable state even if FieldError or GlobalError are present.

return;
}

throw new ClerkElementsRuntimeError(`Unable to fulfill the prepare or attempt request for the sign-in verification.
Error: ${event.error.message}
assertActorEventError(event);

Please open an issue if you continue to run into this issue.`);
}
const error = isClerkAPIResponseError(event.error) ? event.error.errors[0].longMessage : event.error.message;

console.error(`Unable to fulfill the prepare or attempt request for the sign-in verification.
Error: ${error}
Please open an issue if you continue to run into this issue.`);
},
},
guards: {
Expand Down
26 changes: 16 additions & 10 deletions packages/elements/src/react/common/form/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,9 @@ const determineInputTypeFromName = (name: FormFieldProps['name']) => {
if (name === 'code') {
return 'otp' as const;
}
if (name === 'backup_code') {
return 'backup_code' as const;
}

return 'text' as const;
};
Expand Down Expand Up @@ -199,11 +202,12 @@ const useInput = ({
}: FormInputProps) => {
// Inputs can be used outside a <Field> wrapper if desired, so safely destructure here
const fieldContext = useFieldContext();
const name = inputName || fieldContext?.name;
const rawName = inputName || fieldContext?.name;
const name = rawName === 'backup_code' ? 'code' : rawName; // `backup_code` is a special case of `code`
const { state: fieldState } = useFieldState({ name });
const validity = useValidityStateContext();

if (!name) {
if (!rawName || !name) {
throw new Error('Clerk: <Input /> must be wrapped in a <Field> component or have a name prop.');
}

Expand Down Expand Up @@ -248,7 +252,7 @@ const useInput = ({
});
const value = useFormSelector(fieldValueSelector(name));
const hasValue = Boolean(value);
const type = inputType ?? determineInputTypeFromName(name);
const type = inputType ?? determineInputTypeFromName(rawName);
let shouldValidatePassword = false;

if (type === 'password' || type === 'text') {
Expand Down Expand Up @@ -308,10 +312,6 @@ const useInput = ({
ref.send({ type: 'FIELD.UPDATE', field: { name, value: initialValue } });
}, [name, ref, initialValue]);

if (!name) {
throw new Error('Clerk: <Input /> must be wrapped in a <Field> component or have a name prop.');
}

// TODO: Implement clerk-js utils
const shouldBeHidden = false;

Expand All @@ -337,8 +337,13 @@ const useInput = ({
type: 'text',
spellCheck: false,
};
}
if (type === 'password' && shouldValidatePassword) {
} else if (type === 'backup_code') {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The code name has a number of predefined assumptions which don't work for backup codes.

The backup_code name circumvents those assumptions while still maintaining the required field name of code.

props = {
autoComplete: 'off',
type: 'text',
spellCheck: false,
};
} else if (type === 'password' && shouldValidatePassword) {
props = {
'data-has-passed-validation': hasPassedValiation ? true : undefined,
};
Expand Down Expand Up @@ -798,7 +803,8 @@ const GlobalError = React.forwardRef<FormGlobalErrorElement, FormGlobalErrorProp
const FieldError = React.forwardRef<FormFieldErrorElement, FormFieldErrorProps>(
({ asChild = false, children, code, name, ...rest }, forwardedRef) => {
const fieldContext = useFieldContext();
const fieldName = fieldContext?.name || name;
const rawFieldName = fieldContext?.name || name;
const fieldName = rawFieldName === 'backup_code' ? 'code' : rawFieldName;
const { feedback } = useFieldFeedback({ name: fieldName });

if (!(feedback?.type === 'error')) {
Expand Down
1 change: 1 addition & 0 deletions packages/elements/src/react/common/form/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export type ClerkFieldId =
| 'code'
| 'confirmPassword'
| 'currentPassword'
| 'backup_code' // special case of `code`
| 'emailAddress'
| 'firstName'
| 'identifier'
Expand Down
34 changes: 25 additions & 9 deletions packages/elements/src/react/sign-in/choose-strategy.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import type { SignInFactor, SignInFirstFactor, SignInStrategy as TSignInStrategy } from '@clerk/types';
import type { SignInFactor, SignInStrategy as TSignInStrategy } from '@clerk/types';
import { Slot } from '@radix-ui/react-slot';
import { useSelector } from '@xstate/react';
import * as React from 'react';
import type { ActorRefFrom } from 'xstate';

import type { TSignInFirstFactorMachine } from '~/internals/machines/sign-in';
import type { TSignInFirstFactorMachine, TSignInSecondFactorMachine } from '~/internals/machines/sign-in';
import { SignInRouterSystemId } from '~/internals/machines/sign-in';

import { useActiveTags } from '../hooks';
Expand Down Expand Up @@ -43,7 +43,19 @@ export const SignInChooseStrategyCtx = createContextForDomValidation('SignInChoo

export function SignInChooseStrategy({ children, ...props }: SignInChooseStrategyProps) {
const routerRef = SignInRouterCtx.useActorRef();
const activeState = useActiveTags(routerRef, ['route:first-factor', 'route:choose-strategy'], ActiveTagsMode.all);
const activeStateFirstFactor = useActiveTags(
routerRef,
['route:first-factor', 'route:choose-strategy'],
ActiveTagsMode.all,
);

const activeStateSecondFactor = useActiveTags(
routerRef,
['route:second-factor', 'route:choose-strategy'],
ActiveTagsMode.all,
);

const activeState = activeStateFirstFactor || activeStateSecondFactor;

return activeState ? (
<SignInChooseStrategyCtx.Provider>
Expand All @@ -68,7 +80,7 @@ const SUPPORTED_STRATEGY_NAME = 'SignInSupportedStrategy';
export type SignInSupportedStrategyElement = React.ElementRef<'button'>;
export type SignInSupportedStrategyProps = {
asChild?: boolean;
name: Exclude<SignInFirstFactor['strategy'], `oauth_${string}` | 'saml'>;
name: Exclude<SignInFactor['strategy'], `oauth_${string}` | 'saml'>;
children: React.ReactNode;
};

Expand All @@ -93,10 +105,14 @@ export const SignInSupportedStrategy = React.forwardRef<SignInSupportedStrategyE
const snapshot = routerRef.getSnapshot();

const supportedFirstFactors = snapshot.context.clerk.client.signIn.supportedFirstFactors;
const factor = supportedFirstFactors.find(factor => name === factor.strategy);

const currentFirstFactor = useSelector(
snapshot.children[SignInRouterSystemId.firstFactor] as unknown as ActorRefFrom<TSignInFirstFactorMachine>,
const supportedSecondFactors = snapshot.context.clerk.client.signIn.supportedSecondFactors;
const factor = [...supportedFirstFactors, ...supportedSecondFactors].find(factor => name === factor.strategy);

const currentFactor = useSelector(
(snapshot.children[SignInRouterSystemId.firstFactor] ||
snapshot.children[SignInRouterSystemId.secondFactor]) as unknown as ActorRefFrom<
TSignInFirstFactorMachine | TSignInSecondFactorMachine
>,
state => state?.context.currentFactor?.strategy,
);

Expand All @@ -106,7 +122,7 @@ export const SignInSupportedStrategy = React.forwardRef<SignInSupportedStrategyE
);

// Don't render if the current factor is the same as the one we're trying to render
if (currentFirstFactor === name) {
if (currentFactor === name) {
return null;
}

Expand Down