Skip to content

Commit f649be8

Browse files
authored
feat(elements): Add SecondFactor states (clerk#2500)
* feat(elements): Add SecondFactor states * chore(repo): Add changeset
1 parent 91b915a commit f649be8

4 files changed

Lines changed: 163 additions & 21 deletions

File tree

.changeset/wild-cycles-jog.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
---
2+
---

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

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,34 @@ export default function SignInPage() {
185185
</Submit>
186186
</SignInStrategy>
187187

188+
<SignInStrategy name='email_code'>
189+
<Field
190+
name='code'
191+
className='flex flex-col gap-4'
192+
>
193+
<div className='flex gap-4 justify-between items-center'>
194+
<Label>Email Code</Label>
195+
<Input
196+
type='code'
197+
className='bg-tertiary rounded-sm px-2 py-1 border border-foreground data-[invalid]:border-red-500'
198+
/>
199+
</div>
200+
201+
<Errors
202+
render={({ code, message }) => (
203+
<CustomError
204+
code={code}
205+
message={message}
206+
/>
207+
)}
208+
/>
209+
</Field>
210+
211+
<Submit className='px-4 py-2 b-1 bg-blue-950 bg-opacity-20 hover:bg-opacity-10 active:bg-opacity-5 rounded-md dark:bg-opacity-100 dark:hover:bg-opacity-80 dark:active:bg-opacity-50 transition'>
212+
Sign In
213+
</Submit>
214+
</SignInStrategy>
215+
188216
<SignInStrategy name='phone_code'>
189217
<Field
190218
name='code'

packages/elements/src/internals/machines/sign-in.actors.ts

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import type {
22
AttemptFirstFactorParams,
3-
AttemptSecondFactorParams,
43
AuthenticateWithRedirectParams,
54
EnvironmentResource,
65
HandleOAuthCallbackParams,
@@ -9,6 +8,7 @@ import type {
98
PrepareSecondFactorParams,
109
SignInFirstFactor,
1110
SignInResource,
11+
SignInSecondFactor,
1212
} from '@clerk/types';
1313
import { fromPromise } from 'xstate';
1414

@@ -108,19 +108,32 @@ export const attemptFirstFactor = fromPromise<SignInResource, AttemptFirstFactor
108108

109109
// ================= prepareSecondFactor ================= //
110110

111-
export type PrepareSecondFactorInput = WithClient<WithParams<PrepareSecondFactorParams>>;
111+
export type PrepareSecondFactorInput = WithClient<WithParams<PrepareSecondFactorParams | null>>;
112112

113-
export const prepareSecondFactor = fromPromise<SignInResource, PrepareSecondFactorInput>(({ input }) =>
114-
input.client.signIn.prepareSecondFactor(input.params),
115-
);
113+
export const prepareSecondFactor = fromPromise<SignInResource, PrepareSecondFactorInput>(({ input }) => {
114+
const currentFactor = input.params;
115+
assertIsDefined(currentFactor);
116+
117+
return input.client.signIn.prepareSecondFactor({
118+
strategy: currentFactor.strategy,
119+
phoneNumberId: currentFactor.phoneNumberId,
120+
});
121+
});
116122

117123
// ================= attemptSecondFactor ================= //
118124

119-
export type AttemptSecondFactorInput = WithClient<WithParams<AttemptSecondFactorParams>>;
125+
export type AttemptSecondFactorInput = WithClient<
126+
WithParams<{ fields: SignInMachineContext['fields']; currentFactor: SignInSecondFactor | null }>
127+
>;
120128

121-
export const attemptSecondFactor = fromPromise<SignInResource, AttemptSecondFactorInput>(({ input }) =>
122-
input.client.signIn.attemptSecondFactor(input.params),
123-
);
129+
export const attemptSecondFactor = fromPromise<SignInResource, AttemptSecondFactorInput>(({ input }) => {
130+
assertIsDefined(input.params.currentFactor);
131+
132+
return input.client.signIn.attemptSecondFactor({
133+
strategy: input.params.currentFactor.strategy,
134+
code: input.params.fields.get('code').value,
135+
});
136+
});
124137

125138
// ================= handleSSOCallback ================= //
126139

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

Lines changed: 111 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,26 +4,30 @@ import type {
44
EnvironmentResource,
55
OAuthStrategy,
66
PrepareFirstFactorParams,
7+
PrepareSecondFactorParams,
78
SignInFactor,
89
SignInFirstFactor,
910
SignInResource,
11+
SignInSecondFactor,
1012
Web3Strategy,
1113
} from '@clerk/types';
1214
import type { ActorRefFrom, ErrorActorEvent, MachineContext } from 'xstate';
13-
import { and, assertEvent, assign, log, not, or, sendTo, setup } from 'xstate';
15+
import { and, assertEvent, assign, not, or, sendTo, setup } from 'xstate';
1416

1517
import type { ClerkRouter } from '../router';
1618
import type { FormMachine } from './form.machine';
1719
import { waitForClerk } from './shared.actors';
1820
import {
1921
attemptFirstFactor,
22+
attemptSecondFactor,
2023
authenticateWithRedirect,
2124
createSignIn,
2225
handleSSOCallback,
2326
prepareFirstFactor,
27+
prepareSecondFactor,
2428
} from './sign-in.actors';
2529
import type { LoadedClerkWithEnv } from './sign-in.types';
26-
import { determineStartingSignInFactor } from './sign-in.utils';
30+
import { determineStartingSignInFactor, determineStartingSignInSecondFactor } from './sign-in.utils';
2731
import { assertActorEventError } from './utils/assert';
2832

2933
export interface SignInMachineContext extends MachineContext {
@@ -72,8 +76,12 @@ export const SignInMachine = setup({
7276
createSignIn,
7377

7478
// First Factor
75-
attemptFirstFactor,
7679
prepareFirstFactor,
80+
attemptFirstFactor,
81+
82+
// Second Factor
83+
prepareSecondFactor,
84+
attemptSecondFactor,
7785

7886
// SSO
7987
handleSSOCallback,
@@ -105,6 +113,7 @@ export const SignInMachine = setup({
105113
isServer: ({ context }) => context.mode === 'server',
106114
isBrowser: ({ context }) => context.mode === 'browser',
107115
isCurrentFactorPassword: ({ context }) => context.currentFactor?.strategy === 'password',
116+
isCurrentFactorTOTP: ({ context }) => context.currentFactor?.strategy === 'totp',
108117
isClerkLoaded: ({ context }) => context.clerk.loaded,
109118
isClerkEnvironmentLoaded: ({ context }) => Boolean(context.clerk.__unstable__environment),
110119
isSignInComplete: ({ context }) => context?.resource?.status === 'complete',
@@ -292,12 +301,6 @@ export const SignInMachine = setup({
292301
states: {
293302
DeterminingState: {
294303
always: [
295-
{
296-
description: 'If the current factor is not set, determine the starting factor details',
297-
guard: not('hasCurrentFactor'),
298-
target: 'DetermineStartingFactor',
299-
reenter: true,
300-
},
301304
{
302305
description: 'If the current factor is not password, prepare the factor',
303306
guard: not('isCurrentFactorPassword'),
@@ -387,7 +390,7 @@ export const SignInMachine = setup({
387390
},
388391
{
389392
guard: 'needsSecondFactor',
390-
target: '#SignIn.FirstFactor',
393+
target: '#SignIn.SecondFactor',
391394
},
392395
{
393396
target: '#SignIn.DeterminingState',
@@ -403,8 +406,104 @@ export const SignInMachine = setup({
403406
},
404407
},
405408
SecondFactor: {
406-
type: 'final',
407-
actions: [log('SecondFactor'), 'debug'],
409+
initial: 'DeterminingState',
410+
entry: assign({
411+
currentFactor: ({ context }) =>
412+
determineStartingSignInSecondFactor(context.clerk.client.signIn.supportedSecondFactors),
413+
}),
414+
states: {
415+
DeterminingState: {
416+
always: [
417+
{
418+
description: 'If the current factor is not TOTP, prepare the factor',
419+
guard: not('isCurrentFactorTOTP'),
420+
target: 'Preparing',
421+
reenter: true,
422+
},
423+
{
424+
description: 'Else, skip to awaiting input',
425+
target: 'AwaitingInput',
426+
reenter: true,
427+
},
428+
],
429+
},
430+
Preparing: {
431+
invoke: {
432+
id: 'prepareSecondFactor',
433+
src: 'prepareSecondFactor',
434+
input: ({ context }) => {
435+
return {
436+
client: context.clerk.client,
437+
params: !context.currentFactor ? null : (context.currentFactor as PrepareSecondFactorParams),
438+
};
439+
},
440+
onDone: {
441+
target: 'AwaitingInput',
442+
actions: [
443+
assign({
444+
resource: ({ event }) => event.output,
445+
}),
446+
],
447+
},
448+
onError: {
449+
actions: 'setFormErrors',
450+
target: 'Failure',
451+
},
452+
},
453+
},
454+
AwaitingInput: {
455+
description: 'Waiting for user input',
456+
on: {
457+
SUBMIT: {
458+
target: 'Attempting',
459+
reenter: true,
460+
},
461+
},
462+
},
463+
Attempting: {
464+
invoke: {
465+
id: 'attemptSecondFactor',
466+
src: 'attemptSecondFactor',
467+
input: ({ context }) => ({
468+
client: context.clerk.client,
469+
params: {
470+
currentFactor: context.currentFactor as SignInSecondFactor,
471+
fields: context.formRef.getSnapshot().context.fields,
472+
},
473+
}),
474+
onDone: {
475+
actions: [
476+
assign({
477+
resource: ({ event }) => event.output,
478+
}),
479+
],
480+
target: 'Success',
481+
},
482+
onError: {
483+
actions: 'setFormErrors',
484+
target: 'AwaitingInput',
485+
},
486+
},
487+
},
488+
Success: {
489+
type: 'final',
490+
always: [
491+
{
492+
guard: 'isSignInComplete',
493+
target: '#SignIn.Complete',
494+
},
495+
{
496+
target: '#SignIn.DeterminingState',
497+
reenter: true,
498+
},
499+
],
500+
},
501+
Failure: {
502+
type: 'final',
503+
target: '#SignIn.DeterminingState',
504+
reenter: true,
505+
},
506+
},
408507
},
409508
SSOCallbackRunning: {
410509
invoke: {

0 commit comments

Comments
 (0)