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
5 changes: 5 additions & 0 deletions .changeset/goofy-lines-greet.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@clerk/ui': minor
---

Adds `SignInClientTrust` component for discretely handling flows where client trust is required.
7 changes: 7 additions & 0 deletions integration/presets/envs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,12 @@ const withProtectService = base
.setEnvVariable('private', 'CLERK_SECRET_KEY', instanceKeys.get('with-protect-service').sk)
.setEnvVariable('public', 'CLERK_PUBLISHABLE_KEY', instanceKeys.get('with-protect-service').pk);

const withNeedsClientTrust = base
.clone()
.setId('withNeedsClientTrust')
.setEnvVariable('private', 'CLERK_SECRET_KEY', instanceKeys.get('with-needs-client-trust').sk)
.setEnvVariable('public', 'CLERK_PUBLISHABLE_KEY', instanceKeys.get('with-needs-client-trust').pk);

export const envs = {
base,
sessionsProd1,
Expand All @@ -223,6 +229,7 @@ export const envs = {
withEmailLinks,
withKeyless,
withLegalConsent,
withNeedsClientTrust,
withRestrictedMode,
withReverification,
withSessionTasks,
Expand Down
1 change: 1 addition & 0 deletions integration/presets/longRunningApps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export const createLongRunningApps = () => {
{ id: 'next.appRouter.withSessionTasks', config: next.appRouter, env: envs.withSessionTasks },
{ id: 'next.appRouter.withSessionTasksResetPassword', config: next.appRouter, env: envs.withSessionTasksResetPassword },
{ id: 'next.appRouter.withLegalConsent', config: next.appRouter, env: envs.withLegalConsent },
{ id: 'next.appRouter.withNeedsClientTrust', config: next.appRouter, env: envs.withNeedsClientTrust },

/**
* Quickstart apps
Expand Down
64 changes: 64 additions & 0 deletions integration/tests/client-trust.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { expect, test } from '@playwright/test';

import { appConfigs } from '../presets';
import type { FakeUser } from '../testUtils';
import { createTestUtils, testAgainstRunningApps } from '../testUtils';

testAgainstRunningApps({ withEnv: [appConfigs.envs.withNeedsClientTrust] })(
'client trust flow @generic @nextjs',
({ app }) => {
test.describe.configure({ mode: 'serial' });

let fakeUser: FakeUser;

test.beforeAll(async () => {
const u = createTestUtils({ app });
fakeUser = u.services.users.createFakeUser();
await u.services.users.createBapiUser(fakeUser);
});

test.afterAll(async () => {
await fakeUser.deleteIfExists();
await app.teardown();
});

test('sign in with email and password results in needs_client_trust', async ({ page, context }) => {
const u = createTestUtils({ app, page, context });

// Sign in with a new device
await u.po.signIn.goTo();
await u.po.signIn.setIdentifier(fakeUser.email);
await u.po.signIn.continue();
await u.po.signIn.setPassword(fakeUser.password);
await u.po.signIn.continue();

// After password is correctly entered, should navigate to client-trust route
// This verifies that the sign-in status is 'needs_client_trust'
await u.page.waitForURL(/\/sign-in\/client-trust/);

// Should contain the new device verification notice
await expect(u.page.getByText("You're signing in from a new device.")).toBeVisible();

// User should not be signed in yet since client trust step is required
await u.po.expect.toBeSignedOut();

await u.po.signIn.enterTestOtpCode();
await u.po.expect.toBeSignedIn();

await u.po.userButton.toggleTrigger();
await u.po.userButton.waitForPopover();
await u.po.userButton.triggerSignOut();

await u.po.expect.toBeSignedOut();

await u.po.signIn.goTo();
await u.po.signIn.signInWithEmailAndInstantPassword({
email: fakeUser.email,
password: fakeUser.password,
});

// Sign in again with a now "known" device
await u.po.expect.toBeSignedIn();
});
},
);
2 changes: 1 addition & 1 deletion packages/clerk-js/src/core/resources/SignIn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -262,7 +262,7 @@ export class SignIn extends BaseResource implements SignInResource {
emailAddressId,
redirectUrl,
};
const isSecondFactor = this.status === 'needs_second_factor';
const isSecondFactor = this.status === 'needs_second_factor' || this.status === 'needs_client_trust';
const verificationKey: 'firstFactorVerification' | 'secondFactorVerification' = isSecondFactor
? 'secondFactorVerification'
: 'firstFactorVerification';
Expand Down
1 change: 1 addition & 0 deletions packages/shared/src/types/signInCommon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ export type SignInStatus =
| 'needs_identifier'
| 'needs_first_factor'
| 'needs_second_factor'
| 'needs_client_trust'
| 'needs_new_password'
| 'complete';

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,9 @@ export const common = ({ page }: { page: EnhancedPage }) => {
const prepareVerificationPromise = page.waitForResponse(
response =>
response.request().method() === 'POST' &&
(response.url().includes('prepare_verification') || response.url().includes('prepare_first_factor')),
(response.url().includes('prepare_verification') ||
response.url().includes('prepare_first_factor') ||
response.url().includes('prepare_second_factor')),
);
await prepareVerificationPromise;
}
Expand All @@ -52,7 +54,9 @@ export const common = ({ page }: { page: EnhancedPage }) => {
const attemptVerificationPromise = page.waitForResponse(
response =>
response.request().method() === 'POST' &&
(response.url().includes('attempt_verification') || response.url().includes('attempt_first_factor')),
(response.url().includes('attempt_verification') ||
response.url().includes('attempt_first_factor') ||
response.url().includes('attempt_second_factor')),
);
await attemptVerificationPromise;
}
Expand Down
74 changes: 74 additions & 0 deletions packages/ui/src/components/SignIn/SignInClientTrust.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { withCardStateProvider } from '@/ui/elements/contexts';
import { LoadingCard } from '@/ui/elements/LoadingCard';

import { withRedirectToAfterSignIn, withRedirectToSignInTask } from '../../common';
import { useCoreSignIn } from '../../contexts';
import { SignInFactorTwoAlternativeMethods } from './SignInFactorTwoAlternativeMethods';
import { SignInFactorTwoEmailCodeCard } from './SignInFactorTwoEmailCodeCard';
import { SignInFactorTwoEmailLinkCard } from './SignInFactorTwoEmailLinkCard';
import { SignInFactorTwoPhoneCodeCard } from './SignInFactorTwoPhoneCodeCard';
import { useSecondFactorSelection } from './useSecondFactorSelection';

function SignInClientTrustInternal(): JSX.Element {
const signIn = useCoreSignIn();
const {
currentFactor,
factorAlreadyPrepared,
handleFactorPrepare,
selectFactor,
showAllStrategies,
toggleAllStrategies,
} = useSecondFactorSelection(signIn.supportedSecondFactors);

if (!currentFactor) {
return <LoadingCard />;
}

if (showAllStrategies) {
return (
<SignInFactorTwoAlternativeMethods
onBackLinkClick={toggleAllStrategies}
onFactorSelected={selectFactor}
/>
);
}

switch (currentFactor?.strategy) {
case 'phone_code':
return (
<SignInFactorTwoPhoneCodeCard
showClientTrustNotice
factorAlreadyPrepared={factorAlreadyPrepared}
onFactorPrepare={handleFactorPrepare}
factor={currentFactor}
onShowAlternativeMethodsClicked={toggleAllStrategies}
/>
);
case 'email_code':
return (
<SignInFactorTwoEmailCodeCard
showClientTrustNotice
factorAlreadyPrepared={factorAlreadyPrepared}
onFactorPrepare={handleFactorPrepare}
factor={currentFactor}
onShowAlternativeMethodsClicked={toggleAllStrategies}
/>
);
case 'email_link':
return (
<SignInFactorTwoEmailLinkCard
showClientTrustNotice
factorAlreadyPrepared={factorAlreadyPrepared}
onFactorPrepare={handleFactorPrepare}
factor={currentFactor}
onShowAlternativeMethodsClicked={toggleAllStrategies}
/>
);
default:
return <LoadingCard />;
}
}

export const SignInClientTrust = withRedirectToSignInTask(
withRedirectToAfterSignIn(withCardStateProvider(SignInClientTrustInternal)),
);
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,8 @@ export const SignInFactorOnePasswordCard = (props: SignInFactorOnePasswordProps)
});
case 'needs_second_factor':
return navigate('../factor-two');
case 'needs_client_trust':
return navigate('../client-trust');
default:
return console.error(clerkInvalidFAPIResponse(res.status, supportEmail));
}
Expand Down
42 changes: 9 additions & 33 deletions packages/ui/src/components/SignIn/SignInFactorTwo.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,3 @@
import type { SignInFactor } from '@clerk/shared/types';
import React from 'react';

import { withCardStateProvider } from '@/ui/elements/contexts';
import { LoadingCard } from '@/ui/elements/LoadingCard';

Expand All @@ -12,38 +9,18 @@ import { SignInFactorTwoEmailCodeCard } from './SignInFactorTwoEmailCodeCard';
import { SignInFactorTwoEmailLinkCard } from './SignInFactorTwoEmailLinkCard';
import { SignInFactorTwoPhoneCodeCard } from './SignInFactorTwoPhoneCodeCard';
import { SignInFactorTwoTOTPCard } from './SignInFactorTwoTOTPCard';
import { determineStartingSignInSecondFactor } from './utils';

const factorKey = (factor: SignInFactor | null | undefined) => {
if (!factor) {
return '';
}
let key = factor.strategy;
if ('phoneNumberId' in factor) {
key += factor.phoneNumberId;
}
return key;
};
import { useSecondFactorSelection } from './useSecondFactorSelection';

function SignInFactorTwoInternal(): JSX.Element {
const signIn = useCoreSignIn();
const availableFactors = signIn.supportedSecondFactors;

const lastPreparedFactorKeyRef = React.useRef('');
const [currentFactor, setCurrentFactor] = React.useState<SignInFactor | null>(() =>
determineStartingSignInSecondFactor(availableFactors),
);
const [showAllStrategies, setShowAllStrategies] = React.useState<boolean>(!currentFactor);
const toggleAllStrategies = () => setShowAllStrategies(s => !s);

const handleFactorPrepare = () => {
lastPreparedFactorKeyRef.current = factorKey(currentFactor);
};

const selectFactor = (factor: SignInFactor) => {
setCurrentFactor(factor);
toggleAllStrategies();
};
const {
currentFactor,
factorAlreadyPrepared,
handleFactorPrepare,
selectFactor,
showAllStrategies,
toggleAllStrategies,
} = useSecondFactorSelection(signIn.supportedSecondFactors);

if (!currentFactor) {
return <LoadingCard />;
Expand All @@ -58,7 +35,6 @@ function SignInFactorTwoInternal(): JSX.Element {
);
}

const factorAlreadyPrepared = lastPreparedFactorKeyRef.current === factorKey(currentFactor);
switch (currentFactor?.strategy) {
case 'phone_code':
return (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { useRouter } from '../../router';
import { isResetPasswordStrategy } from './utils';

export type SignInFactorTwoCodeCard = Pick<VerificationCodeCardProps, 'onShowAlternativeMethodsClicked'> & {
showClientTrustNotice?: boolean;
factor: EmailCodeFactor | PhoneCodeFactor | TOTPFactor;
factorAlreadyPrepared: boolean;
onFactorPrepare: () => void;
Expand Down Expand Up @@ -46,6 +47,7 @@ export const SignInFactorTwoCodeForm = (props: SignInFactorTwoCodeFormProps) =>

// Only show the new device verification notice if the user is new
// and no attributes are explicitly used for second factor.
// Retained for backwards compatibility.
const showNewDeviceVerificationNotice = useMemo(() => {
const anyAttributeUsedForSecondFactor = Object.values(env.userSettings.attributes).some(
attr => attr.used_for_second_factor,
Expand Down Expand Up @@ -115,7 +117,11 @@ export const SignInFactorTwoCodeForm = (props: SignInFactorTwoCodeFormProps) =>
cardSubtitle={
isResettingPassword(signIn) ? localizationKeys('signIn.forgotPassword.subtitle') : props.cardSubtitle
}
cardNotice={showNewDeviceVerificationNotice ? localizationKeys('signIn.newDeviceVerificationNotice') : undefined}
cardNotice={
props.showClientTrustNotice || showNewDeviceVerificationNotice
? localizationKeys('signIn.newDeviceVerificationNotice')
: undefined
}
resendButton={props.resendButton}
inputLabel={props.inputLabel}
onCodeEntryFinishedAction={action}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,13 @@ import { useCardState } from '../../elements/contexts';
import { useEmailLink } from '../../hooks/useEmailLink';

type SignInFactorTwoEmailLinkCardProps = Pick<VerificationCodeCardProps, 'onShowAlternativeMethodsClicked'> & {
showClientTrustNotice?: boolean;
factor: EmailLinkFactor;
factorAlreadyPrepared: boolean;
onFactorPrepare: () => void;
};

// Retained for backwards compatibility.
const isNewDevice = (resource: SignInResource) => resource.clientTrustState === 'new';

export const SignInFactorTwoEmailLinkCard = (props: SignInFactorTwoEmailLinkCardProps) => {
Expand Down Expand Up @@ -88,7 +90,11 @@ export const SignInFactorTwoEmailLinkCard = (props: SignInFactorTwoEmailLinkCard
<VerificationLinkCard
cardTitle={localizationKeys('signIn.emailLinkMfa.title')}
cardSubtitle={localizationKeys('signIn.emailLinkMfa.subtitle')}
cardNotice={isNewDevice(signIn) ? localizationKeys('signIn.newDeviceVerificationNotice') : undefined}
cardNotice={
props.showClientTrustNotice || isNewDevice(signIn)
? localizationKeys('signIn.newDeviceVerificationNotice')
: undefined
}
formSubtitle={localizationKeys('signIn.emailLinkMfa.formSubtitle')}
resendButton={localizationKeys('signIn.emailLinkMfa.resendButton')}
onResendCodeClicked={restartVerification}
Expand Down
4 changes: 4 additions & 0 deletions packages/ui/src/components/SignIn/SignInStart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,8 @@ function SignInStartInternal(): JSX.Element {
}
case 'needs_second_factor':
return navigate('factor-two');
case 'needs_client_trust':
return navigate('client-trust');
case 'complete':
removeClerkQueryParam('__clerk_ticket');
return clerk.setActive({
Expand Down Expand Up @@ -390,6 +392,8 @@ function SignInStartInternal(): JSX.Element {
}
case 'needs_second_factor':
return navigate('factor-two');
case 'needs_client_trust':
return navigate('client-trust');
case 'complete':
return clerk.setActive({
session: res.createdSessionId,
Expand Down
4 changes: 4 additions & 0 deletions packages/ui/src/components/SignIn/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
import { ResetPassword } from './ResetPassword';
import { ResetPasswordSuccess } from './ResetPasswordSuccess';
import { SignInAccountSwitcher } from './SignInAccountSwitcher';
import { SignInClientTrust } from './SignInClientTrust';
import { SignInFactorOne } from './SignInFactorOne';
import { SignInFactorTwo } from './SignInFactorTwo';
import { SignInSSOCallback } from './SignInSSOCallback';
Expand All @@ -55,6 +56,9 @@ function SignInRoutes(): JSX.Element {
<Route path='factor-two'>
<SignInFactorTwo />
</Route>
<Route path='client-trust'>
<SignInClientTrust />
</Route>
<Route path='reset-password'>
<ResetPassword />
</Route>
Expand Down
Loading