From 64cb67fd3925a6ff3d73673e8d6c20a90ea0c6aa Mon Sep 17 00:00:00 2001 From: Tom Milewski Date: Wed, 26 Jun 2024 19:00:54 -0400 Subject: [PATCH] feat(elements): Add backup_code verification strategy --- .changeset/chilled-cougars-type.md | 25 +++++++++++ .../app/sign-in/[[...sign-in]]/page.tsx | 44 +++++++++++++++++++ .../machines/sign-in/router.machine.ts | 27 ++++++++++++ .../machines/sign-in/verification.machine.ts | 16 ++++--- .../elements/src/react/common/form/index.tsx | 26 ++++++----- .../elements/src/react/common/form/types.ts | 1 + .../src/react/sign-in/choose-strategy.tsx | 34 ++++++++++---- 7 files changed, 148 insertions(+), 25 deletions(-) create mode 100644 .changeset/chilled-cougars-type.md diff --git a/.changeset/chilled-cougars-type.md b/.changeset/chilled-cougars-type.md new file mode 100644 index 00000000000..c9cb37db9b2 --- /dev/null +++ b/.changeset/chilled-cougars-type.md @@ -0,0 +1,25 @@ +--- +'@clerk/elements': minor +--- + +Add `backup_code` verification strategy + +```tsx + + Use a backup code + +``` + +```tsx + + + + Code: + + + + + Continue + + +``` diff --git a/packages/elements/examples/nextjs/app/sign-in/[[...sign-in]]/page.tsx b/packages/elements/examples/nextjs/app/sign-in/[[...sign-in]]/page.tsx index ca2b2f2ddd8..0b7cbf23952 100644 --- a/packages/elements/examples/nextjs/app/sign-in/[[...sign-in]]/page.tsx +++ b/packages/elements/examples/nextjs/app/sign-in/[[...sign-in]]/page.tsx @@ -231,6 +231,20 @@ export default function SignInPage() { + + + + + + + + Verify + +

Please enter your authenticator code...

+ + + + + + Verify +
+ + +

Please enter your backup code...

+ + + + + + Verify +
+

Verify your email

diff --git a/packages/elements/src/internals/machines/sign-in/router.machine.ts b/packages/elements/src/internals/machines/sign-in/router.machine.ts index 61cdef76336..89226f70567 100644 --- a/packages/elements/src/internals/machines/sign-in/router.machine.ts +++ b/packages/elements/src/internals/machines/sign-in/router.machine.ts @@ -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: { diff --git a/packages/elements/src/internals/machines/sign-in/verification.machine.ts b/packages/elements/src/internals/machines/sign-in/verification.machine.ts index a0d749e2bd0..e0dacac80be 100644 --- a/packages/elements/src/internals/machines/sign-in/verification.machine.ts +++ b/packages/elements/src/internals/machines/sign-in/verification.machine.ts @@ -1,3 +1,4 @@ +import { isClerkAPIResponseError } from '@clerk/shared/error'; import type { AttemptFirstFactorParams, EmailCodeAttempt, @@ -168,14 +169,17 @@ const SignInVerificationMachine = setup({ }, ), setConsoleError: ({ event }) => { - if (process.env.NODE_ENV === 'development') { - assertActorEventError(event); + if (process.env.NODE_ENV !== 'development') { + 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: { diff --git a/packages/elements/src/react/common/form/index.tsx b/packages/elements/src/react/common/form/index.tsx index 4975352567e..13146b6cbe0 100644 --- a/packages/elements/src/react/common/form/index.tsx +++ b/packages/elements/src/react/common/form/index.tsx @@ -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; }; @@ -199,11 +202,12 @@ const useInput = ({ }: FormInputProps) => { // Inputs can be used outside a 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: must be wrapped in a component or have a name prop.'); } @@ -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') { @@ -308,10 +312,6 @@ const useInput = ({ ref.send({ type: 'FIELD.UPDATE', field: { name, value: initialValue } }); }, [name, ref, initialValue]); - if (!name) { - throw new Error('Clerk: must be wrapped in a component or have a name prop.'); - } - // TODO: Implement clerk-js utils const shouldBeHidden = false; @@ -337,8 +337,13 @@ const useInput = ({ type: 'text', spellCheck: false, }; - } - if (type === 'password' && shouldValidatePassword) { + } else if (type === 'backup_code') { + props = { + autoComplete: 'off', + type: 'text', + spellCheck: false, + }; + } else if (type === 'password' && shouldValidatePassword) { props = { 'data-has-passed-validation': hasPassedValiation ? true : undefined, }; @@ -798,7 +803,8 @@ const GlobalError = React.forwardRef( ({ 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')) { diff --git a/packages/elements/src/react/common/form/types.ts b/packages/elements/src/react/common/form/types.ts index c9fedb844b2..b63efc7420f 100644 --- a/packages/elements/src/react/common/form/types.ts +++ b/packages/elements/src/react/common/form/types.ts @@ -5,6 +5,7 @@ export type ClerkFieldId = | 'code' | 'confirmPassword' | 'currentPassword' + | 'backup_code' // special case of `code` | 'emailAddress' | 'firstName' | 'identifier' diff --git a/packages/elements/src/react/sign-in/choose-strategy.tsx b/packages/elements/src/react/sign-in/choose-strategy.tsx index fbf6e8eb359..eb0ca634c01 100644 --- a/packages/elements/src/react/sign-in/choose-strategy.tsx +++ b/packages/elements/src/react/sign-in/choose-strategy.tsx @@ -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'; @@ -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 ? ( @@ -68,7 +80,7 @@ const SUPPORTED_STRATEGY_NAME = 'SignInSupportedStrategy'; export type SignInSupportedStrategyElement = React.ElementRef<'button'>; export type SignInSupportedStrategyProps = { asChild?: boolean; - name: Exclude; + name: Exclude; children: React.ReactNode; }; @@ -93,10 +105,14 @@ export const SignInSupportedStrategy = React.forwardRef name === factor.strategy); - - const currentFirstFactor = useSelector( - snapshot.children[SignInRouterSystemId.firstFactor] as unknown as ActorRefFrom, + 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, ); @@ -106,7 +122,7 @@ export const SignInSupportedStrategy = React.forwardRef