From e75f7ac1d4431df70be05669b674ed5c8ea5f103 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Tue, 26 May 2026 18:31:00 -0400 Subject: [PATCH 1/3] refactor(ui): Make FactorOne sub-cards prop-driven for snapshot testability PasswordCard, CodeForm, and AlternativeChannelCodeForm no longer read context hooks (useCoreSignIn, useRouter, useHandleFirstFactorResult). Parent container provides callbacks and data as props. Adds snapshot tests for both card types. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/components/SignIn/SignInFactorOne.tsx | 120 +- ...nInFactorOneAlternativeChannelCodeForm.tsx | 48 +- .../SignIn/SignInFactorOneCodeForm.tsx | 60 +- .../SignIn/SignInFactorOnePasswordCard.tsx | 88 +- .../SignInFactorOneCodeForm.snapshot.test.tsx | 75 ++ .../SignInFactorOneCodeForm.test.tsx | 28 +- ...nInFactorOnePasswordCard.snapshot.test.tsx | 62 + ...InFactorOneCodeForm.snapshot.test.tsx.snap | 751 +++++++++++ ...ctorOnePasswordCard.snapshot.test.tsx.snap | 1099 +++++++++++++++++ packages/ui/src/components/SignIn/utils.ts | 17 + 10 files changed, 2166 insertions(+), 182 deletions(-) create mode 100644 packages/ui/src/components/SignIn/__tests__/SignInFactorOneCodeForm.snapshot.test.tsx create mode 100644 packages/ui/src/components/SignIn/__tests__/SignInFactorOnePasswordCard.snapshot.test.tsx create mode 100644 packages/ui/src/components/SignIn/__tests__/__snapshots__/SignInFactorOneCodeForm.snapshot.test.tsx.snap create mode 100644 packages/ui/src/components/SignIn/__tests__/__snapshots__/SignInFactorOnePasswordCard.snapshot.test.tsx.snap diff --git a/packages/ui/src/components/SignIn/SignInFactorOne.tsx b/packages/ui/src/components/SignIn/SignInFactorOne.tsx index 7403ec02f1c..ad736134b99 100644 --- a/packages/ui/src/components/SignIn/SignInFactorOne.tsx +++ b/packages/ui/src/components/SignIn/SignInFactorOne.tsx @@ -1,3 +1,4 @@ +import { isPasswordCompromisedError, isPasswordPwnedError } from '@clerk/shared/error'; import { useClerk } from '@clerk/shared/react'; import type { SignInFactor } from '@clerk/shared/types'; import React from 'react'; @@ -23,25 +24,9 @@ import { SignInFactorOnePasskey } from './SignInFactorOnePasskey'; import type { PasswordErrorCode } from './SignInFactorOnePasswordCard'; import { SignInFactorOnePasswordCard } from './SignInFactorOnePasswordCard'; import { SignInFactorOnePhoneCodeCard } from './SignInFactorOnePhoneCodeCard'; +import { useHandleFirstFactorResult, useHandleUserLockedError } from './useHandleAttemptResult'; import { useResetPasswordFactor } from './useResetPasswordFactor'; -import { determineStartingSignInFactor, factorHasLocalStrategy } from './utils'; - -const factorKey = (factor: SignInFactor | null | undefined) => { - if (!factor) { - return ''; - } - let key = factor.strategy; - if ('emailAddressId' in factor) { - key += factor.emailAddressId; - } - if ('phoneNumberId' in factor) { - key += factor.phoneNumberId; - } - if ('channel' in factor) { - key += factor.channel; - } - return key; -}; +import { determineStartingSignInFactor, factorHasLocalStrategy, factorKey } from './utils'; function determineAlternativeMethodsMode( showForgotPasswordStrategies: boolean, @@ -107,6 +92,66 @@ function SignInFactorOneInternal(): JSX.Element { const [passwordErrorCode, setPasswordErrorCode] = React.useState(null); + const handleFirstFactorResult = useHandleFirstFactorResult(); + const handleUserLockedError = useHandleUserLockedError(); + + const goBack = React.useCallback(() => { + void router.navigate('../'); + }, [router]); + + const handleAttemptPassword = React.useCallback( + async (password: string) => { + try { + const res = await signIn.attemptFirstFactor({ strategy: 'password', password }); + await handleFirstFactorResult(res); + } catch (err: any) { + if (handleUserLockedError(err)) { + return; + } + if (isPasswordPwnedError(err)) { + card.setError({ ...err.errors[0], code: 'form_password_pwned__sign_in' }); + setPasswordErrorCode('pwned'); + setShowForgotPasswordStrategies(s => !s); + return; + } + if (isPasswordCompromisedError(err)) { + card.setError({ ...err.errors[0], code: 'form_password_compromised__sign_in' }); + setPasswordErrorCode('compromised'); + setShowForgotPasswordStrategies(s => !s); + return; + } + throw err; + } + }, + [signIn, handleFirstFactorResult, handleUserLockedError, card], + ); + + const handleAttemptCode = React.useCallback( + (code: string, resolve: () => Promise, reject: (err: unknown) => void) => { + if (!currentFactor) { + return; + } + signIn + .attemptFirstFactor({ strategy: currentFactor.strategy as any, code }) + .then(async res => { + await resolve(); + return handleFirstFactorResult(res); + }) + .catch(err => { + if (handleUserLockedError(err)) { + return; + } + return reject(err); + }); + }, + [signIn, currentFactor, handleFirstFactorResult, handleUserLockedError], + ); + + const handlePrepareFirstFactor = React.useCallback( + (factor: SignInFactor) => signIn.prepareFirstFactor(factor as any), + [signIn], + ); + React.useEffect(() => { if (__internal_setActiveInProgress) { return; @@ -184,6 +229,17 @@ function SignInFactorOneInternal(): JSX.Element { return ; } + const factorAlreadyPrepared = lastPreparedFactorKeyRef.current === factorKey(currentFactor); + const shouldAvoidPrepare = signIn.firstFactorVerification.status === 'verified' && factorAlreadyPrepared; + const codeCardProps = { + onAttemptCode: handleAttemptCode, + onPrepare: handlePrepareFirstFactor, + onGoBack: goBack, + identifier: signIn.identifier, + avatarUrl: signIn.userData.imageUrl, + shouldAvoidPrepare, + } as const; + switch (currentFactor?.strategy) { case 'passkey': return ( @@ -197,40 +253,42 @@ function SignInFactorOneInternal(): JSX.Element { { - setPasswordErrorCode(errorCode); - toggleForgotPasswordStrategies(); - }} + onAttemptPassword={handleAttemptPassword} + onGoBack={goBack} + identifier={signIn.identifier} + avatarUrl={signIn.userData.imageUrl} + hasResetPasswordFactor={!!resetPasswordFactor} /> ); case 'email_code': return ( ); case 'phone_code': if (currentFactor.channel && currentFactor.channel !== 'sms') { - // Alternative phone code provider (e.g. WhatsApp) return ( ); } else { - // SMS return ( ); } @@ -238,7 +296,7 @@ function SignInFactorOneInternal(): JSX.Element { case 'email_link': return ( ); case 'reset_password_email_code': return ( ); default: diff --git a/packages/ui/src/components/SignIn/SignInFactorOneAlternativeChannelCodeForm.tsx b/packages/ui/src/components/SignIn/SignInFactorOneAlternativeChannelCodeForm.tsx index c30134833fb..3856b47c283 100644 --- a/packages/ui/src/components/SignIn/SignInFactorOneAlternativeChannelCodeForm.tsx +++ b/packages/ui/src/components/SignIn/SignInFactorOneAlternativeChannelCodeForm.tsx @@ -1,14 +1,11 @@ -import type { PhoneCodeFactor, SignInFactor } from '@clerk/shared/types'; +import type { PhoneCodeFactor, SignInFactor, SignInResource } from '@clerk/shared/types'; import { useCardState } from '@/ui/elements/contexts'; import type { VerificationCodeCardProps } from '@/ui/elements/VerificationCodeCard'; import { VerificationCodeCard } from '@/ui/elements/VerificationCodeCard'; import { handleError } from '@/ui/utils/errorHandler'; -import { useCoreSignIn } from '../../contexts'; import { type LocalizationKey, localizationKeys } from '../../localization'; -import { useRouter } from '../../router'; -import { useHandleFirstFactorResult, useHandleUserLockedError } from './useHandleAttemptResult'; export type SignInFactorOneAlternativeChannelCodeCard = Pick< VerificationCodeCardProps, @@ -18,6 +15,11 @@ export type SignInFactorOneAlternativeChannelCodeCard = Pick< factorAlreadyPrepared: boolean; onFactorPrepare: () => void; onChangePhoneCodeChannel: (factor: SignInFactor) => void; + onAttemptCode: VerificationCodeCardProps['onCodeEntryFinishedAction']; + onPrepare: (factor: PhoneCodeFactor) => Promise; + onGoBack: () => void; + avatarUrl: string | undefined; + shouldAvoidPrepare: boolean; }; export type SignInFactorOneAlternativeChannelCodeFormProps = SignInFactorOneAlternativeChannelCodeCard & { @@ -28,46 +30,20 @@ export type SignInFactorOneAlternativeChannelCodeFormProps = SignInFactorOneAlte }; export const SignInFactorOneAlternativeChannelCodeForm = (props: SignInFactorOneAlternativeChannelCodeFormProps) => { - const signIn = useCoreSignIn(); const card = useCardState(); - const { navigate } = useRouter(); - const handleFirstFactorResult = useHandleFirstFactorResult(); - const handleUserLockedError = useHandleUserLockedError(); const channel = props.factor.channel; - const shouldAvoidPrepare = signIn.firstFactorVerification.status === 'verified' && props.factorAlreadyPrepared; - - const goBack = () => { - return navigate('../'); - }; - const prepare = () => { - if (shouldAvoidPrepare) { + if (props.shouldAvoidPrepare) { return; } - void signIn - .prepareFirstFactor({ ...props.factor, channel } as PhoneCodeFactor) + void props + .onPrepare({ ...props.factor, channel } as PhoneCodeFactor) .then(() => props.onFactorPrepare()) .catch(err => handleError(err, [], card.setError)); }; - const action: VerificationCodeCardProps['onCodeEntryFinishedAction'] = (code, resolve, reject) => { - signIn - .attemptFirstFactor({ strategy: props.factor.strategy, code }) - .then(async res => { - await resolve(); - return handleFirstFactorResult(res); - }) - .catch(err => { - if (handleUserLockedError(err)) { - return; - } - return reject(err); - }); - }; - - // This is used on clicking "Send code via SMS instead" const prepareWithSMS = () => { card.setError(undefined); props.onChangePhoneCodeChannel({ ...props.factor, channel: undefined } as SignInFactor); @@ -79,14 +55,14 @@ export const SignInFactorOneAlternativeChannelCodeForm = (props: SignInFactorOne cardSubtitle={props.cardSubtitle} inputLabel={props.inputLabel} resendButton={props.resendButton} - onCodeEntryFinishedAction={action} + onCodeEntryFinishedAction={props.onAttemptCode} onResendCodeClicked={prepare} safeIdentifier={props.factor.safeIdentifier} - profileImageUrl={signIn.userData.imageUrl} + profileImageUrl={props.avatarUrl} alternativeMethodsLabel={localizationKeys('footerActionLink__alternativePhoneCodeProvider')} onShowAlternativeMethodsClicked={prepareWithSMS} showAlternativeMethods - onIdentityPreviewEditClicked={goBack} + onIdentityPreviewEditClicked={props.onGoBack} onBackLinkClicked={props.onBackLinkClicked} /> ); diff --git a/packages/ui/src/components/SignIn/SignInFactorOneCodeForm.tsx b/packages/ui/src/components/SignIn/SignInFactorOneCodeForm.tsx index 9557a5fd39c..178d4e50ae1 100644 --- a/packages/ui/src/components/SignIn/SignInFactorOneCodeForm.tsx +++ b/packages/ui/src/components/SignIn/SignInFactorOneCodeForm.tsx @@ -1,4 +1,4 @@ -import type { EmailCodeFactor, PhoneCodeFactor, ResetPasswordCodeFactor } from '@clerk/shared/types'; +import type { EmailCodeFactor, PhoneCodeFactor, ResetPasswordCodeFactor, SignInResource } from '@clerk/shared/types'; import { useMemo } from 'react'; import { useCardState } from '@/ui/elements/contexts'; @@ -6,11 +6,8 @@ import type { VerificationCodeCardProps } from '@/ui/elements/VerificationCodeCa import { VerificationCodeCard } from '@/ui/elements/VerificationCodeCard'; import { handleError } from '@/ui/utils/errorHandler'; -import { useCoreSignIn } from '../../contexts'; import { useFetch } from '../../hooks'; import { type LocalizationKey } from '../../localization'; -import { useRouter } from '../../router'; -import { useHandleFirstFactorResult, useHandleUserLockedError } from './useHandleAttemptResult'; export type SignInFactorOneCodeCard = Pick< VerificationCodeCardProps, @@ -19,6 +16,12 @@ export type SignInFactorOneCodeCard = Pick< factor: EmailCodeFactor | PhoneCodeFactor | ResetPasswordCodeFactor; factorAlreadyPrepared: boolean; onFactorPrepare: () => void; + onAttemptCode: VerificationCodeCardProps['onCodeEntryFinishedAction']; + onPrepare: (factor: EmailCodeFactor | PhoneCodeFactor | ResetPasswordCodeFactor) => Promise; + onGoBack: () => void; + identifier: string | null; + avatarUrl: string | undefined; + shouldAvoidPrepare: boolean; }; export type SignInFactorOneCodeFormProps = SignInFactorOneCodeCard & { @@ -29,31 +32,25 @@ export type SignInFactorOneCodeFormProps = SignInFactorOneCodeCard & { }; export const SignInFactorOneCodeForm = (props: SignInFactorOneCodeFormProps) => { - const signIn = useCoreSignIn(); const card = useCardState(); - const { navigate } = useRouter(); - const handleFirstFactorResult = useHandleFirstFactorResult(); - const handleUserLockedError = useHandleUserLockedError(); - - const shouldAvoidPrepare = signIn.firstFactorVerification.status === 'verified' && props.factorAlreadyPrepared; const cacheKey = useMemo(() => { const factor = props.factor; - let factorKey = factor.strategy; + let key = factor.strategy as string; if ('emailAddressId' in factor) { - factorKey += `_${factor.emailAddressId}`; + key += `_${factor.emailAddressId}`; } if ('phoneNumberId' in factor) { - factorKey += `_${factor.phoneNumberId}`; + key += `_${factor.phoneNumberId}`; } if ('channel' in factor && factor.channel) { - factorKey += `_${factor.channel}`; + key += `_${factor.channel}`; } return { name: 'signIn.prepareFirstFactor', - factorKey, + factorKey: key, }; }, [ props.factor.strategy, @@ -62,55 +59,36 @@ export const SignInFactorOneCodeForm = (props: SignInFactorOneCodeFormProps) => 'channel' in props.factor ? props.factor.channel : undefined, ]); - const goBack = () => { - return navigate('../'); - }; - const prepare = () => { - if (shouldAvoidPrepare) { + if (props.shouldAvoidPrepare) { return; } - void signIn - .prepareFirstFactor(props.factor) + void props + .onPrepare(props.factor) .then(() => props.onFactorPrepare()) .catch(err => handleError(err, [], card.setError)); }; - useFetch(shouldAvoidPrepare ? undefined : () => signIn?.prepareFirstFactor(props.factor), cacheKey, { + useFetch(props.shouldAvoidPrepare ? undefined : () => props.onPrepare(props.factor), cacheKey, { staleTime: 100, onSuccess: () => props.onFactorPrepare(), onError: err => handleError(err, [], card.setError), }); - const action: VerificationCodeCardProps['onCodeEntryFinishedAction'] = (code, resolve, reject) => { - signIn - .attemptFirstFactor({ strategy: props.factor.strategy, code }) - .then(async res => { - await resolve(); - return handleFirstFactorResult(res); - }) - .catch(err => { - if (handleUserLockedError(err)) { - return; - } - return reject(err); - }); - }; - return ( ); diff --git a/packages/ui/src/components/SignIn/SignInFactorOnePasswordCard.tsx b/packages/ui/src/components/SignIn/SignInFactorOnePasswordCard.tsx index f09cffbc1e6..2d8a5e57821 100644 --- a/packages/ui/src/components/SignIn/SignInFactorOnePasswordCard.tsx +++ b/packages/ui/src/components/SignIn/SignInFactorOnePasswordCard.tsx @@ -1,4 +1,3 @@ -import { isPasswordCompromisedError, isPasswordPwnedError } from '@clerk/shared/error'; import React from 'react'; import { Card } from '@/ui/elements/Card'; @@ -9,24 +8,33 @@ import { IdentityPreview } from '@/ui/elements/IdentityPreview'; import { handleError } from '@/ui/utils/errorHandler'; import { useFormControl } from '@/ui/utils/useFormControl'; -import { useCoreSignIn } from '../../contexts'; import { descriptors, Flex, Flow, localizationKeys } from '../../customizables'; -import { useRouter } from '../../router/RouteContext'; import { HavingTrouble } from './HavingTrouble'; -import { useHandleFirstFactorResult, useHandleUserLockedError } from './useHandleAttemptResult'; -import { useResetPasswordFactor } from './useResetPasswordFactor'; export type PasswordErrorCode = 'compromised' | 'pwned'; type SignInFactorOnePasswordProps = { onForgotPasswordMethodClick: React.MouseEventHandler | undefined; onShowAlternativeMethodsClick: React.MouseEventHandler | undefined; - onPasswordError?: (errorCode: PasswordErrorCode) => void; + onAttemptPassword: (password: string) => Promise; + onGoBack: () => void; + identifier: string | null; + avatarUrl: string | undefined; + hasResetPasswordFactor: boolean; }; -const usePasswordControl = (props: SignInFactorOnePasswordProps) => { - const { onForgotPasswordMethodClick, onShowAlternativeMethodsClick } = props; - const resetPasswordFactor = useResetPasswordFactor(); +export const SignInFactorOnePasswordCard = (props: SignInFactorOnePasswordProps) => { + const { + onShowAlternativeMethodsClick, + onAttemptPassword, + onGoBack, + identifier, + avatarUrl, + hasResetPasswordFactor, + onForgotPasswordMethodClick, + } = props; + const passwordInputRef = React.useRef(null); + const card = useCardState(); const passwordControl = useFormControl('password', '', { type: 'password', @@ -34,12 +42,14 @@ const usePasswordControl = (props: SignInFactorOnePasswordProps) => { placeholder: localizationKeys('formFieldInputPlaceholder__password'), }); - return { + const passwordControlWithAction = { ...passwordControl, props: { ...passwordControl.props, actionLabel: - resetPasswordFactor || onShowAlternativeMethodsClick ? localizationKeys('formFieldAction__forgotPassword') : '', + hasResetPasswordFactor || onShowAlternativeMethodsClick + ? localizationKeys('formFieldAction__forgotPassword') + : '', onActionClicked: onForgotPasswordMethodClick ? onForgotPasswordMethodClick : onShowAlternativeMethodsClick @@ -47,52 +57,16 @@ const usePasswordControl = (props: SignInFactorOnePasswordProps) => { : () => null, }, }; -}; -export const SignInFactorOnePasswordCard = (props: SignInFactorOnePasswordProps) => { - const { onShowAlternativeMethodsClick, onPasswordError } = props; - const passwordInputRef = React.useRef(null); - const card = useCardState(); - const signIn = useCoreSignIn(); - const passwordControl = usePasswordControl(props); - const { navigate } = useRouter(); const [showHavingTrouble, setShowHavingTrouble] = React.useState(false); const toggleHavingTrouble = React.useCallback(() => setShowHavingTrouble(s => !s), [setShowHavingTrouble]); - const handleFirstFactorResult = useHandleFirstFactorResult(); - const handleUserLockedError = useHandleUserLockedError(); - - const goBack = () => { - void navigate('../'); - }; const handlePasswordSubmit: React.FormEventHandler = e => { e.preventDefault(); - void signIn - .attemptFirstFactor({ strategy: 'password', password: passwordControl.value }) - .then(handleFirstFactorResult) - .catch(err => { - if (handleUserLockedError(err)) { - return; - } - - if (onPasswordError) { - if (isPasswordPwnedError(err)) { - card.setError({ ...err.errors[0], code: 'form_password_pwned__sign_in' }); - onPasswordError('pwned'); - return; - } - - if (isPasswordCompromisedError(err)) { - card.setError({ ...err.errors[0], code: 'form_password_compromised__sign_in' }); - onPasswordError('compromised'); - return; - } - } - - handleError(err, [passwordControl], card.setError); - - setTimeout(() => passwordInputRef.current?.focus(), 0); - }); + void onAttemptPassword(passwordControlWithAction.value).catch(err => { + handleError(err, [passwordControlWithAction], card.setError); + setTimeout(() => passwordInputRef.current?.focus(), 0); + }); }; if (showHavingTrouble) { @@ -107,9 +81,9 @@ export const SignInFactorOnePasswordCard = (props: SignInFactorOnePasswordProps) {card.error} @@ -128,12 +102,12 @@ export const SignInFactorOnePasswordCard = (props: SignInFactorOnePasswordProps) readOnly id='identifier-field' name='identifier' - value={signIn.identifier || ''} + value={identifier || ''} style={{ display: 'none' }} /> - + diff --git a/packages/ui/src/components/SignIn/__tests__/SignInFactorOneCodeForm.snapshot.test.tsx b/packages/ui/src/components/SignIn/__tests__/SignInFactorOneCodeForm.snapshot.test.tsx new file mode 100644 index 00000000000..8a90db7f23e --- /dev/null +++ b/packages/ui/src/components/SignIn/__tests__/SignInFactorOneCodeForm.snapshot.test.tsx @@ -0,0 +1,75 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { renderForSnapshot } from '@/test/render-for-snapshot'; + +import { clearFetchCache, useFetch } from '../../../hooks'; +import { localizationKeys } from '../../../localization'; +import { SignInFactorOneCodeForm } from '../SignInFactorOneCodeForm'; + +vi.mock('../../../hooks', async () => { + const actual = await vi.importActual('../../../hooks'); + return { + ...actual, + useFetch: vi.fn(), + }; +}); + +describe('SignInFactorOneCodeForm snapshots', () => { + beforeEach(() => { + clearFetchCache(); + vi.mocked(useFetch).mockClear(); + }); + + const defaultProps = { + factor: { + strategy: 'email_code' as const, + emailAddressId: 'ea_123', + safeIdentifier: 'user@example.com', + }, + factorAlreadyPrepared: true, + onFactorPrepare: vi.fn(), + onAttemptCode: vi.fn(), + onPrepare: vi.fn().mockResolvedValue({}), + onGoBack: vi.fn(), + identifier: 'user@example.com', + avatarUrl: undefined, + shouldAvoidPrepare: true, + cardTitle: localizationKeys('signIn.emailCode.title'), + cardSubtitle: localizationKeys('signIn.emailCode.subtitle'), + inputLabel: localizationKeys('signIn.emailCode.formTitle'), + resendButton: localizationKeys('signIn.emailCode.resendButton'), + }; + + it('renders email code card', () => { + const { container } = renderForSnapshot(); + expect(container).toMatchSnapshot(); + }); + + it('renders with alternative methods', () => { + const { container } = renderForSnapshot( + , + ); + expect(container).toMatchSnapshot(); + }); + + it('renders phone code card', () => { + const { container } = renderForSnapshot( + , + ); + expect(container).toMatchSnapshot(); + }); +}); diff --git a/packages/ui/src/components/SignIn/__tests__/SignInFactorOneCodeForm.test.tsx b/packages/ui/src/components/SignIn/__tests__/SignInFactorOneCodeForm.test.tsx index c5f99f1a824..cd6e1d74ef8 100644 --- a/packages/ui/src/components/SignIn/__tests__/SignInFactorOneCodeForm.test.tsx +++ b/packages/ui/src/components/SignIn/__tests__/SignInFactorOneCodeForm.test.tsx @@ -36,6 +36,12 @@ describe('SignInFactorOneCodeForm', () => { }, factorAlreadyPrepared: false, onFactorPrepare: vi.fn(), + onAttemptCode: vi.fn(), + onPrepare: vi.fn().mockResolvedValue({}), + onGoBack: vi.fn(), + identifier: '+1234567890', + avatarUrl: undefined, + shouldAvoidPrepare: false, cardTitle: localizationKeys('signIn.phoneCode.title'), cardSubtitle: localizationKeys('signIn.phoneCode.subtitle'), inputLabel: localizationKeys('signIn.phoneCode.formTitle'), @@ -72,18 +78,13 @@ describe('SignInFactorOneCodeForm', () => { }); const phonePropsWithChannel = { + ...defaultProps, factor: { strategy: 'phone_code' as const, phoneNumberId: 'idn_123', safeIdentifier: '+1234567890', channel: 'whatsapp' as const, }, - factorAlreadyPrepared: false, - onFactorPrepare: vi.fn(), - cardTitle: localizationKeys('signIn.phoneCode.title'), - cardSubtitle: localizationKeys('signIn.phoneCode.subtitle'), - inputLabel: localizationKeys('signIn.phoneCode.formTitle'), - resendButton: localizationKeys('signIn.phoneCode.resendButton'), }; renderWithProviders(, { wrapper }); @@ -109,17 +110,8 @@ describe('SignInFactorOneCodeForm', () => { }); const props = { - factor: { - strategy: 'phone_code' as const, - phoneNumberId: 'idn_123', - safeIdentifier: '+1234567890', - }, + ...defaultProps, factorAlreadyPrepared: true, - onFactorPrepare: vi.fn(), - cardTitle: localizationKeys('signIn.phoneCode.title'), - cardSubtitle: localizationKeys('signIn.phoneCode.subtitle'), - inputLabel: localizationKeys('signIn.phoneCode.formTitle'), - resendButton: localizationKeys('signIn.phoneCode.resendButton'), }; renderWithProviders(, { wrapper }); @@ -189,13 +181,13 @@ describe('SignInFactorOneCodeForm', () => { }); const emailProps = { + ...defaultProps, factor: { strategy: 'email_code' as const, emailAddressId: 'idn_456', safeIdentifier: 'test@example.com', }, - factorAlreadyPrepared: false, - onFactorPrepare: vi.fn(), + identifier: 'test@example.com', cardTitle: localizationKeys('signIn.emailCode.title'), cardSubtitle: localizationKeys('signIn.emailCode.subtitle'), inputLabel: localizationKeys('signIn.emailCode.formTitle'), diff --git a/packages/ui/src/components/SignIn/__tests__/SignInFactorOnePasswordCard.snapshot.test.tsx b/packages/ui/src/components/SignIn/__tests__/SignInFactorOnePasswordCard.snapshot.test.tsx new file mode 100644 index 00000000000..50002b951d3 --- /dev/null +++ b/packages/ui/src/components/SignIn/__tests__/SignInFactorOnePasswordCard.snapshot.test.tsx @@ -0,0 +1,62 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { renderForSnapshot } from '@/test/render-for-snapshot'; + +import { SignInFactorOnePasswordCard } from '../SignInFactorOnePasswordCard'; + +describe('SignInFactorOnePasswordCard snapshots', () => { + const defaultProps = { + onForgotPasswordMethodClick: vi.fn() as any, + onShowAlternativeMethodsClick: vi.fn() as any, + onAttemptPassword: vi.fn().mockResolvedValue(undefined), + onGoBack: vi.fn(), + identifier: 'user@example.com', + avatarUrl: undefined, + hasResetPasswordFactor: false, + }; + + it('renders default state', () => { + const { container } = renderForSnapshot(); + expect(container).toMatchSnapshot(); + }); + + it('renders with alternative methods link', () => { + const { container } = renderForSnapshot( + , + ); + expect(container).toMatchSnapshot(); + }); + + it('renders without alternative methods (having trouble)', () => { + const { container } = renderForSnapshot( + , + ); + expect(container).toMatchSnapshot(); + }); + + it('renders with reset password factor', () => { + const { container } = renderForSnapshot( + , + ); + expect(container).toMatchSnapshot(); + }); + + it('renders with identifier', () => { + const { container } = renderForSnapshot( + , + ); + expect(container).toMatchSnapshot(); + }); +}); diff --git a/packages/ui/src/components/SignIn/__tests__/__snapshots__/SignInFactorOneCodeForm.snapshot.test.tsx.snap b/packages/ui/src/components/SignIn/__tests__/__snapshots__/SignInFactorOneCodeForm.snapshot.test.tsx.snap new file mode 100644 index 00000000000..bc74f54a463 --- /dev/null +++ b/packages/ui/src/components/SignIn/__tests__/__snapshots__/SignInFactorOneCodeForm.snapshot.test.tsx.snap @@ -0,0 +1,751 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`SignInFactorOneCodeForm snapshots > renders email code card 1`] = ` +
+
+
+
+
+

+ Check your email +

+

+ to continue to TestApp +

+
+

+ user@example.com +

+ +
+
+
+
+
+
+
+
+`; diff --git a/packages/ui/src/components/SignIn/utils.ts b/packages/ui/src/components/SignIn/utils.ts index 0f8873d0094..769654295cb 100644 --- a/packages/ui/src/components/SignIn/utils.ts +++ b/packages/ui/src/components/SignIn/utils.ts @@ -118,6 +118,23 @@ const resetPasswordStrategies: SignInStrategy[] = ['reset_password_phone_code', export const isResetPasswordStrategy = (strategy: SignInStrategy | string | null | undefined) => !!strategy && resetPasswordStrategies.includes(strategy as SignInStrategy); +export const factorKey = (factor: SignInFactor | null | undefined): string => { + if (!factor) { + return ''; + } + let key = factor.strategy as string; + if ('emailAddressId' in factor) { + key += factor.emailAddressId; + } + if ('phoneNumberId' in factor) { + key += factor.phoneNumberId; + } + if ('channel' in factor) { + key += factor.channel; + } + return key; +}; + const isEmail = (str: string) => /^\S+@\S+\.\S+$/.test(str); export function getSignUpAttributeFromIdentifier(identifier: FormControlState<'identifier'>) { if (identifier.type === 'tel') { From 6714a073cadc78f4fd864f50351aaf874c287976 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Tue, 26 May 2026 18:31:10 -0400 Subject: [PATCH 2/3] chore(repo): empty changeset for prop-driven factor one Co-Authored-By: Claude Opus 4.6 (1M context) --- .changeset/hot-tires-thank.md | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 .changeset/hot-tires-thank.md diff --git a/.changeset/hot-tires-thank.md b/.changeset/hot-tires-thank.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/hot-tires-thank.md @@ -0,0 +1,2 @@ +--- +--- From 0cd0fcfe3bc1e2db0ef894696fcef3a482c7acef Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Tue, 26 May 2026 20:11:48 -0400 Subject: [PATCH 3/3] refactor(ui): Extract SignInFactorOneView and make remaining sub-cards prop-driven Container/view split for FactorOne: SignInFactorOne becomes a pure container (context + state), SignInFactorOneView is a pure-props component rendering the correct sub-card. EmailLinkCard, Passkey, and EnterpriseConnections are now prop-driven. Adds 9 snapshot tests for the view component covering all factor strategies. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/components/SignIn/SignInFactorOne.tsx | 288 ++- .../SignIn/SignInFactorOneEmailLinkCard.tsx | 38 +- .../SignInFactorOneEnterpriseConnections.tsx | 45 +- .../SignIn/SignInFactorOnePasskey.tsx | 30 +- .../components/SignIn/SignInFactorOneView.tsx | 218 +++ .../SignInFactorOneView.snapshot.test.tsx | 142 ++ ...SignInFactorOneView.snapshot.test.tsx.snap | 1626 +++++++++++++++++ 7 files changed, 2125 insertions(+), 262 deletions(-) create mode 100644 packages/ui/src/components/SignIn/SignInFactorOneView.tsx create mode 100644 packages/ui/src/components/SignIn/__tests__/SignInFactorOneView.snapshot.test.tsx create mode 100644 packages/ui/src/components/SignIn/__tests__/__snapshots__/SignInFactorOneView.snapshot.test.tsx.snap diff --git a/packages/ui/src/components/SignIn/SignInFactorOne.tsx b/packages/ui/src/components/SignIn/SignInFactorOne.tsx index ad736134b99..c5700238d2a 100644 --- a/packages/ui/src/components/SignIn/SignInFactorOne.tsx +++ b/packages/ui/src/components/SignIn/SignInFactorOne.tsx @@ -4,26 +4,16 @@ import type { SignInFactor } from '@clerk/shared/types'; import React from 'react'; import { useCardState, withCardStateProvider } from '@/ui/elements/contexts'; -import { ErrorCard } from '@/ui/elements/ErrorCard'; -import { LoadingCard } from '@/ui/elements/LoadingCard'; import { withRedirectToAfterSignIn, withRedirectToSignInTask } from '../../common'; -import { useCoreSignIn, useEnvironment } from '../../contexts'; +import { buildVerificationRedirectUrl } from '../../common/redirects'; +import { useCoreSignIn, useEnvironment, useSignInContext } from '../../contexts'; import { useAlternativeStrategies } from '../../hooks/useAlternativeStrategies'; -import { localizationKeys } from '../../localization'; import { useRouter } from '../../router'; import type { AlternativeMethodsMode } from './AlternativeMethods'; -import { AlternativeMethods } from './AlternativeMethods'; -import { hasMultipleEnterpriseConnections } from './shared'; -import { SignInFactorOneAlternativePhoneCodeCard } from './SignInFactorOneAlternativePhoneCodeCard'; -import { SignInFactorOneEmailCodeCard } from './SignInFactorOneEmailCodeCard'; -import { SignInFactorOneEmailLinkCard } from './SignInFactorOneEmailLinkCard'; -import { SignInFactorOneEnterpriseConnections } from './SignInFactorOneEnterpriseConnections'; -import { SignInFactorOneForgotPasswordCard } from './SignInFactorOneForgotPasswordCard'; -import { SignInFactorOnePasskey } from './SignInFactorOnePasskey'; +import { hasMultipleEnterpriseConnections, useHandleAuthenticateWithPasskey } from './shared'; import type { PasswordErrorCode } from './SignInFactorOnePasswordCard'; -import { SignInFactorOnePasswordCard } from './SignInFactorOnePasswordCard'; -import { SignInFactorOnePhoneCodeCard } from './SignInFactorOnePhoneCodeCard'; +import { SignInFactorOneView } from './SignInFactorOneView'; import { useHandleFirstFactorResult, useHandleUserLockedError } from './useHandleAttemptResult'; import { useResetPasswordFactor } from './useResetPasswordFactor'; import { determineStartingSignInFactor, factorHasLocalStrategy, factorKey } from './utils'; @@ -48,12 +38,15 @@ function determineAlternativeMethodsMode( } function SignInFactorOneInternal(): JSX.Element { - const { __internal_setActiveInProgress } = useClerk(); + const clerk = useClerk(); + const { __internal_setActiveInProgress } = clerk; const signIn = useCoreSignIn(); - const { preferredSignInStrategy } = useEnvironment().displayConfig; + const env = useEnvironment(); + const { preferredSignInStrategy } = env.displayConfig; const availableFactors = signIn.supportedFirstFactors; const router = useRouter(); const card = useCardState(); + const signInContext = useSignInContext(); const { supportedFirstFactors, firstFactorVerification } = useCoreSignIn(); const lastPreparedFactorKeyRef = React.useRef(''); @@ -67,8 +60,6 @@ function SignInFactorOneInternal(): JSX.Element { !!firstFactorVerification.channel && firstFactorVerification.channel !== 'sms' ) { - // This is only applied to phone_code with channel that is not 'sms' - // because we don't want to send the channel parameter when its value is 'sms' factor.channel = firstFactorVerification.channel; } return { @@ -152,38 +143,64 @@ function SignInFactorOneInternal(): JSX.Element { [signIn], ); + const onSecondFactor = React.useCallback(() => router.navigate('../factor-two'), [router]); + const authenticateWithPasskey = useHandleAuthenticateWithPasskey(onSecondFactor); + + const handleEmailLinkVerificationComplete = React.useCallback( + async (si: import('@clerk/shared/types').SignInResource) => { + if (si.status === 'complete') { + await clerk.setActive({ + session: si.createdSessionId, + redirectUrl: signInContext.afterSignInUrl, + }); + } else if (si.status === 'needs_second_factor') { + await router.navigate('../factor-two'); + } + }, + [clerk, signInContext.afterSignInUrl, router], + ); + + const emailLinkRedirectUrl = React.useMemo( + () => + buildVerificationRedirectUrl({ + ctx: signInContext, + baseUrl: signInContext.signInUrl, + intent: 'sign-in', + }), + [signInContext], + ); + + const handleEnterpriseSSO = React.useCallback( + (enterpriseConnectionId: string) => { + void signIn.authenticateWithRedirect({ + strategy: 'enterprise_sso', + redirectUrl: signInContext.ssoCallbackUrl, + redirectUrlComplete: signInContext.afterSignInUrl || '/', + oidcPrompt: signInContext.oidcPrompt, + continueSignIn: true, + enterpriseConnectionId, + }); + }, + [signIn, signInContext], + ); + React.useEffect(() => { if (__internal_setActiveInProgress) { return; } - // Handle the case where a user lands on alternative methods screen, - // clicks a social button but then navigates back to sign in. - // SignIn status resets to 'needs_identifier' if (signIn.status === 'needs_identifier' || signIn.status === null) { void router.navigate('../'); } }, [__internal_setActiveInProgress]); - if (!currentFactor) { - return signIn.status ? ( - - ) : ( - - ); - } - const toggleAllStrategies = hasAnyStrategy ? () => setShowAllStrategies(s => !s) : undefined; - const toggleForgotPasswordStrategies = () => setShowForgotPasswordStrategies(s => !s); const handleFactorPrepare = () => { - lastPreparedFactorKeyRef.current = factorKey(currentFactor); + lastPreparedFactorKeyRef.current = factorKey(currentFactor!); }; + const selectFactor = (factor: SignInFactor) => { setFactor(prev => ({ currentFactor: factor, @@ -191,157 +208,66 @@ function SignInFactorOneInternal(): JSX.Element { })); }; - /** - * Prompt to choose between a list of enterprise connections as supported first factors - * @experimental - */ - if (hasMultipleEnterpriseConnections(signIn.supportedFirstFactors)) { - return ; - } - - if (showAllStrategies || showForgotPasswordStrategies) { - // Password errors are not recoverable by re-entering the password, so we hide the back button - const canGoBack = factorHasLocalStrategy(currentFactor) && !passwordErrorCode; - - const toggle = showAllStrategies ? toggleAllStrategies : toggleForgotPasswordStrategies; - const backHandler = () => { - card.setError(undefined); - setPasswordErrorCode(null); - toggle?.(); - }; + const handleClearPasswordError = () => { + card.setError(undefined); + setPasswordErrorCode(null); + }; - const mode = determineAlternativeMethodsMode(showForgotPasswordStrategies, passwordErrorCode); - - return ( - { - selectFactor(f); - toggle?.(); - }} - currentFactor={currentFactor} - /> - ); - } + const handleResetPasswordBackLink = () => { + setFactor(prev => ({ + currentFactor: prev.prevCurrentFactor, + prevCurrentFactor: prev.currentFactor, + })); + toggleForgotPasswordStrategies(); + }; - if (!currentFactor) { - return ; - } + const factorAlreadyPrepared = currentFactor ? lastPreparedFactorKeyRef.current === factorKey(currentFactor) : false; - const factorAlreadyPrepared = lastPreparedFactorKeyRef.current === factorKey(currentFactor); const shouldAvoidPrepare = signIn.firstFactorVerification.status === 'verified' && factorAlreadyPrepared; - const codeCardProps = { - onAttemptCode: handleAttemptCode, - onPrepare: handlePrepareFirstFactor, - onGoBack: goBack, - identifier: signIn.identifier, - avatarUrl: signIn.userData.imageUrl, - shouldAvoidPrepare, - } as const; - - switch (currentFactor?.strategy) { - case 'passkey': - return ( - - ); - case 'password': - return ( - - ); - case 'email_code': - return ( - - ); - case 'phone_code': - if (currentFactor.channel && currentFactor.channel !== 'sms') { - return ( - - ); - } else { - return ( - - ); - } - case 'email_link': - return ( - - ); - case 'reset_password_phone_code': - return ( - { - setFactor(prev => ({ - currentFactor: prev.prevCurrentFactor, - prevCurrentFactor: prev.currentFactor, - })); - toggleForgotPasswordStrategies(); - }} - cardSubtitle={localizationKeys('signIn.forgotPassword.subtitle_phone')} - {...codeCardProps} - /> - ); - - case 'reset_password_email_code': - return ( - { - setFactor(prev => ({ - currentFactor: prev.prevCurrentFactor, - prevCurrentFactor: prev.currentFactor, - })); - toggleForgotPasswordStrategies(); - }} - cardSubtitle={localizationKeys('signIn.forgotPassword.subtitle_email')} - {...codeCardProps} - /> - ); - default: - return ; - } + const enterpriseConnections = hasMultipleEnterpriseConnections(signIn.supportedFirstFactors) + ? signIn.supportedFirstFactors.map(ff => ({ + id: ff.enterpriseConnectionId, + name: ff.enterpriseConnectionName, + })) + : []; + + return ( + { + await authenticateWithPasskey(); + }} + onEnterpriseSSO={handleEnterpriseSSO} + signIn={signIn} + onEmailLinkVerificationComplete={handleEmailLinkVerificationComplete} + onUserLockedError={handleUserLockedError} + emailLinkRedirectUrl={emailLinkRedirectUrl} + alternativeMethodsMode={determineAlternativeMethodsMode(showForgotPasswordStrategies, passwordErrorCode)} + onResetPasswordBackLink={handleResetPasswordBackLink} + /> + ); } export const SignInFactorOne = withRedirectToSignInTask( diff --git a/packages/ui/src/components/SignIn/SignInFactorOneEmailLinkCard.tsx b/packages/ui/src/components/SignIn/SignInFactorOneEmailLinkCard.tsx index 2b51245c7c4..efe180b24f9 100644 --- a/packages/ui/src/components/SignIn/SignInFactorOneEmailLinkCard.tsx +++ b/packages/ui/src/components/SignIn/SignInFactorOneEmailLinkCard.tsx @@ -1,4 +1,3 @@ -import { useClerk } from '@clerk/shared/react'; import type { EmailLinkFactor, SignInResource } from '@clerk/shared/types'; import React from 'react'; @@ -7,32 +6,26 @@ import { VerificationLinkCard } from '@/ui/elements/VerificationLinkCard'; import { handleError } from '@/ui/utils/errorHandler'; import { EmailLinkStatusCard } from '../../common'; -import { buildVerificationRedirectUrl } from '../../common/redirects'; -import { useCoreSignIn, useSignInContext } from '../../contexts'; import { Flow, localizationKeys, useLocalizations } from '../../customizables'; import { useCardState } from '../../elements/contexts'; import { useEmailLink } from '../../hooks/useEmailLink'; -import { useRouter } from '../../router/RouteContext'; -import { useHandleUserLockedError } from './useHandleAttemptResult'; type SignInFactorOneEmailLinkCardProps = Pick & { factor: EmailLinkFactor; factorAlreadyPrepared: boolean; onFactorPrepare: () => void; + signIn: SignInResource; + onVerificationComplete: (si: SignInResource) => Promise; + onUserLockedError: (err: unknown) => boolean; + avatarUrl: string | undefined; + redirectUrl: string; }; export const SignInFactorOneEmailLinkCard = (props: SignInFactorOneEmailLinkCardProps) => { const { t } = useLocalizations(); const card = useCardState(); - const signIn = useCoreSignIn(); - const signInContext = useSignInContext(); - const { signInUrl } = signInContext; - const { navigate } = useRouter(); - const { afterSignInUrl } = useSignInContext(); - const { setActive } = useClerk(); - const { startEmailLinkFlow, cancelEmailLinkFlow } = useEmailLink(signIn); + const { startEmailLinkFlow, cancelEmailLinkFlow } = useEmailLink(props.signIn); const [showVerifyModal, setShowVerifyModal] = React.useState(false); - const handleUserLockedError = useHandleUserLockedError(); React.useEffect(() => { void startEmailLinkVerification(); @@ -46,11 +39,11 @@ export const SignInFactorOneEmailLinkCard = (props: SignInFactorOneEmailLinkCard const startEmailLinkVerification = () => { startEmailLinkFlow({ emailAddressId: props.factor.emailAddressId, - redirectUrl: buildVerificationRedirectUrl({ ctx: signInContext, baseUrl: signInUrl, intent: 'sign-in' }), + redirectUrl: props.redirectUrl, }) .then(res => handleVerificationResult(res)) .catch(err => { - if (handleUserLockedError(err)) { + if (props.onUserLockedError(err)) { return; } handleError(err, [], card.setError); @@ -64,18 +57,7 @@ export const SignInFactorOneEmailLinkCard = (props: SignInFactorOneEmailLinkCard } else if (ver.verifiedFromTheSameClient()) { setShowVerifyModal(true); } else { - await completeSignInFlow(si); - } - }; - - const completeSignInFlow = async (si: SignInResource) => { - if (si.status === 'complete') { - return setActive({ - session: si.createdSessionId, - redirectUrl: afterSignInUrl, - }); - } else if (si.status === 'needs_second_factor') { - return navigate('../factor-two'); + await props.onVerificationComplete(si); } }; @@ -99,7 +81,7 @@ export const SignInFactorOneEmailLinkCard = (props: SignInFactorOneEmailLinkCard resendButton={localizationKeys('signIn.emailLink.resendButton')} onResendCodeClicked={restartVerification} safeIdentifier={props.factor.safeIdentifier} - profileImageUrl={signIn.userData.imageUrl} + profileImageUrl={props.avatarUrl} onShowAlternativeMethodsClicked={props.onShowAlternativeMethodsClicked} /> diff --git a/packages/ui/src/components/SignIn/SignInFactorOneEnterpriseConnections.tsx b/packages/ui/src/components/SignIn/SignInFactorOneEnterpriseConnections.tsx index 8bf166d4a62..fc51a6486dd 100644 --- a/packages/ui/src/components/SignIn/SignInFactorOneEnterpriseConnections.tsx +++ b/packages/ui/src/components/SignIn/SignInFactorOneEnterpriseConnections.tsx @@ -1,4 +1,3 @@ -import { useClerk } from '@clerk/shared/react/index'; import type { ComponentType } from 'react'; import { withRedirect } from '@/ui/common'; @@ -10,45 +9,21 @@ import type { AvailableComponentProps } from '@/ui/types'; import { hasMultipleEnterpriseConnections } from './shared'; -/** - * @experimental - */ -const SignInFactorOneEnterpriseConnectionsInternal = () => { - const ctx = useSignInContext(); - const clerk = useClerk(); - const signIn = clerk.client.signIn; +type EnterpriseConnection = { id: string; name: string }; - if (!hasMultipleEnterpriseConnections(signIn.supportedFirstFactors)) { - // This should not happen due to the HOC guard, but provides type safety - return null; - } - - const enterpriseConnections = signIn.supportedFirstFactors.map(ff => ({ - id: ff.enterpriseConnectionId, - name: ff.enterpriseConnectionName, - })); - - const handleEnterpriseSSO = (enterpriseConnectionId: string) => { - const redirectUrl = ctx.ssoCallbackUrl; - const redirectUrlComplete = ctx.afterSignInUrl || '/'; - - return signIn.authenticateWithRedirect({ - strategy: 'enterprise_sso', - redirectUrl, - redirectUrlComplete, - oidcPrompt: ctx.oidcPrompt, - continueSignIn: true, - enterpriseConnectionId, - }); - }; +type SignInFactorOneEnterpriseConnectionsInternalProps = { + enterpriseConnections: EnterpriseConnection[]; + onEnterpriseSSO: (enterpriseConnectionId: string) => Promise | void; +}; +export const SignInFactorOneEnterpriseConnectionsCard = (props: SignInFactorOneEnterpriseConnectionsInternalProps) => { return ( Promise} + enterpriseConnections={props.enterpriseConnections} /> ); @@ -75,6 +50,8 @@ const withEnterpriseConnectionsGuard =

(Compo return HOC; }; +export { type EnterpriseConnection }; + export const SignInFactorOneEnterpriseConnections = withCardStateProvider( - withEnterpriseConnectionsGuard(SignInFactorOneEnterpriseConnectionsInternal), + withEnterpriseConnectionsGuard(SignInFactorOneEnterpriseConnectionsCard as any), ); diff --git a/packages/ui/src/components/SignIn/SignInFactorOnePasskey.tsx b/packages/ui/src/components/SignIn/SignInFactorOnePasskey.tsx index da60fb03388..2e22ccd008d 100644 --- a/packages/ui/src/components/SignIn/SignInFactorOnePasskey.tsx +++ b/packages/ui/src/components/SignIn/SignInFactorOnePasskey.tsx @@ -1,4 +1,3 @@ -import type { ResetPasswordCodeFactor } from '@clerk/shared/types'; import React from 'react'; import { Card } from '@/ui/elements/Card'; @@ -7,30 +6,23 @@ import { Form } from '@/ui/elements/Form'; import { Header } from '@/ui/elements/Header'; import { IdentityPreview } from '@/ui/elements/IdentityPreview'; -import { useCoreSignIn } from '../../contexts'; import { descriptors, Flex, Flow, localizationKeys } from '../../customizables'; -import { useRouter } from '../../router/RouteContext'; import { HavingTrouble } from './HavingTrouble'; -import { useHandleAuthenticateWithPasskey } from './shared'; -type SignInFactorOnePasswordProps = { +type SignInFactorOnePasskeyProps = { onShowAlternativeMethodsClick: React.MouseEventHandler | undefined; - onFactorPrepare: (f: ResetPasswordCodeFactor) => void; + onFactorPrepare: () => void; + authenticateWithPasskey: () => Promise; + onGoBack: () => void; + identifier: string | null; + avatarUrl: string | undefined; }; -export const SignInFactorOnePasskey = (props: SignInFactorOnePasswordProps) => { - const { onShowAlternativeMethodsClick } = props; +export const SignInFactorOnePasskey = (props: SignInFactorOnePasskeyProps) => { + const { onShowAlternativeMethodsClick, authenticateWithPasskey, onGoBack, identifier, avatarUrl } = props; const card = useCardState(); - const signIn = useCoreSignIn(); - const { navigate } = useRouter(); const [showHavingTrouble, setShowHavingTrouble] = React.useState(false); const toggleHavingTrouble = React.useCallback(() => setShowHavingTrouble(s => !s), [setShowHavingTrouble]); - const onSecondFactor = () => navigate('../factor-two'); - const authenticateWithPasskey = useHandleAuthenticateWithPasskey(onSecondFactor); - - const goBack = () => { - return navigate('../'); - }; const handleSubmit: React.FormEventHandler = e => { e.preventDefault(); @@ -49,9 +41,9 @@ export const SignInFactorOnePasskey = (props: SignInFactorOnePasswordProps) => { {card.error} diff --git a/packages/ui/src/components/SignIn/SignInFactorOneView.tsx b/packages/ui/src/components/SignIn/SignInFactorOneView.tsx new file mode 100644 index 00000000000..d05b87e581f --- /dev/null +++ b/packages/ui/src/components/SignIn/SignInFactorOneView.tsx @@ -0,0 +1,218 @@ +import type { EmailLinkFactor, SignInFactor, SignInResource } from '@clerk/shared/types'; + +import { ErrorCard } from '@/ui/elements/ErrorCard'; +import { LoadingCard } from '@/ui/elements/LoadingCard'; +import type { VerificationCodeCardProps } from '@/ui/elements/VerificationCodeCard'; + +import { localizationKeys } from '../../localization'; +import type { AlternativeMethodsMode } from './AlternativeMethods'; +import { AlternativeMethods } from './AlternativeMethods'; +import type { EnterpriseConnection } from './SignInFactorOneEnterpriseConnections'; +import { SignInFactorOneAlternativePhoneCodeCard } from './SignInFactorOneAlternativePhoneCodeCard'; +import { SignInFactorOneEmailCodeCard } from './SignInFactorOneEmailCodeCard'; +import { SignInFactorOneEmailLinkCard } from './SignInFactorOneEmailLinkCard'; +import { SignInFactorOneEnterpriseConnectionsCard } from './SignInFactorOneEnterpriseConnections'; +import { SignInFactorOneForgotPasswordCard } from './SignInFactorOneForgotPasswordCard'; +import { SignInFactorOnePasskey } from './SignInFactorOnePasskey'; +import { SignInFactorOnePasswordCard } from './SignInFactorOnePasswordCard'; +import { SignInFactorOnePhoneCodeCard } from './SignInFactorOnePhoneCodeCard'; +import { factorHasLocalStrategy } from './utils'; + +export type SignInFactorOneViewProps = { + currentFactor: SignInFactor | undefined | null; + signInStatus: string | null; + + showAllStrategies: boolean; + showForgotPasswordStrategies: boolean; + passwordErrorCode: 'compromised' | 'pwned' | null; + + hasAnyAlternativeStrategy: boolean; + hasResetPasswordFactor: boolean; + hasMultipleEnterpriseConnections: boolean; + factorAlreadyPrepared: boolean; + shouldAvoidPrepare: boolean; + + identifier: string | null; + avatarUrl: string | undefined; + + enterpriseConnections: EnterpriseConnection[]; + + onGoBack: () => void; + onToggleAllStrategies: (() => void) | undefined; + onToggleForgotPasswordStrategies: () => void; + onSelectFactor: (factor: SignInFactor) => void; + onFactorPrepare: () => void; + onClearPasswordError: () => void; + + onAttemptPassword: (password: string) => Promise; + onAttemptCode: VerificationCodeCardProps['onCodeEntryFinishedAction']; + onPrepareFirstFactor: (factor: SignInFactor) => Promise; + + authenticateWithPasskey: () => Promise; + + onEnterpriseSSO: (enterpriseConnectionId: string) => void; + + signIn: SignInResource; + onEmailLinkVerificationComplete: (si: SignInResource) => Promise; + onUserLockedError: (err: unknown) => boolean; + emailLinkRedirectUrl: string; + + alternativeMethodsMode: AlternativeMethodsMode; + + onResetPasswordBackLink: () => void; +}; + +export function SignInFactorOneView(props: SignInFactorOneViewProps): JSX.Element { + if (!props.currentFactor) { + return props.signInStatus ? ( + + ) : ( + + ); + } + + if (props.hasMultipleEnterpriseConnections) { + return ( + + ); + } + + if (props.showAllStrategies || props.showForgotPasswordStrategies) { + const canGoBack = factorHasLocalStrategy(props.currentFactor) && !props.passwordErrorCode; + + const toggle = props.showAllStrategies ? props.onToggleAllStrategies : props.onToggleForgotPasswordStrategies; + + const backHandler = () => { + props.onClearPasswordError(); + toggle?.(); + }; + + return ( + { + props.onSelectFactor(f); + toggle?.(); + }} + currentFactor={props.currentFactor} + /> + ); + } + + const codeCardProps = { + onAttemptCode: props.onAttemptCode, + onPrepare: props.onPrepareFirstFactor, + onGoBack: props.onGoBack, + identifier: props.identifier, + avatarUrl: props.avatarUrl, + shouldAvoidPrepare: props.shouldAvoidPrepare, + } as const; + + switch (props.currentFactor.strategy) { + case 'passkey': + return ( + + ); + case 'password': + return ( + + ); + case 'email_code': + return ( + + ); + case 'phone_code': + if (props.currentFactor.channel && props.currentFactor.channel !== 'sms') { + return ( + + ); + } else { + return ( + + ); + } + case 'email_link': + return ( + + ); + case 'reset_password_phone_code': + return ( + + ); + case 'reset_password_email_code': + return ( + + ); + default: + return ; + } +} diff --git a/packages/ui/src/components/SignIn/__tests__/SignInFactorOneView.snapshot.test.tsx b/packages/ui/src/components/SignIn/__tests__/SignInFactorOneView.snapshot.test.tsx new file mode 100644 index 00000000000..4b3dadfc78d --- /dev/null +++ b/packages/ui/src/components/SignIn/__tests__/SignInFactorOneView.snapshot.test.tsx @@ -0,0 +1,142 @@ +import type { SignInResource } from '@clerk/shared/types'; +import { describe, expect, it, vi } from 'vitest'; + +import { renderForSnapshot } from '@/test/render-for-snapshot'; + +import { SignInFactorOneView, type SignInFactorOneViewProps } from '../SignInFactorOneView'; + +const noop = vi.fn(); +const asyncNoop = vi.fn().mockResolvedValue(undefined); + +const defaultProps: SignInFactorOneViewProps = { + currentFactor: { strategy: 'password' } as any, + signInStatus: 'needs_first_factor', + + showAllStrategies: false, + showForgotPasswordStrategies: false, + passwordErrorCode: null, + + hasAnyAlternativeStrategy: true, + hasResetPasswordFactor: false, + hasMultipleEnterpriseConnections: false, + factorAlreadyPrepared: false, + shouldAvoidPrepare: false, + + identifier: 'user@example.com', + avatarUrl: undefined, + + enterpriseConnections: [], + + onGoBack: noop, + onToggleAllStrategies: noop, + onToggleForgotPasswordStrategies: noop, + onSelectFactor: noop, + onFactorPrepare: noop, + onClearPasswordError: noop, + + onAttemptPassword: asyncNoop, + onAttemptCode: noop as any, + onPrepareFirstFactor: asyncNoop as any, + + authenticateWithPasskey: asyncNoop, + + onEnterpriseSSO: noop, + + signIn: {} as SignInResource, + onEmailLinkVerificationComplete: asyncNoop, + onUserLockedError: vi.fn().mockReturnValue(false), + emailLinkRedirectUrl: 'https://example.com/verify', + + alternativeMethodsMode: 'default', + + onResetPasswordBackLink: noop, +}; + +describe('SignInFactorOneView snapshots', () => { + it('renders password card', () => { + const { container } = renderForSnapshot(); + expect(container).toMatchSnapshot(); + }); + + it('renders passkey card', () => { + const { container } = renderForSnapshot( + , + ); + expect(container).toMatchSnapshot(); + }); + + it('renders email code card', () => { + const { container } = renderForSnapshot( + , + ); + expect(container).toMatchSnapshot(); + }); + + it('renders phone code card', () => { + const { container } = renderForSnapshot( + , + ); + expect(container).toMatchSnapshot(); + }); + + it('renders error state when no available methods', () => { + const { container } = renderForSnapshot( + , + ); + expect(container).toMatchSnapshot(); + }); + + it('renders loading state when signIn status is null', () => { + const { container } = renderForSnapshot( + , + ); + expect(container).toMatchSnapshot(); + }); + + it('renders reset password phone code card', () => { + const { container } = renderForSnapshot( + , + ); + expect(container).toMatchSnapshot(); + }); + + it('renders reset password email code card', () => { + const { container } = renderForSnapshot( + , + ); + expect(container).toMatchSnapshot(); + }); + + it('renders default/loading for unknown strategy', () => { + const { container } = renderForSnapshot( + , + ); + expect(container).toMatchSnapshot(); + }); +}); diff --git a/packages/ui/src/components/SignIn/__tests__/__snapshots__/SignInFactorOneView.snapshot.test.tsx.snap b/packages/ui/src/components/SignIn/__tests__/__snapshots__/SignInFactorOneView.snapshot.test.tsx.snap new file mode 100644 index 00000000000..2444b5a26d3 --- /dev/null +++ b/packages/ui/src/components/SignIn/__tests__/__snapshots__/SignInFactorOneView.snapshot.test.tsx.snap @@ -0,0 +1,1626 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`SignInFactorOneView snapshots > renders default/loading for unknown strategy 1`] = ` +

+
+
+
+ +
+
+
+ +
+
+`; + +exports[`SignInFactorOneView snapshots > renders email code card 1`] = ` +
+