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 @@ +--- +--- diff --git a/packages/ui/src/components/SignIn/SignInFactorOne.tsx b/packages/ui/src/components/SignIn/SignInFactorOne.tsx index 7403ec02f1c..c5700238d2a 100644 --- a/packages/ui/src/components/SignIn/SignInFactorOne.tsx +++ b/packages/ui/src/components/SignIn/SignInFactorOne.tsx @@ -1,47 +1,22 @@ +import { isPasswordCompromisedError, isPasswordPwnedError } from '@clerk/shared/error'; import { useClerk } from '@clerk/shared/react'; 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 } 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, @@ -63,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(''); @@ -82,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 { @@ -107,38 +83,124 @@ 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], + ); + + 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, @@ -146,142 +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 handleClearPasswordError = () => { + card.setError(undefined); + setPasswordErrorCode(null); + }; - const toggle = showAllStrategies ? toggleAllStrategies : toggleForgotPasswordStrategies; - const backHandler = () => { - card.setError(undefined); - setPasswordErrorCode(null); - toggle?.(); - }; + const handleResetPasswordBackLink = () => { + setFactor(prev => ({ + currentFactor: prev.prevCurrentFactor, + prevCurrentFactor: prev.currentFactor, + })); + toggleForgotPasswordStrategies(); + }; - const mode = determineAlternativeMethodsMode(showForgotPasswordStrategies, passwordErrorCode); - - return ( - { - selectFactor(f); - toggle?.(); - }} - currentFactor={currentFactor} - /> - ); - } + const factorAlreadyPrepared = currentFactor ? lastPreparedFactorKeyRef.current === factorKey(currentFactor) : false; - if (!currentFactor) { - return ; - } + const shouldAvoidPrepare = signIn.firstFactorVerification.status === 'verified' && factorAlreadyPrepared; - switch (currentFactor?.strategy) { - case 'passkey': - return ( - - ); - case 'password': - return ( - { - setPasswordErrorCode(errorCode); - toggleForgotPasswordStrategies(); - }} - /> - ); - case 'email_code': - return ( - - ); - case 'phone_code': - if (currentFactor.channel && currentFactor.channel !== 'sms') { - // Alternative phone code provider (e.g. WhatsApp) - return ( - - ); - } else { - // SMS - return ( - - ); - } + const enterpriseConnections = hasMultipleEnterpriseConnections(signIn.supportedFirstFactors) + ? signIn.supportedFirstFactors.map(ff => ({ + id: ff.enterpriseConnectionId, + name: ff.enterpriseConnectionName, + })) + : []; - 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')} - /> - ); - - case 'reset_password_email_code': - return ( - { - setFactor(prev => ({ - currentFactor: prev.prevCurrentFactor, - prevCurrentFactor: prev.currentFactor, - })); - toggleForgotPasswordStrategies(); - }} - cardSubtitle={localizationKeys('signIn.forgotPassword.subtitle_email')} - /> - ); - default: - return ; - } + 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/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/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/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/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__/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__/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__/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/__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`] = ` +
+