Skip to content

Commit b30383a

Browse files
authored
feat(js,shared,ui): Support needs_client_trust sign-in status (#7430)
1 parent cfd0cba commit b30383a

File tree

16 files changed

+249
-68
lines changed

16 files changed

+249
-68
lines changed

.changeset/goofy-lines-greet.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@clerk/ui': minor
3+
---
4+
5+
Adds `SignInClientTrust` component for discretely handling flows where client trust is required.

integration/presets/envs.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,12 @@ const withProtectService = base
204204
.setEnvVariable('private', 'CLERK_SECRET_KEY', instanceKeys.get('with-protect-service').sk)
205205
.setEnvVariable('public', 'CLERK_PUBLISHABLE_KEY', instanceKeys.get('with-protect-service').pk);
206206

207+
const withNeedsClientTrust = base
208+
.clone()
209+
.setId('withNeedsClientTrust')
210+
.setEnvVariable('private', 'CLERK_SECRET_KEY', instanceKeys.get('with-needs-client-trust').sk)
211+
.setEnvVariable('public', 'CLERK_PUBLISHABLE_KEY', instanceKeys.get('with-needs-client-trust').pk);
212+
207213
export const envs = {
208214
base,
209215
sessionsProd1,
@@ -223,6 +229,7 @@ export const envs = {
223229
withEmailLinks,
224230
withKeyless,
225231
withLegalConsent,
232+
withNeedsClientTrust,
226233
withRestrictedMode,
227234
withReverification,
228235
withSessionTasks,

integration/presets/longRunningApps.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ export const createLongRunningApps = () => {
3232
{ id: 'next.appRouter.withSessionTasks', config: next.appRouter, env: envs.withSessionTasks },
3333
{ id: 'next.appRouter.withSessionTasksResetPassword', config: next.appRouter, env: envs.withSessionTasksResetPassword },
3434
{ id: 'next.appRouter.withLegalConsent', config: next.appRouter, env: envs.withLegalConsent },
35+
{ id: 'next.appRouter.withNeedsClientTrust', config: next.appRouter, env: envs.withNeedsClientTrust },
3536

3637
/**
3738
* Quickstart apps
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { expect, test } from '@playwright/test';
2+
3+
import { appConfigs } from '../presets';
4+
import type { FakeUser } from '../testUtils';
5+
import { createTestUtils, testAgainstRunningApps } from '../testUtils';
6+
7+
testAgainstRunningApps({ withEnv: [appConfigs.envs.withNeedsClientTrust] })(
8+
'client trust flow @generic @nextjs',
9+
({ app }) => {
10+
test.describe.configure({ mode: 'serial' });
11+
12+
let fakeUser: FakeUser;
13+
14+
test.beforeAll(async () => {
15+
const u = createTestUtils({ app });
16+
fakeUser = u.services.users.createFakeUser();
17+
await u.services.users.createBapiUser(fakeUser);
18+
});
19+
20+
test.afterAll(async () => {
21+
await fakeUser.deleteIfExists();
22+
await app.teardown();
23+
});
24+
25+
test('sign in with email and password results in needs_client_trust', async ({ page, context }) => {
26+
const u = createTestUtils({ app, page, context });
27+
28+
// Sign in with a new device
29+
await u.po.signIn.goTo();
30+
await u.po.signIn.setIdentifier(fakeUser.email);
31+
await u.po.signIn.continue();
32+
await u.po.signIn.setPassword(fakeUser.password);
33+
await u.po.signIn.continue();
34+
35+
// After password is correctly entered, should navigate to client-trust route
36+
// This verifies that the sign-in status is 'needs_client_trust'
37+
await u.page.waitForURL(/\/sign-in\/client-trust/);
38+
39+
// Should contain the new device verification notice
40+
await expect(u.page.getByText("You're signing in from a new device.")).toBeVisible();
41+
42+
// User should not be signed in yet since client trust step is required
43+
await u.po.expect.toBeSignedOut();
44+
45+
await u.po.signIn.enterTestOtpCode();
46+
await u.po.expect.toBeSignedIn();
47+
48+
await u.po.userButton.toggleTrigger();
49+
await u.po.userButton.waitForPopover();
50+
await u.po.userButton.triggerSignOut();
51+
52+
await u.po.expect.toBeSignedOut();
53+
54+
await u.po.signIn.goTo();
55+
await u.po.signIn.signInWithEmailAndInstantPassword({
56+
email: fakeUser.email,
57+
password: fakeUser.password,
58+
});
59+
60+
// Sign in again with a now "known" device
61+
await u.po.expect.toBeSignedIn();
62+
});
63+
},
64+
);

packages/clerk-js/src/core/resources/SignIn.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -262,7 +262,7 @@ export class SignIn extends BaseResource implements SignInResource {
262262
emailAddressId,
263263
redirectUrl,
264264
};
265-
const isSecondFactor = this.status === 'needs_second_factor';
265+
const isSecondFactor = this.status === 'needs_second_factor' || this.status === 'needs_client_trust';
266266
const verificationKey: 'firstFactorVerification' | 'secondFactorVerification' = isSecondFactor
267267
? 'secondFactorVerification'
268268
: 'firstFactorVerification';

packages/shared/src/types/signInCommon.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ export type SignInStatus =
6161
| 'needs_identifier'
6262
| 'needs_first_factor'
6363
| 'needs_second_factor'
64+
| 'needs_client_trust'
6465
| 'needs_new_password'
6566
| 'complete';
6667

packages/testing/src/playwright/unstable/page-objects/common.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,9 @@ export const common = ({ page }: { page: EnhancedPage }) => {
3333
const prepareVerificationPromise = page.waitForResponse(
3434
response =>
3535
response.request().method() === 'POST' &&
36-
(response.url().includes('prepare_verification') || response.url().includes('prepare_first_factor')),
36+
(response.url().includes('prepare_verification') ||
37+
response.url().includes('prepare_first_factor') ||
38+
response.url().includes('prepare_second_factor')),
3739
);
3840
await prepareVerificationPromise;
3941
}
@@ -52,7 +54,9 @@ export const common = ({ page }: { page: EnhancedPage }) => {
5254
const attemptVerificationPromise = page.waitForResponse(
5355
response =>
5456
response.request().method() === 'POST' &&
55-
(response.url().includes('attempt_verification') || response.url().includes('attempt_first_factor')),
57+
(response.url().includes('attempt_verification') ||
58+
response.url().includes('attempt_first_factor') ||
59+
response.url().includes('attempt_second_factor')),
5660
);
5761
await attemptVerificationPromise;
5862
}
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { withCardStateProvider } from '@/ui/elements/contexts';
2+
import { LoadingCard } from '@/ui/elements/LoadingCard';
3+
4+
import { withRedirectToAfterSignIn, withRedirectToSignInTask } from '../../common';
5+
import { useCoreSignIn } from '../../contexts';
6+
import { SignInFactorTwoAlternativeMethods } from './SignInFactorTwoAlternativeMethods';
7+
import { SignInFactorTwoEmailCodeCard } from './SignInFactorTwoEmailCodeCard';
8+
import { SignInFactorTwoEmailLinkCard } from './SignInFactorTwoEmailLinkCard';
9+
import { SignInFactorTwoPhoneCodeCard } from './SignInFactorTwoPhoneCodeCard';
10+
import { useSecondFactorSelection } from './useSecondFactorSelection';
11+
12+
function SignInClientTrustInternal(): JSX.Element {
13+
const signIn = useCoreSignIn();
14+
const {
15+
currentFactor,
16+
factorAlreadyPrepared,
17+
handleFactorPrepare,
18+
selectFactor,
19+
showAllStrategies,
20+
toggleAllStrategies,
21+
} = useSecondFactorSelection(signIn.supportedSecondFactors);
22+
23+
if (!currentFactor) {
24+
return <LoadingCard />;
25+
}
26+
27+
if (showAllStrategies) {
28+
return (
29+
<SignInFactorTwoAlternativeMethods
30+
onBackLinkClick={toggleAllStrategies}
31+
onFactorSelected={selectFactor}
32+
/>
33+
);
34+
}
35+
36+
switch (currentFactor?.strategy) {
37+
case 'phone_code':
38+
return (
39+
<SignInFactorTwoPhoneCodeCard
40+
showClientTrustNotice
41+
factorAlreadyPrepared={factorAlreadyPrepared}
42+
onFactorPrepare={handleFactorPrepare}
43+
factor={currentFactor}
44+
onShowAlternativeMethodsClicked={toggleAllStrategies}
45+
/>
46+
);
47+
case 'email_code':
48+
return (
49+
<SignInFactorTwoEmailCodeCard
50+
showClientTrustNotice
51+
factorAlreadyPrepared={factorAlreadyPrepared}
52+
onFactorPrepare={handleFactorPrepare}
53+
factor={currentFactor}
54+
onShowAlternativeMethodsClicked={toggleAllStrategies}
55+
/>
56+
);
57+
case 'email_link':
58+
return (
59+
<SignInFactorTwoEmailLinkCard
60+
showClientTrustNotice
61+
factorAlreadyPrepared={factorAlreadyPrepared}
62+
onFactorPrepare={handleFactorPrepare}
63+
factor={currentFactor}
64+
onShowAlternativeMethodsClicked={toggleAllStrategies}
65+
/>
66+
);
67+
default:
68+
return <LoadingCard />;
69+
}
70+
}
71+
72+
export const SignInClientTrust = withRedirectToSignInTask(
73+
withRedirectToAfterSignIn(withCardStateProvider(SignInClientTrustInternal)),
74+
);

packages/ui/src/components/SignIn/SignInFactorOnePasswordCard.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,8 @@ export const SignInFactorOnePasswordCard = (props: SignInFactorOnePasswordProps)
8484
});
8585
case 'needs_second_factor':
8686
return navigate('../factor-two');
87+
case 'needs_client_trust':
88+
return navigate('../client-trust');
8789
default:
8890
return console.error(clerkInvalidFAPIResponse(res.status, supportEmail));
8991
}

packages/ui/src/components/SignIn/SignInFactorTwo.tsx

Lines changed: 9 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,3 @@
1-
import type { SignInFactor } from '@clerk/shared/types';
2-
import React from 'react';
3-
41
import { withCardStateProvider } from '@/ui/elements/contexts';
52
import { LoadingCard } from '@/ui/elements/LoadingCard';
63

@@ -12,38 +9,18 @@ import { SignInFactorTwoEmailCodeCard } from './SignInFactorTwoEmailCodeCard';
129
import { SignInFactorTwoEmailLinkCard } from './SignInFactorTwoEmailLinkCard';
1310
import { SignInFactorTwoPhoneCodeCard } from './SignInFactorTwoPhoneCodeCard';
1411
import { SignInFactorTwoTOTPCard } from './SignInFactorTwoTOTPCard';
15-
import { determineStartingSignInSecondFactor } from './utils';
16-
17-
const factorKey = (factor: SignInFactor | null | undefined) => {
18-
if (!factor) {
19-
return '';
20-
}
21-
let key = factor.strategy;
22-
if ('phoneNumberId' in factor) {
23-
key += factor.phoneNumberId;
24-
}
25-
return key;
26-
};
12+
import { useSecondFactorSelection } from './useSecondFactorSelection';
2713

2814
function SignInFactorTwoInternal(): JSX.Element {
2915
const signIn = useCoreSignIn();
30-
const availableFactors = signIn.supportedSecondFactors;
31-
32-
const lastPreparedFactorKeyRef = React.useRef('');
33-
const [currentFactor, setCurrentFactor] = React.useState<SignInFactor | null>(() =>
34-
determineStartingSignInSecondFactor(availableFactors),
35-
);
36-
const [showAllStrategies, setShowAllStrategies] = React.useState<boolean>(!currentFactor);
37-
const toggleAllStrategies = () => setShowAllStrategies(s => !s);
38-
39-
const handleFactorPrepare = () => {
40-
lastPreparedFactorKeyRef.current = factorKey(currentFactor);
41-
};
42-
43-
const selectFactor = (factor: SignInFactor) => {
44-
setCurrentFactor(factor);
45-
toggleAllStrategies();
46-
};
16+
const {
17+
currentFactor,
18+
factorAlreadyPrepared,
19+
handleFactorPrepare,
20+
selectFactor,
21+
showAllStrategies,
22+
toggleAllStrategies,
23+
} = useSecondFactorSelection(signIn.supportedSecondFactors);
4724

4825
if (!currentFactor) {
4926
return <LoadingCard />;
@@ -58,7 +35,6 @@ function SignInFactorTwoInternal(): JSX.Element {
5835
);
5936
}
6037

61-
const factorAlreadyPrepared = lastPreparedFactorKeyRef.current === factorKey(currentFactor);
6238
switch (currentFactor?.strategy) {
6339
case 'phone_code':
6440
return (

0 commit comments

Comments
 (0)