Skip to content

Commit c09a0af

Browse files
authored
feat(elements): Add backup_code verification strategy (clerk#3627)
1 parent 5634ddc commit c09a0af

7 files changed

Lines changed: 148 additions & 25 deletions

File tree

.changeset/chilled-cougars-type.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
---
2+
'@clerk/elements': minor
3+
---
4+
5+
Add `backup_code` verification strategy
6+
7+
```tsx
8+
<SignIn.Step name='choose-strategy'>
9+
<SignIn.SupportedStrategy name='backup_code'>Use a backup code</SignIn.SupportedStrategy>
10+
<SignIn.Step>
11+
```
12+
13+
```tsx
14+
<SignIn.Step name='verifications'>
15+
<SignIn.Strategy name='backup_code'>
16+
<Clerk.Field name="backup_code">
17+
<Clerk.Label>Code:</Clerk.Label>
18+
<Clerk.Input />
19+
<Clerk.FieldError />
20+
</Clerk.Field>
21+
22+
<Clerk.Action submit>Continue</Clerk.Action>
23+
</SignIn.Strategy>
24+
<SignIn.Step>
25+
```

packages/elements/examples/nextjs/app/sign-in/[[...sign-in]]/page.tsx

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,20 @@ export default function SignInPage() {
231231
<Button>Send a code to your phone</Button>
232232
</SignIn.SupportedStrategy>
233233

234+
<SignIn.SupportedStrategy
235+
asChild
236+
name='backup_code'
237+
>
238+
<Button>Use a backup code</Button>
239+
</SignIn.SupportedStrategy>
240+
241+
<SignIn.SupportedStrategy
242+
asChild
243+
name='totp'
244+
>
245+
<Button>MFA</Button>
246+
</SignIn.SupportedStrategy>
247+
234248
<SignIn.SupportedStrategy
235249
asChild
236250
name='passkey'
@@ -354,6 +368,36 @@ export default function SignInPage() {
354368
<CustomSubmit>Verify</CustomSubmit>
355369
</SignIn.Strategy>
356370

371+
<SignIn.Strategy name='totp'>
372+
<P className='text-sm'>Please enter your authenticator code...</P>
373+
374+
<CustomField
375+
// eslint-disable-next-line jsx-a11y/no-autofocus
376+
autoFocus
377+
label='Authenticator Code'
378+
name='code'
379+
/>
380+
381+
<Clerk.FieldError className='block w-full font-mono text-red-400' />
382+
383+
<CustomSubmit>Verify</CustomSubmit>
384+
</SignIn.Strategy>
385+
386+
<SignIn.Strategy name='backup_code'>
387+
<P className='text-sm'>Please enter your backup code...</P>
388+
389+
<CustomField
390+
// eslint-disable-next-line jsx-a11y/no-autofocus
391+
autoFocus
392+
label='Backup Code'
393+
name='backup_code'
394+
/>
395+
396+
<Clerk.FieldError className='block w-full font-mono text-red-400' />
397+
398+
<CustomSubmit>Verify</CustomSubmit>
399+
</SignIn.Strategy>
400+
357401
<SignIn.Strategy name='reset_password_email_code'>
358402
<H3>Verify your email</H3>
359403

packages/elements/src/internals/machines/sign-in/router.machine.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -460,6 +460,33 @@ export const SignInRouterMachine = setup({
460460
target: 'ResetPassword',
461461
},
462462
],
463+
'STRATEGY.UPDATE': {
464+
description: 'Send event to verification machine to update the current strategy.',
465+
actions: sendTo('secondFactor', ({ event }) => event),
466+
target: '.Idle',
467+
},
468+
},
469+
initial: 'Idle',
470+
states: {
471+
Idle: {
472+
on: {
473+
'NAVIGATE.CHOOSE_STRATEGY': {
474+
description: 'Navigate to choose strategy screen.',
475+
actions: sendTo('secondFactor', ({ event }) => event),
476+
target: 'ChoosingStrategy',
477+
},
478+
},
479+
},
480+
ChoosingStrategy: {
481+
tags: ['route:choose-strategy'],
482+
on: {
483+
'NAVIGATE.PREVIOUS': {
484+
description: 'Go to Idle, and also tell firstFactor to go to Pending',
485+
target: 'Idle',
486+
actions: sendTo('secondFactor', { type: 'NAVIGATE.PREVIOUS' }),
487+
},
488+
},
489+
},
463490
},
464491
},
465492
ResetPassword: {

packages/elements/src/internals/machines/sign-in/verification.machine.ts

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { isClerkAPIResponseError } from '@clerk/shared/error';
12
import type {
23
AttemptFirstFactorParams,
34
EmailCodeAttempt,
@@ -168,14 +169,17 @@ const SignInVerificationMachine = setup({
168169
},
169170
),
170171
setConsoleError: ({ event }) => {
171-
if (process.env.NODE_ENV === 'development') {
172-
assertActorEventError(event);
172+
if (process.env.NODE_ENV !== 'development') {
173+
return;
174+
}
173175

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

177-
Please open an issue if you continue to run into this issue.`);
178-
}
178+
const error = isClerkAPIResponseError(event.error) ? event.error.errors[0].longMessage : event.error.message;
179+
180+
console.error(`Unable to fulfill the prepare or attempt request for the sign-in verification.
181+
Error: ${error}
182+
Please open an issue if you continue to run into this issue.`);
179183
},
180184
},
181185
guards: {

packages/elements/src/react/common/form/index.tsx

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,9 @@ const determineInputTypeFromName = (name: FormFieldProps['name']) => {
7373
if (name === 'code') {
7474
return 'otp' as const;
7575
}
76+
if (name === 'backup_code') {
77+
return 'backup_code' as const;
78+
}
7679

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

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

@@ -248,7 +252,7 @@ const useInput = ({
248252
});
249253
const value = useFormSelector(fieldValueSelector(name));
250254
const hasValue = Boolean(value);
251-
const type = inputType ?? determineInputTypeFromName(name);
255+
const type = inputType ?? determineInputTypeFromName(rawName);
252256
let shouldValidatePassword = false;
253257

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

311-
if (!name) {
312-
throw new Error('Clerk: <Input /> must be wrapped in a <Field> component or have a name prop.');
313-
}
314-
315315
// TODO: Implement clerk-js utils
316316
const shouldBeHidden = false;
317317

@@ -337,8 +337,13 @@ const useInput = ({
337337
type: 'text',
338338
spellCheck: false,
339339
};
340-
}
341-
if (type === 'password' && shouldValidatePassword) {
340+
} else if (type === 'backup_code') {
341+
props = {
342+
autoComplete: 'off',
343+
type: 'text',
344+
spellCheck: false,
345+
};
346+
} else if (type === 'password' && shouldValidatePassword) {
342347
props = {
343348
'data-has-passed-validation': hasPassedValiation ? true : undefined,
344349
};
@@ -798,7 +803,8 @@ const GlobalError = React.forwardRef<FormGlobalErrorElement, FormGlobalErrorProp
798803
const FieldError = React.forwardRef<FormFieldErrorElement, FormFieldErrorProps>(
799804
({ asChild = false, children, code, name, ...rest }, forwardedRef) => {
800805
const fieldContext = useFieldContext();
801-
const fieldName = fieldContext?.name || name;
806+
const rawFieldName = fieldContext?.name || name;
807+
const fieldName = rawFieldName === 'backup_code' ? 'code' : rawFieldName;
802808
const { feedback } = useFieldFeedback({ name: fieldName });
803809

804810
if (!(feedback?.type === 'error')) {

packages/elements/src/react/common/form/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ export type ClerkFieldId =
55
| 'code'
66
| 'confirmPassword'
77
| 'currentPassword'
8+
| 'backup_code' // special case of `code`
89
| 'emailAddress'
910
| 'firstName'
1011
| 'identifier'

packages/elements/src/react/sign-in/choose-strategy.tsx

Lines changed: 25 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
1-
import type { SignInFactor, SignInFirstFactor, SignInStrategy as TSignInStrategy } from '@clerk/types';
1+
import type { SignInFactor, SignInStrategy as TSignInStrategy } from '@clerk/types';
22
import { Slot } from '@radix-ui/react-slot';
33
import { useSelector } from '@xstate/react';
44
import * as React from 'react';
55
import type { ActorRefFrom } from 'xstate';
66

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

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

4444
export function SignInChooseStrategy({ children, ...props }: SignInChooseStrategyProps) {
4545
const routerRef = SignInRouterCtx.useActorRef();
46-
const activeState = useActiveTags(routerRef, ['route:first-factor', 'route:choose-strategy'], ActiveTagsMode.all);
46+
const activeStateFirstFactor = useActiveTags(
47+
routerRef,
48+
['route:first-factor', 'route:choose-strategy'],
49+
ActiveTagsMode.all,
50+
);
51+
52+
const activeStateSecondFactor = useActiveTags(
53+
routerRef,
54+
['route:second-factor', 'route:choose-strategy'],
55+
ActiveTagsMode.all,
56+
);
57+
58+
const activeState = activeStateFirstFactor || activeStateSecondFactor;
4759

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

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

95107
const supportedFirstFactors = snapshot.context.clerk.client.signIn.supportedFirstFactors;
96-
const factor = supportedFirstFactors.find(factor => name === factor.strategy);
97-
98-
const currentFirstFactor = useSelector(
99-
snapshot.children[SignInRouterSystemId.firstFactor] as unknown as ActorRefFrom<TSignInFirstFactorMachine>,
108+
const supportedSecondFactors = snapshot.context.clerk.client.signIn.supportedSecondFactors;
109+
const factor = [...supportedFirstFactors, ...supportedSecondFactors].find(factor => name === factor.strategy);
110+
111+
const currentFactor = useSelector(
112+
(snapshot.children[SignInRouterSystemId.firstFactor] ||
113+
snapshot.children[SignInRouterSystemId.secondFactor]) as unknown as ActorRefFrom<
114+
TSignInFirstFactorMachine | TSignInSecondFactorMachine
115+
>,
100116
state => state?.context.currentFactor?.strategy,
101117
);
102118

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

108124
// Don't render if the current factor is the same as the one we're trying to render
109-
if (currentFirstFactor === name) {
125+
if (currentFactor === name) {
110126
return null;
111127
}
112128

0 commit comments

Comments
 (0)