diff --git a/.changeset/calm-zoos-sort.md b/.changeset/calm-zoos-sort.md new file mode 100644 index 00000000000..1d7416adb4e --- /dev/null +++ b/.changeset/calm-zoos-sort.md @@ -0,0 +1,5 @@ +--- +"@clerk/localizations": minor +--- + +Add localization keys for `<__experimental_UserVerification />` (experimental feature). diff --git a/.changeset/fifty-ravens-attack.md b/.changeset/fifty-ravens-attack.md new file mode 100644 index 00000000000..22a70291c21 --- /dev/null +++ b/.changeset/fifty-ravens-attack.md @@ -0,0 +1,7 @@ +--- +"@clerk/chrome-extension": minor +"@clerk/nextjs": minor +"@clerk/clerk-react": minor +--- + +Add `<__experimental_UserVerification />` component. This is an experimental feature and breaking changes can occur until it's marked as stable. diff --git a/.changeset/healthy-colts-look.md b/.changeset/healthy-colts-look.md new file mode 100644 index 00000000000..53404facc9f --- /dev/null +++ b/.changeset/healthy-colts-look.md @@ -0,0 +1,12 @@ +--- +"@clerk/clerk-js": minor +--- + +Add new `UserVerification` component (experimental feature). This UI component allows for a user to "re-enter" their credentials (first factor and/or second factor) which results in them being re-verified. + +New methods have been added: + +- `__experimental_openUserVerification()` +- `__experimental_closeUserVerification()` +- `__experimental_mountUserVerification(targetNode: HTMLDivElement)` +- `__experimental_unmountUserVerification(targetNode: HTMLDivElement)` diff --git a/.changeset/proud-dryers-smile.md b/.changeset/proud-dryers-smile.md new file mode 100644 index 00000000000..1f4a34838a4 --- /dev/null +++ b/.changeset/proud-dryers-smile.md @@ -0,0 +1,13 @@ +--- +"@clerk/types": minor +--- + +Add types for newly introduced `<__experimental_UserVerification />` component (experimental feature). New types: + +- `Appearance` has a new `userVerification` property +- `__experimental_UserVerificationProps` and `__experimental_UserVerificationModalProps` +- `__experimental_openUserVerification` method under the `Clerk` interface +- `__experimental_closeUserVerification` method under the `Clerk` interface +- `__experimental_mountUserVerification` method under the `Clerk` interface +- `__experimental_unmountUserVerification` method under the `Clerk` interface +- `__experimental_userVerification` property under `LocalizationResource` diff --git a/packages/chrome-extension/src/__tests__/__snapshots__/exports.test.ts.snap b/packages/chrome-extension/src/__tests__/__snapshots__/exports.test.ts.snap index 5926a52cb76..6a648684885 100644 --- a/packages/chrome-extension/src/__tests__/__snapshots__/exports.test.ts.snap +++ b/packages/chrome-extension/src/__tests__/__snapshots__/exports.test.ts.snap @@ -27,6 +27,7 @@ exports[`public exports should not include a breaking change 1`] = ` "SignedOut", "UserButton", "UserProfile", + "__experimental_UserVerification", "useAuth", "useClerk", "useEmailLink", diff --git a/packages/clerk-js/bundlewatch.config.json b/packages/clerk-js/bundlewatch.config.json index c155e90dd4e..9e711db89c8 100644 --- a/packages/clerk-js/bundlewatch.config.json +++ b/packages/clerk-js/bundlewatch.config.json @@ -13,6 +13,7 @@ { "path": "./dist/signup*.js", "maxSize": "10KB" }, { "path": "./dist/userbutton*.js", "maxSize": "5KB" }, { "path": "./dist/userprofile*.js", "maxSize": "15KB" }, + { "path": "./dist/userverification*.js", "maxSize": "5KB" }, { "path": "./dist/onetap*.js", "maxSize": "1KB" } ] } diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index aec54a17ab2..00f88ad3fb7 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -17,6 +17,8 @@ import { import { logger } from '@clerk/shared/logger'; import { eventPrebuiltComponentMounted, TelemetryCollector } from '@clerk/shared/telemetry'; import type { + __experimental_UserVerificationModalProps, + __experimental_UserVerificationProps, ActiveSessionResource, AuthenticateWithCoinbaseParams, AuthenticateWithGoogleOneTapParams, @@ -348,6 +350,7 @@ export class Clerk implements ClerkInterface { }; public openGoogleOneTap = (props?: GoogleOneTapProps): void => { + // TODO: add telemetry this.assertComponentsReady(this.#componentControls); void this.#componentControls .ensureMounted({ preloadHint: 'GoogleOneTap' }) @@ -379,6 +382,26 @@ export class Clerk implements ClerkInterface { void this.#componentControls.ensureMounted().then(controls => controls.closeModal('signIn')); }; + public __experimental_openUserVerification = (props?: __experimental_UserVerificationModalProps): void => { + this.assertComponentsReady(this.#componentControls); + if (noUserExists(this)) { + if (this.#instanceType === 'development') { + throw new ClerkRuntimeError(warnings.cannotOpenUserProfile, { + code: 'cannot_render_user_missing', + }); + } + return; + } + void this.#componentControls + .ensureMounted({ preloadHint: 'UserVerification' }) + .then(controls => controls.openModal('userVerification', props || {})); + }; + + public __experimental_closeUserVerification = (): void => { + this.assertComponentsReady(this.#componentControls); + void this.#componentControls.ensureMounted().then(controls => controls.closeModal('userVerification')); + }; + public openSignUp = (props?: SignUpProps): void => { this.assertComponentsReady(this.#componentControls); if (sessionExistsAndSingleSessionModeEnabled(this, this.environment)) { @@ -489,6 +512,38 @@ export class Clerk implements ClerkInterface { ); }; + public __experimental_mountUserVerification = ( + node: HTMLDivElement, + props?: __experimental_UserVerificationProps, + ): void => { + this.assertComponentsReady(this.#componentControls); + if (noUserExists(this)) { + if (this.#instanceType === 'development') { + throw new ClerkRuntimeError(warnings.cannotOpenUserProfile, { + code: 'cannot_render_user_missing', + }); + } + return; + } + void this.#componentControls.ensureMounted({ preloadHint: 'UserVerification' }).then(controls => + controls.mountComponent({ + name: 'UserVerification', + appearanceKey: 'userVerification', + node, + props, + }), + ); + }; + + public __experimental_unmountUserVerification = (node: HTMLDivElement): void => { + this.assertComponentsReady(this.#componentControls); + void this.#componentControls.ensureMounted().then(controls => + controls.unmountComponent({ + node, + }), + ); + }; + public mountSignUp = (node: HTMLDivElement, props?: SignUpProps): void => { this.assertComponentsReady(this.#componentControls); void this.#componentControls.ensureMounted({ preloadHint: 'SignUp' }).then(controls => @@ -860,6 +915,7 @@ export class Clerk implements ClerkInterface { return this.#authService.decorateUrlWithDevBrowserToken(toURL).href; } + public buildSignInUrl(options?: SignInRedirectOptions): string { return this.#buildUrl( 'signInUrl', diff --git a/packages/clerk-js/src/ui/Components.tsx b/packages/clerk-js/src/ui/Components.tsx index 56a31321705..e35ab93326d 100644 --- a/packages/clerk-js/src/ui/Components.tsx +++ b/packages/clerk-js/src/ui/Components.tsx @@ -1,6 +1,7 @@ import { createDeferredPromise } from '@clerk/shared'; import { useSafeLayoutEffect } from '@clerk/shared/react'; import type { + __experimental_UserVerificationProps, Appearance, Clerk, ClerkOptions, @@ -28,6 +29,7 @@ import { SignInModal, SignUpModal, UserProfileModal, + UserVerificationModal, } from './lazyModules/components'; import { LazyComponentRenderer, @@ -55,13 +57,33 @@ export type ComponentControls = { props?: unknown; }) => void; openModal: < - T extends 'googleOneTap' | 'signIn' | 'signUp' | 'userProfile' | 'organizationProfile' | 'createOrganization', + T extends + | 'googleOneTap' + | 'signIn' + | 'signUp' + | 'userProfile' + | 'organizationProfile' + | 'createOrganization' + | 'userVerification', >( modal: T, - props: T extends 'signIn' ? SignInProps : T extends 'signUp' ? SignUpProps : UserProfileProps, + props: T extends 'signIn' + ? SignInProps + : T extends 'signUp' + ? SignUpProps + : T extends 'userVerification' + ? __experimental_UserVerificationProps + : UserProfileProps, ) => void; closeModal: ( - modal: 'googleOneTap' | 'signIn' | 'signUp' | 'userProfile' | 'organizationProfile' | 'createOrganization', + modal: + | 'googleOneTap' + | 'signIn' + | 'signUp' + | 'userProfile' + | 'organizationProfile' + | 'createOrganization' + | 'userVerification', ) => void; // Special case, as the impersonation fab mounts automatically mountImpersonationFab: () => void; @@ -88,6 +110,7 @@ interface ComponentsState { signInModal: null | SignInProps; signUpModal: null | SignUpProps; userProfileModal: null | UserProfileProps; + userVerificationModal: null | __experimental_UserVerificationProps; organizationProfileModal: null | OrganizationProfileProps; createOrganizationModal: null | CreateOrganizationProps; nodes: Map; @@ -164,6 +187,7 @@ const Components = (props: ComponentsProps) => { signInModal: null, signUpModal: null, userProfileModal: null, + userVerificationModal: null, organizationProfileModal: null, createOrganizationModal: null, nodes: new Map(), @@ -175,6 +199,7 @@ const Components = (props: ComponentsProps) => { signInModal, signUpModal, userProfileModal, + userVerificationModal, organizationProfileModal, createOrganizationModal, nodes, @@ -297,6 +322,23 @@ const Components = (props: ComponentsProps) => { ); + const mountedUserVerificationModal = ( + componentsControls.closeModal('userVerification')} + onExternalNavigate={() => componentsControls.closeModal('userVerification')} + startPath={buildVirtualRouterUrl({ base: '/user-verification', path: urlStateParam?.path })} + componentName={'UserVerificationModal'} + modalContainerSx={{ alignItems: 'center' }} + modalContentSx={t => ({ height: `min(${t.sizes.$176}, calc(100% - ${t.sizes.$12}))`, margin: 0 })} + > + + + ); + const mountedOrganizationProfileModal = ( { {signInModal && mountedSignInModal} {signUpModal && mountedSignUpModal} {userProfileModal && mountedUserProfileModal} + {userVerificationModal && mountedUserVerificationModal} {organizationProfileModal && mountedOrganizationProfileModal} {createOrganizationModal && mountedCreateOrganizationModal} {state.impersonationFab && ( diff --git a/packages/clerk-js/src/ui/components/SignIn/AlternativeMethods.tsx b/packages/clerk-js/src/ui/components/SignIn/AlternativeMethods.tsx index 9285b08905d..bf93b476b71 100644 --- a/packages/clerk-js/src/ui/components/SignIn/AlternativeMethods.tsx +++ b/packages/clerk-js/src/ui/components/SignIn/AlternativeMethods.tsx @@ -1,6 +1,7 @@ import type { SignInFactor } from '@clerk/types'; import React from 'react'; +import { useCoreSignIn } from '../../contexts'; import type { LocalizationKey } from '../../customizables'; import { Button, Col, descriptors, Flex, Flow, localizationKeys } from '../../customizables'; import { ArrowBlockButton, BackLink, Card, Divider, Header } from '../../elements'; @@ -33,8 +34,10 @@ const AlternativeMethodsList = (props: AlternativeMethodListProps) => { const { onBackLinkClick, onHavingTroubleClick, onFactorSelected, mode = 'default' } = props; const card = useCardState(); const resetPasswordFactor = useResetPasswordFactor(); + const { supportedFirstFactors } = useCoreSignIn(); const { firstPartyFactors, hasAnyStrategy } = useAlternativeStrategies({ filterOutFactor: props?.currentFactor, + supportedFirstFactors: supportedFirstFactors, }); const flowPart = determineFlowPart(mode); diff --git a/packages/clerk-js/src/ui/components/SignIn/SignInFactorOne.tsx b/packages/clerk-js/src/ui/components/SignIn/SignInFactorOne.tsx index 0c5df0d862f..880f429039f 100644 --- a/packages/clerk-js/src/ui/components/SignIn/SignInFactorOne.tsx +++ b/packages/clerk-js/src/ui/components/SignIn/SignInFactorOne.tsx @@ -37,6 +37,7 @@ export function _SignInFactorOne(): JSX.Element { const availableFactors = signIn.supportedFirstFactors; const router = useRouter(); const card = useCardState(); + const { supportedFirstFactors } = useCoreSignIn(); const lastPreparedFactorKeyRef = React.useRef(''); const [{ currentFactor }, setFactor] = React.useState<{ @@ -49,6 +50,7 @@ export function _SignInFactorOne(): JSX.Element { const { hasAnyStrategy } = useAlternativeStrategies({ filterOutFactor: currentFactor, + supportedFirstFactors, }); const [showAllStrategies, setShowAllStrategies] = React.useState( @@ -123,7 +125,6 @@ export function _SignInFactorOne(): JSX.Element { } switch (currentFactor?.strategy) { - // @ts-ignore case 'passkey': return ( ): string { return titleize(signIn.userData?.firstName) || titleize(signIn.userData?.lastName) || signIn?.identifier || ''; } -// @ts-ignore const localStrategies: SignInStrategy[] = ['passkey', 'email_code', 'password', 'phone_code', 'email_link']; export function factorHasLocalStrategy(factor: SignInFactor | undefined | null): boolean { diff --git a/packages/clerk-js/src/ui/components/UserVerification/AlternativeMethods.tsx b/packages/clerk-js/src/ui/components/UserVerification/AlternativeMethods.tsx new file mode 100644 index 00000000000..4699d6e34d7 --- /dev/null +++ b/packages/clerk-js/src/ui/components/UserVerification/AlternativeMethods.tsx @@ -0,0 +1,135 @@ +import type { __experimental_SessionVerificationFirstFactor, SignInFactor } from '@clerk/types'; +import React from 'react'; + +import type { LocalizationKey } from '../../customizables'; +import { Col, descriptors, Flex, Flow, localizationKeys } from '../../customizables'; +import { ArrowBlockButton, BackLink, Card, Header } from '../../elements'; +import { useCardState } from '../../elements/contexts'; +import { useAlternativeStrategies } from '../../hooks/useAlternativeStrategies'; +import { ChatAltIcon, Email, LockClosedIcon } from '../../icons'; +import { formatSafeIdentifier } from '../../utils'; +import { useUserVerificationSession } from './useUserVerificationSession'; +import { withHavingTrouble } from './withHavingTrouble'; + +export type AlternativeMethodsProps = { + onBackLinkClick: React.MouseEventHandler | undefined; + onFactorSelected: (factor: SignInFactor) => void; + currentFactor: SignInFactor | undefined | null; +}; + +export type AlternativeMethodListProps = AlternativeMethodsProps & { onHavingTroubleClick: React.MouseEventHandler }; + +export const AlternativeMethods = (props: AlternativeMethodsProps) => { + return withHavingTrouble(AlternativeMethodsList, { + ...props, + }); +}; + +const AlternativeMethodsList = (props: AlternativeMethodListProps) => { + const { onBackLinkClick, onHavingTroubleClick, onFactorSelected } = props; + const card = useCardState(); + const { data } = useUserVerificationSession(); + const { firstPartyFactors, hasAnyStrategy } = useAlternativeStrategies<__experimental_SessionVerificationFirstFactor>( + { + filterOutFactor: props?.currentFactor, + supportedFirstFactors: data!.supportedFirstFactors, + }, + ); + + return ( + + + + + + + + {card.error} + {/*TODO: extract main in its own component */} + + + {hasAnyStrategy && ( + + {firstPartyFactors.map((factor, i) => ( + { + card.setError(undefined); + onFactorSelected(factor); + }} + /> + ))} + + )} + {onBackLinkClick && ( + + )} + + + + + + + + + + + + + ); +}; + +export function getButtonLabel(factor: __experimental_SessionVerificationFirstFactor): LocalizationKey { + switch (factor.strategy) { + case 'email_code': + return localizationKeys('__experimental_userVerification.alternativeMethods.blockButton__emailCode', { + identifier: formatSafeIdentifier(factor.safeIdentifier) || '', + }); + case 'phone_code': + return localizationKeys('__experimental_userVerification.alternativeMethods.blockButton__phoneCode', { + identifier: formatSafeIdentifier(factor.safeIdentifier) || '', + }); + case 'password': + return localizationKeys('__experimental_userVerification.alternativeMethods.blockButton__password'); + default: + throw `Invalid sign in strategy: "${(factor as any).strategy}"`; + } +} + +export function getButtonIcon(factor: __experimental_SessionVerificationFirstFactor) { + const icons = { + email_code: Email, + phone_code: ChatAltIcon, + password: LockClosedIcon, + } as const; + + return icons[factor.strategy as keyof typeof icons]; +} diff --git a/packages/clerk-js/src/ui/components/UserVerification/HavingTrouble.tsx b/packages/clerk-js/src/ui/components/UserVerification/HavingTrouble.tsx new file mode 100644 index 00000000000..92a9f706bb6 --- /dev/null +++ b/packages/clerk-js/src/ui/components/UserVerification/HavingTrouble.tsx @@ -0,0 +1,15 @@ +import { localizationKeys } from '../../customizables'; +import { ErrorCard } from '../../elements'; +import type { PropsOfComponent } from '../../styledSystem'; + +export const HavingTrouble = (props: PropsOfComponent) => { + const { onBackLinkClick } = props; + + return ( + + ); +}; diff --git a/packages/clerk-js/src/ui/components/UserVerification/UVFactorOneCodeForm.tsx b/packages/clerk-js/src/ui/components/UserVerification/UVFactorOneCodeForm.tsx new file mode 100644 index 00000000000..1e4cf289af8 --- /dev/null +++ b/packages/clerk-js/src/ui/components/UserVerification/UVFactorOneCodeForm.tsx @@ -0,0 +1,71 @@ +import { useUser } from '@clerk/shared/react'; +import type { EmailCodeFactor, PhoneCodeFactor } from '@clerk/types'; +import React from 'react'; + +import type { VerificationCodeCardProps } from '../../elements'; +import { useCardState, VerificationCodeCard } from '../../elements'; +import type { LocalizationKey } from '../../localization'; +import { handleError } from '../../utils'; +import { useAfterVerification } from './use-after-verification'; + +export type UVFactorOneCodeCard = Pick< + VerificationCodeCardProps, + 'onShowAlternativeMethodsClicked' | 'showAlternativeMethods' | 'onBackLinkClicked' +> & { + factor: EmailCodeFactor | PhoneCodeFactor; + factorAlreadyPrepared: boolean; + onFactorPrepare: () => void; +}; + +export type UVFactorOneCodeFormProps = UVFactorOneCodeCard & { + cardTitle: LocalizationKey; + cardSubtitle: LocalizationKey; + inputLabel: LocalizationKey; + resendButton: LocalizationKey; +}; + +export const UVFactorOneCodeForm = (props: UVFactorOneCodeFormProps) => { + const { user } = useUser(); + const card = useCardState(); + + const { handleVerificationResponse } = useAfterVerification(); + + React.useEffect(() => { + if (!props.factorAlreadyPrepared) { + prepare(); + } + }, []); + + const prepare = () => { + void user! + .__experimental_verifySessionPrepareFirstFactor(props.factor) + .then(() => props.onFactorPrepare()) + .catch(err => handleError(err, [], card.setError)); + }; + + const action: VerificationCodeCardProps['onCodeEntryFinishedAction'] = (code, resolve, reject) => { + user! + .__experimental_verifySessionAttemptFirstFactor({ strategy: props.factor.strategy, code }) + .then(async res => { + await resolve(); + return handleVerificationResponse(res); + }) + .catch(reject); + }; + + return ( + + ); +}; diff --git a/packages/clerk-js/src/ui/components/UserVerification/UVFactorOneEmailCodeCard.tsx b/packages/clerk-js/src/ui/components/UserVerification/UVFactorOneEmailCodeCard.tsx new file mode 100644 index 00000000000..ca72283043d --- /dev/null +++ b/packages/clerk-js/src/ui/components/UserVerification/UVFactorOneEmailCodeCard.tsx @@ -0,0 +1,21 @@ +import type { EmailCodeFactor } from '@clerk/types'; + +import { Flow, localizationKeys } from '../../customizables'; +import type { UVFactorOneCodeCard } from './UVFactorOneCodeForm'; +import { UVFactorOneCodeForm } from './UVFactorOneCodeForm'; + +type UVFactorOneEmailCodeCardProps = UVFactorOneCodeCard & { factor: EmailCodeFactor }; + +export const UVFactorOneEmailCodeCard = (props: UVFactorOneEmailCodeCardProps) => { + return ( + + + + ); +}; diff --git a/packages/clerk-js/src/ui/components/UserVerification/UVFactorTwoAlternativeMethods.tsx b/packages/clerk-js/src/ui/components/UserVerification/UVFactorTwoAlternativeMethods.tsx new file mode 100644 index 00000000000..5ec8ea02973 --- /dev/null +++ b/packages/clerk-js/src/ui/components/UserVerification/UVFactorTwoAlternativeMethods.tsx @@ -0,0 +1,110 @@ +import type { __experimental_SessionVerificationSecondFactor } from '@clerk/types'; +import React from 'react'; + +import type { LocalizationKey } from '../../customizables'; +import { Col, descriptors, Flow, localizationKeys } from '../../customizables'; +import { ArrowBlockButton, Card, Header } from '../../elements'; +import { useCardState } from '../../elements/contexts'; +import { backupCodePrefFactorComparator, formatSafeIdentifier } from '../../utils'; +import { HavingTrouble } from './HavingTrouble'; + +export type AlternativeMethodsProps = { + onBackLinkClick: React.MouseEventHandler | undefined; + onFactorSelected: (factor: __experimental_SessionVerificationSecondFactor) => void; + supportedSecondFactors: __experimental_SessionVerificationSecondFactor[] | null; +}; + +export const UVFactorTwoAlternativeMethods = (props: AlternativeMethodsProps) => { + const [showHavingTrouble, setShowHavingTrouble] = React.useState(false); + const toggleHavingTrouble = React.useCallback(() => setShowHavingTrouble(s => !s), [setShowHavingTrouble]); + + if (showHavingTrouble) { + return ; + } + + return ( + + ); +}; + +const AlternativeMethodsList = (props: AlternativeMethodsProps & { onHavingTroubleClick: React.MouseEventHandler }) => { + const { supportedSecondFactors, onHavingTroubleClick, onFactorSelected, onBackLinkClick } = props; + const card = useCardState(); + + return ( + + + + + + + + {card.error} + {/*TODO: extract main in its own component */} + + + {supportedSecondFactors?.sort(backupCodePrefFactorComparator).map((factor, i) => ( + onFactorSelected(factor)} + /> + ))} + + + {onBackLinkClick && ( + + )} + + + + + + + + + + + + + ); +}; + +export function getButtonLabel(factor: __experimental_SessionVerificationSecondFactor): LocalizationKey { + switch (factor.strategy) { + case 'phone_code': + return localizationKeys('__experimental_userVerification.alternativeMethods.blockButton__phoneCode', { + identifier: formatSafeIdentifier(factor.safeIdentifier) || '', + }); + case 'totp': + return localizationKeys('__experimental_userVerification.alternativeMethods.blockButton__totp'); + case 'backup_code': + return localizationKeys('__experimental_userVerification.alternativeMethods.blockButton__backupCode'); + default: + throw `Invalid verification strategy: "${(factor as any).strategy}"`; + } +} diff --git a/packages/clerk-js/src/ui/components/UserVerification/UVFactorTwoBackupCodeCard.tsx b/packages/clerk-js/src/ui/components/UserVerification/UVFactorTwoBackupCodeCard.tsx new file mode 100644 index 00000000000..a9248708659 --- /dev/null +++ b/packages/clerk-js/src/ui/components/UserVerification/UVFactorTwoBackupCodeCard.tsx @@ -0,0 +1,73 @@ +import { useUser } from '@clerk/shared/react'; +import React from 'react'; + +import { Col, descriptors, localizationKeys } from '../../customizables'; +import { Card, Form, Header, useCardState } from '../../elements'; +import { handleError, useFormControl } from '../../utils'; +import { useAfterVerification } from './use-after-verification'; + +type UVFactorTwoBackupCodeCardProps = { + onShowAlternativeMethodsClicked: React.MouseEventHandler; +}; + +export const UVFactorTwoBackupCodeCard = (props: UVFactorTwoBackupCodeCardProps) => { + const { onShowAlternativeMethodsClicked } = props; + const { user } = useUser(); + const { handleVerificationResponse } = useAfterVerification(); + + const card = useCardState(); + const codeControl = useFormControl('code', '', { + type: 'text', + label: localizationKeys('formFieldLabel__backupCode'), + isRequired: true, + }); + + const handleBackupCodeSubmit: React.FormEventHandler = e => { + e.preventDefault(); + return user! + .__experimental_verifySessionAttemptSecondFactor({ strategy: 'backup_code', code: codeControl.value }) + .then(handleVerificationResponse) + .catch(err => handleError(err, [codeControl], card.setError)); + }; + + return ( + + + + + + + {card.error} + + + + + + + + + {onShowAlternativeMethodsClicked && ( + + )} + + + + + + + + + ); +}; diff --git a/packages/clerk-js/src/ui/components/UserVerification/UVFactorTwoCodeForm.tsx b/packages/clerk-js/src/ui/components/UserVerification/UVFactorTwoCodeForm.tsx new file mode 100644 index 00000000000..6a8e3fe47ba --- /dev/null +++ b/packages/clerk-js/src/ui/components/UserVerification/UVFactorTwoCodeForm.tsx @@ -0,0 +1,69 @@ +import { useUser } from '@clerk/shared/react'; +import type { __experimental_SessionVerificationResource, PhoneCodeFactor, TOTPFactor } from '@clerk/types'; +import React from 'react'; + +import type { VerificationCodeCardProps } from '../../elements'; +import { useCardState, VerificationCodeCard } from '../../elements'; +import type { LocalizationKey } from '../../localization'; +import { handleError } from '../../utils'; +import { useAfterVerification } from './use-after-verification'; + +export type UVFactorTwoCodeCard = Pick & { + factor: PhoneCodeFactor | TOTPFactor; + factorAlreadyPrepared: boolean; + onFactorPrepare: () => void; + prepare?: () => Promise<__experimental_SessionVerificationResource>; +}; + +type SignInFactorTwoCodeFormProps = UVFactorTwoCodeCard & { + cardTitle: LocalizationKey; + cardSubtitle: LocalizationKey; + inputLabel: LocalizationKey; + resendButton?: LocalizationKey; +}; + +export const UVFactorTwoCodeForm = (props: SignInFactorTwoCodeFormProps) => { + const card = useCardState(); + const { user } = useUser(); + const { handleVerificationResponse } = useAfterVerification(); + + React.useEffect(() => { + if (props.factorAlreadyPrepared) { + return; + } + + void prepare?.(); + }, []); + + const prepare = props.prepare + ? () => + props + .prepare?.() + .then(() => props.onFactorPrepare()) + .catch(err => handleError(err, [], card.setError)) + : undefined; + + const action: VerificationCodeCardProps['onCodeEntryFinishedAction'] = (code, resolve, reject) => { + user! + .__experimental_verifySessionAttemptSecondFactor({ strategy: props.factor.strategy, code }) + .then(async res => { + await resolve(); + return handleVerificationResponse(res); + }) + .catch(reject); + }; + + return ( + + ); +}; diff --git a/packages/clerk-js/src/ui/components/UserVerification/UVFactorTwoPhoneCodeCard.tsx b/packages/clerk-js/src/ui/components/UserVerification/UVFactorTwoPhoneCodeCard.tsx new file mode 100644 index 00000000000..aa133de1b71 --- /dev/null +++ b/packages/clerk-js/src/ui/components/UserVerification/UVFactorTwoPhoneCodeCard.tsx @@ -0,0 +1,30 @@ +import { useUser } from '@clerk/shared/react'; +import type { PhoneCodeFactor } from '@clerk/types'; + +import { Flow, localizationKeys } from '../../customizables'; +import type { UVFactorTwoCodeCard } from './UVFactorTwoCodeForm'; +import { UVFactorTwoCodeForm } from './UVFactorTwoCodeForm'; + +type UVFactorTwoPhoneCodeCardProps = UVFactorTwoCodeCard & { factor: PhoneCodeFactor }; + +export const UVFactorTwoPhoneCodeCard = (props: UVFactorTwoPhoneCodeCardProps) => { + const { user } = useUser(); + + const prepare = () => { + const { phoneNumberId, strategy } = props.factor; + return user!.__experimental_verifySessionPrepareSecondFactor({ phoneNumberId, strategy }); + }; + + return ( + + + + ); +}; diff --git a/packages/clerk-js/src/ui/components/UserVerification/UserVerificationFactorOne.tsx b/packages/clerk-js/src/ui/components/UserVerification/UserVerificationFactorOne.tsx new file mode 100644 index 00000000000..79d191c58ca --- /dev/null +++ b/packages/clerk-js/src/ui/components/UserVerification/UserVerificationFactorOne.tsx @@ -0,0 +1,126 @@ +import type { SignInFactor } from '@clerk/types'; +import React, { useEffect } from 'react'; + +import { useEnvironment } from '../../contexts'; +import { ErrorCard, LoadingCard, useCardState, withCardStateProvider } from '../../elements'; +import { useAlternativeStrategies } from '../../hooks/useAlternativeStrategies'; +import { localizationKeys } from '../../localization'; +import { useRouter } from '../../router'; +import { determineStartingSignInFactor, factorHasLocalStrategy } from '../SignIn/utils'; +import { AlternativeMethods } from './AlternativeMethods'; +import { UserVerificationFactorOnePasswordCard } from './UserVerificationFactorOnePassword'; +import { useUserVerificationSession, withUserVerificationSessionGuard } from './useUserVerificationSession'; +import { UVFactorOneEmailCodeCard } from './UVFactorOneEmailCodeCard'; + +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; + } + return key; +}; + +export function _UserVerificationFactorOne(): JSX.Element | null { + const { data } = useUserVerificationSession(); + const card = useCardState(); + const { navigate } = useRouter(); + + const lastPreparedFactorKeyRef = React.useRef(''); + const sessionVerification = data!; + + const availableFactors = sessionVerification.supportedFirstFactors; + const { preferredSignInStrategy } = useEnvironment().displayConfig; + + const [{ currentFactor }, setFactor] = React.useState<{ + currentFactor: SignInFactor | undefined | null; + prevCurrentFactor: SignInFactor | undefined | null; + }>(() => ({ + currentFactor: determineStartingSignInFactor(availableFactors, null, preferredSignInStrategy), + prevCurrentFactor: undefined, + })); + + const { hasAnyStrategy } = useAlternativeStrategies({ + filterOutFactor: currentFactor, + supportedFirstFactors: availableFactors, + }); + + const [showAllStrategies, setShowAllStrategies] = React.useState( + () => !currentFactor || !factorHasLocalStrategy(currentFactor), + ); + + const toggleAllStrategies = hasAnyStrategy ? () => setShowAllStrategies(s => !s) : undefined; + + const handleFactorPrepare = () => { + lastPreparedFactorKeyRef.current = factorKey(currentFactor); + }; + + const selectFactor = (factor: SignInFactor) => { + setFactor(prev => ({ + currentFactor: factor, + prevCurrentFactor: prev.currentFactor, + })); + }; + + useEffect(() => { + if (sessionVerification.status === 'needs_second_factor') { + void navigate('factor-two'); + } + }, []); + + if (!currentFactor) { + return ( + + ); + } + + if (showAllStrategies) { + const canGoBack = factorHasLocalStrategy(currentFactor); + + const toggle = toggleAllStrategies; + const backHandler = () => { + card.setError(undefined); + toggle?.(); + }; + + return ( + { + selectFactor(f); + toggle?.(); + }} + currentFactor={currentFactor} + /> + ); + } + + switch (currentFactor?.strategy) { + case 'password': + return ; + case 'email_code': + return ( + + ); + default: + return ; + } +} + +export const UserVerificationFactorOne = withUserVerificationSessionGuard( + withCardStateProvider(_UserVerificationFactorOne), +); diff --git a/packages/clerk-js/src/ui/components/UserVerification/UserVerificationFactorOnePassword.tsx b/packages/clerk-js/src/ui/components/UserVerification/UserVerificationFactorOnePassword.tsx new file mode 100644 index 00000000000..d4783d5725d --- /dev/null +++ b/packages/clerk-js/src/ui/components/UserVerification/UserVerificationFactorOnePassword.tsx @@ -0,0 +1,95 @@ +import { useUser } from '@clerk/shared/react'; +import React from 'react'; + +import { Col, descriptors, Flow, localizationKeys } from '../../customizables'; +import { Card, Form, Header, useCardState } from '../../elements'; +import { handleError, useFormControl } from '../../utils'; +import { HavingTrouble } from '../SignIn/HavingTrouble'; +import { useAfterVerification } from './use-after-verification'; + +type UserVerificationFactorOnePasswordProps = { + onShowAlternativeMethodsClick: React.MouseEventHandler | undefined; +}; + +export function UserVerificationFactorOnePasswordCard(props: UserVerificationFactorOnePasswordProps): JSX.Element { + const { onShowAlternativeMethodsClick } = props; + const { user } = useUser(); + + const { handleVerificationResponse } = useAfterVerification(); + const card = useCardState(); + + const [showHavingTrouble, setShowHavingTrouble] = React.useState(false); + const toggleHavingTrouble = React.useCallback(() => setShowHavingTrouble(s => !s), [setShowHavingTrouble]); + + const passwordControl = useFormControl('password', '', { + type: 'password', + label: localizationKeys('formFieldLabel__password'), + placeholder: localizationKeys('formFieldInputPlaceholder__password'), + }); + + const handlePasswordSubmit: React.FormEventHandler = async e => { + e.preventDefault(); + return user + ?.__experimental_verifySessionAttemptFirstFactor({ + strategy: 'password', + password: passwordControl.value, + }) + .then(handleVerificationResponse) + .catch(err => handleError(err, [passwordControl], card.setError)); + }; + + if (showHavingTrouble) { + return ; + } + + return ( + + + + + + + + {card.error} + + + {/* For password managers */} + {/**/} + + + + + + + + + + + + + + + ); +} diff --git a/packages/clerk-js/src/ui/components/UserVerification/UserVerificationFactorTwo.tsx b/packages/clerk-js/src/ui/components/UserVerification/UserVerificationFactorTwo.tsx new file mode 100644 index 00000000000..f2fb1af64d8 --- /dev/null +++ b/packages/clerk-js/src/ui/components/UserVerification/UserVerificationFactorTwo.tsx @@ -0,0 +1,95 @@ +import type { __experimental_SessionVerificationSecondFactor, SignInFactor } from '@clerk/types'; +import React, { useEffect } from 'react'; + +import { LoadingCard, withCardStateProvider } from '../../elements'; +import { useRouter } from '../../router'; +import { determineStartingSignInSecondFactor } from '../SignIn/utils'; +import { UserVerificationFactorTwoTOTP } from './UserVerificationFactorTwoTOTP'; +import { useUserVerificationSession, withUserVerificationSessionGuard } from './useUserVerificationSession'; +import { UVFactorTwoAlternativeMethods } from './UVFactorTwoAlternativeMethods'; +import { UVFactorTwoBackupCodeCard } from './UVFactorTwoBackupCodeCard'; +import { UVFactorTwoPhoneCodeCard } from './UVFactorTwoPhoneCodeCard'; + +const factorKey = (factor: SignInFactor | null | undefined) => { + if (!factor) { + return ''; + } + let key = factor.strategy; + if ('phoneNumberId' in factor) { + key += factor.phoneNumberId; + } + return key; +}; + +export function _UserVerificationFactorTwo(): JSX.Element { + const { navigate } = useRouter(); + const { data } = useUserVerificationSession(); + const sessionVerification = data!; + + const availableFactors = sessionVerification.supportedSecondFactors; + + const lastPreparedFactorKeyRef = React.useRef(''); + const [currentFactor, setCurrentFactor] = React.useState<__experimental_SessionVerificationSecondFactor | null>( + () => determineStartingSignInSecondFactor(availableFactors) as __experimental_SessionVerificationSecondFactor, + ); + const [showAllStrategies, setShowAllStrategies] = React.useState(!currentFactor); + const toggleAllStrategies = () => setShowAllStrategies(s => !s); + + const handleFactorPrepare = () => { + lastPreparedFactorKeyRef.current = factorKey(currentFactor); + }; + + const selectFactor = (factor: __experimental_SessionVerificationSecondFactor) => { + setCurrentFactor(factor); + toggleAllStrategies(); + }; + + useEffect(() => { + if (sessionVerification.status === 'needs_first_factor') { + void navigate('../'); + } + }, []); + + if (!currentFactor) { + return ; + } + + if (showAllStrategies) { + return ( + + ); + } + + switch (currentFactor?.strategy) { + case 'phone_code': + return ( + + ); + case 'totp': + return ( + + ); + case 'backup_code': + return ; + default: + return ; + } +} + +export const UserVerificationFactorTwo = withUserVerificationSessionGuard( + withCardStateProvider(_UserVerificationFactorTwo), +); diff --git a/packages/clerk-js/src/ui/components/UserVerification/UserVerificationFactorTwoTOTP.tsx b/packages/clerk-js/src/ui/components/UserVerification/UserVerificationFactorTwoTOTP.tsx new file mode 100644 index 00000000000..906049eed61 --- /dev/null +++ b/packages/clerk-js/src/ui/components/UserVerification/UserVerificationFactorTwoTOTP.tsx @@ -0,0 +1,20 @@ +import type { TOTPFactor } from '@clerk/types'; + +import { Flow, localizationKeys } from '../../customizables'; +import type { UVFactorTwoCodeCard } from './UVFactorTwoCodeForm'; +import { UVFactorTwoCodeForm } from './UVFactorTwoCodeForm'; + +type UVFactorTwoTOTPCardProps = UVFactorTwoCodeCard & { factor: TOTPFactor }; + +export function UserVerificationFactorTwoTOTP(props: UVFactorTwoTOTPCardProps): JSX.Element { + return ( + + + + ); +} diff --git a/packages/clerk-js/src/ui/components/UserVerification/__tests__/UVFactorOne.test.tsx b/packages/clerk-js/src/ui/components/UserVerification/__tests__/UVFactorOne.test.tsx new file mode 100644 index 00000000000..c1cc5ec37b5 --- /dev/null +++ b/packages/clerk-js/src/ui/components/UserVerification/__tests__/UVFactorOne.test.tsx @@ -0,0 +1,123 @@ +import { describe, it } from '@jest/globals'; +import { waitFor } from '@testing-library/react'; + +import { render, screen } from '../../../../testUtils'; +import { clearFetchCache } from '../../../hooks'; +import { bindCreateFixtures } from '../../../utils/test/createFixtures'; +import { UserVerificationFactorOne } from '../UserVerificationFactorOne'; + +const { createFixtures } = bindCreateFixtures('UserVerification'); + +describe('UserVerificationFactorOne', () => { + /** + * `` internally uses useFetch which caches the results, be sure to clear the cache before each test + */ + beforeEach(() => { + clearFetchCache(); + }); + + it('renders the component for with strategy:password', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withUser({ username: 'clerkuser' }); + }); + fixtures.user?.__experimental_verifySession.mockResolvedValue({ + status: 'needs_first_factor', + supportedFirstFactors: [{ strategy: 'password' }], + }); + const { getByLabelText, getByText } = render(, { wrapper }); + + await waitFor(() => { + getByText('Enter your password'); + getByText('Enter the password associated with your account'); + getByLabelText(/^password/i); + }); + }); + + it('renders the component for with strategy:email_code', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withUser({ username: 'clerkuser' }); + f.withPreferredSignInStrategy({ strategy: 'otp' }); + }); + fixtures.user?.__experimental_verifySession.mockResolvedValue({ + status: 'needs_first_factor', + supportedFirstFactors: [{ strategy: 'password' }, { strategy: 'email_code' }], + }); + fixtures.user?.__experimental_verifySessionPrepareFirstFactor.mockResolvedValue({}); + const { getByLabelText, getByText } = render(, { wrapper }); + + await waitFor(() => { + getByText('Check your email'); + getByLabelText(/Enter verification code/i); + }); + }); + + it.todo('renders the component for with strategy:phone_code'); + + describe('Submitting', () => { + it('navigates to UserVerificationFactorTwo page when user submits first factor and second factor is enabled', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withUser({ username: 'clerkuser' }); + }); + fixtures.user?.__experimental_verifySession.mockResolvedValue({ + status: 'needs_first_factor', + supportedFirstFactors: [{ strategy: 'password' }], + }); + fixtures.user?.__experimental_verifySessionAttemptFirstFactor.mockResolvedValue({ + status: 'needs_second_factor', + supportedFirstFactors: [{ strategy: 'password' }], + }); + fixtures.user?.__experimental_verifySessionPrepareSecondFactor.mockResolvedValue({ + status: 'needs_second_factor', + supportedFirstFactors: [{ strategy: 'password' }], + }); + + const { userEvent, getByLabelText, getByText } = render(, { wrapper }); + + await waitFor(() => getByText('Enter your password')); + await userEvent.type(getByLabelText(/^password/i), 'testtest'); + await userEvent.click(getByText('Continue')); + + expect(fixtures.user?.__experimental_verifySessionAttemptFirstFactor).toHaveBeenCalledWith({ + strategy: 'password', + password: 'testtest', + }); + + await waitFor(() => expect(fixtures.router.navigate).toHaveBeenCalledWith('./factor-two')); + }); + + it('sets an active session when user submits first factor successfully and second factor does not exist', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withUser({ username: 'clerkuser' }); + }); + fixtures.user?.__experimental_verifySession.mockResolvedValue({ + status: 'needs_first_factor', + supportedFirstFactors: [{ strategy: 'password' }], + }); + fixtures.user?.__experimental_verifySessionAttemptFirstFactor.mockResolvedValue({ + status: 'complete', + session: { + id: '123', + }, + supportedFirstFactors: [], + }); + + const { userEvent, getByLabelText, getByText } = render(, { wrapper }); + + await waitFor(() => getByText('Enter your password')); + await userEvent.type(getByLabelText(/^password/i), 'testtest'); + await userEvent.click(screen.getByText('Continue')); + + await waitFor(() => { + expect(fixtures.clerk.setActive).toHaveBeenCalled(); + }); + }); + }); + + describe('Use another method', () => { + it.todo('should list enabled first factor methods without the current one'); + }); + + describe('Get Help', () => { + it.todo('should render the get help component when clicking the "Get Help" button'); + }); +}); diff --git a/packages/clerk-js/src/ui/components/UserVerification/__tests__/UVFactorTwo.test.tsx b/packages/clerk-js/src/ui/components/UserVerification/__tests__/UVFactorTwo.test.tsx new file mode 100644 index 00000000000..68f9067124f --- /dev/null +++ b/packages/clerk-js/src/ui/components/UserVerification/__tests__/UVFactorTwo.test.tsx @@ -0,0 +1,144 @@ +import { describe, it } from '@jest/globals'; +import { waitFor } from '@testing-library/react'; + +import { render, runFakeTimers } from '../../../../testUtils'; +import { clearFetchCache } from '../../../hooks'; +import { bindCreateFixtures } from '../../../utils/test/createFixtures'; +import { UserVerificationFactorTwo } from '../UserVerificationFactorTwo'; + +const { createFixtures } = bindCreateFixtures('UserVerification'); + +describe('UserVerificationFactorTwo', () => { + /** + * `` internally uses useFetch which caches the results, be sure to clear the cache before each test + */ + beforeEach(() => { + clearFetchCache(); + }); + + it('renders the component for with strategy:phone_code', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withUser({ username: 'clerkuser' }); + }); + fixtures.user?.__experimental_verifySession.mockResolvedValue({ + status: 'needs_second_factor', + supportedSecondFactors: [{ strategy: 'phone_code' }], + }); + + fixtures.user?.__experimental_verifySessionPrepareSecondFactor.mockResolvedValue({ + status: 'needs_second_factor', + supportedSecondFactors: [{ strategy: 'phone_code' }], + }); + + const { getByText, getAllByLabelText } = render(, { wrapper }); + + await waitFor(() => { + getByText('Check your phone'); + const inputs = getAllByLabelText(/digit/i); + expect(inputs.length).toBe(6); + }); + }); + + it('renders the component for with strategy:totp', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withUser({ username: 'clerkuser' }); + }); + fixtures.user?.__experimental_verifySession.mockResolvedValue({ + status: 'needs_second_factor', + supportedSecondFactors: [{ strategy: 'totp' }], + }); + + fixtures.user?.__experimental_verifySessionPrepareSecondFactor.mockResolvedValue({ + status: 'needs_second_factor', + supportedSecondFactors: [{ strategy: 'totp' }], + }); + const { getByLabelText, getByText } = render(, { wrapper }); + + await waitFor(() => { + getByText('Two-step verification'); + getByText('To continue, please enter the verification code generated by your authenticator app'); + getByLabelText(/Enter verification code/i); + }); + }); + + it('renders the component for with strategy:backup_code', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withUser({ username: 'clerkuser' }); + }); + fixtures.user?.__experimental_verifySession.mockResolvedValue({ + status: 'needs_second_factor', + supportedSecondFactors: [{ strategy: 'backup_code' }], + }); + + fixtures.user?.__experimental_verifySessionPrepareSecondFactor.mockResolvedValue({ + status: 'needs_second_factor', + supportedSecondFactors: [{ strategy: 'backup_code' }], + }); + const { getByLabelText, getByText } = render(, { wrapper }); + + await waitFor(() => { + getByText('Enter a backup code'); + getByText('Your backup code is the one you got when setting up two-step authentication.'); + getByLabelText(/Backup code/i); + }); + }); + + describe('Navigation', () => { + it('navigates to UserVerificationFactorOne component if user lands on SignInFactorTwo page but they should not', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withUser({ username: 'clerkuser' }); + }); + fixtures.user?.__experimental_verifySession.mockResolvedValue({ + status: 'needs_first_factor', + }); + render(, { wrapper }); + + await waitFor(() => expect(fixtures.router.navigate).toHaveBeenCalledWith('../')); + }); + }); + + describe('Submitting', () => { + it('sets an active session when user submits second factor successfully', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withUser({ username: 'clerkuser' }); + }); + fixtures.user?.__experimental_verifySession.mockResolvedValue({ + status: 'needs_second_factor', + supportedSecondFactors: [{ strategy: 'phone_code' }], + }); + + fixtures.user?.__experimental_verifySessionPrepareSecondFactor.mockResolvedValue({ + status: 'needs_second_factor', + supportedSecondFactors: [{ strategy: 'phone_code' }], + }); + + fixtures.user?.__experimental_verifySessionAttemptSecondFactor.mockResolvedValue({ + status: 'complete', + supportedSecondFactors: [], + session: { + id: '123', + }, + }); + + await runFakeTimers(async timers => { + const { userEvent, getByLabelText, getByText } = render(, { wrapper }); + + await waitFor(() => getByText('Check your phone')); + + await userEvent.type(getByLabelText(/Enter verification code/i), '123456'); + timers.runOnlyPendingTimers(); + await waitFor(() => { + expect(fixtures.clerk.setActive).toHaveBeenCalled(); + }); + }); + }); + }); + + describe('Use another method', () => { + it.todo('should list enabled second factor methods without the current one'); + }); + + describe('Get Help', () => { + it.todo('should render the get help component when clicking the "Get Help" button'); + }); +}); diff --git a/packages/clerk-js/src/ui/components/UserVerification/index.tsx b/packages/clerk-js/src/ui/components/UserVerification/index.tsx new file mode 100644 index 00000000000..955bc58ac32 --- /dev/null +++ b/packages/clerk-js/src/ui/components/UserVerification/index.tsx @@ -0,0 +1,52 @@ +import type { __experimental_UserVerificationModalProps, __experimental_UserVerificationProps } from '@clerk/types'; +import React from 'react'; + +import { ComponentContext, withCoreSessionSwitchGuard } from '../../contexts'; +import { Flow } from '../../customizables'; +import { Route, Switch } from '../../router'; +import { UserVerificationFactorOne } from './UserVerificationFactorOne'; +import { UserVerificationFactorTwo } from './UserVerificationFactorTwo'; + +function UserVerificationRoutes(): JSX.Element { + return ( + + + + + + + + + + + ); +} + +UserVerificationRoutes.displayName = 'UserVerification'; + +const UserVerification: React.ComponentType<__experimental_UserVerificationProps> = + withCoreSessionSwitchGuard(UserVerificationRoutes); + +const UserVerificationModal = (props: __experimental_UserVerificationModalProps): JSX.Element => { + return ( + + + {/*TODO: Used by InvisibleRootBox, can we simplify? */} +
+ +
+
+
+ ); +}; + +export { UserVerification, UserVerificationModal }; diff --git a/packages/clerk-js/src/ui/components/UserVerification/use-after-verification.ts b/packages/clerk-js/src/ui/components/UserVerification/use-after-verification.ts new file mode 100644 index 00000000000..a4682458e82 --- /dev/null +++ b/packages/clerk-js/src/ui/components/UserVerification/use-after-verification.ts @@ -0,0 +1,61 @@ +import { useClerk } from '@clerk/shared/react'; +import type { __experimental_SessionVerificationResource } from '@clerk/types'; +import { useCallback } from 'react'; + +import { clerkInvalidFAPIResponse } from '../../../core/errors'; +import { useUserVerification } from '../../contexts'; +import { useSupportEmail } from '../../hooks/useSupportEmail'; +import { useRouter } from '../../router'; +import { useUserVerificationSession } from './useUserVerificationSession'; + +const useAfterVerification = () => { + const { afterVerification, routing, afterVerificationUrl } = useUserVerification(); + const supportEmail = useSupportEmail(); + const { setActive } = useClerk(); + const { setCache } = useUserVerificationSession(); + const { __experimental_closeUserVerification } = useClerk(); + const { navigate } = useRouter(); + + const beforeEmit = useCallback(async () => { + if (routing === 'virtual') { + /** + * if `afterVerificationUrl` and modal redirect there, + * else if `afterVerificationUrl` redirect there, + * else If modal close it, + */ + afterVerification?.(); + __experimental_closeUserVerification(); + } else { + if (afterVerificationUrl) { + await navigate(afterVerificationUrl); + } + } + }, [__experimental_closeUserVerification, afterVerification, afterVerificationUrl, navigate, routing]); + + const handleVerificationResponse = useCallback( + async (sessionVerification: __experimental_SessionVerificationResource) => { + setCache({ + data: sessionVerification, + isLoading: false, + isValidating: false, + error: null, + cachedAt: Date.now(), + }); + switch (sessionVerification.status) { + case 'complete': + return setActive({ session: sessionVerification.session.id, beforeEmit }); + case 'needs_second_factor': + return navigate('./factor-two'); + default: + return console.error(clerkInvalidFAPIResponse(sessionVerification.status, supportEmail)); + } + }, + [beforeEmit, navigate, setActive, supportEmail], + ); + + return { + handleVerificationResponse, + }; +}; + +export { useAfterVerification }; diff --git a/packages/clerk-js/src/ui/components/UserVerification/useUserVerificationSession.tsx b/packages/clerk-js/src/ui/components/UserVerification/useUserVerificationSession.tsx new file mode 100644 index 00000000000..1cd1b26a38b --- /dev/null +++ b/packages/clerk-js/src/ui/components/UserVerification/useUserVerificationSession.tsx @@ -0,0 +1,42 @@ +import { useUser } from '@clerk/shared/react'; + +import { useUserVerification } from '../../contexts'; +import { LoadingCard } from '../../elements'; +import { useFetch } from '../../hooks'; + +const useUserVerificationSession = () => { + const { user } = useUser(); + const { level } = useUserVerification(); + const data = useFetch( + user ? user.__experimental_verifySession : undefined, + { + level: level || 'L2.secondFactor', + // TODO(STEP-UP): Figure out if this needs to be a prop + maxAge: 'A1.10min', + }, + { + throttleTime: 300, + }, + ); + + return { ...data }; +}; + +function withUserVerificationSessionGuard

(Component: React.ComponentType

): React.ComponentType

{ + const Hoc = (props: P) => { + const { isLoading, data } = useUserVerificationSession(); + + if (isLoading || !data) { + return ; + } + + return ; + }; + + const displayName = Component.displayName || Component.name || 'Component'; + Component.displayName = displayName; + Hoc.displayName = displayName; + return Hoc; +} + +export { useUserVerificationSession, withUserVerificationSessionGuard }; diff --git a/packages/clerk-js/src/ui/components/UserVerification/withHavingTrouble.tsx b/packages/clerk-js/src/ui/components/UserVerification/withHavingTrouble.tsx new file mode 100644 index 00000000000..84af28aa3b5 --- /dev/null +++ b/packages/clerk-js/src/ui/components/UserVerification/withHavingTrouble.tsx @@ -0,0 +1,23 @@ +import React from 'react'; + +import type { AlternativeMethodsProps } from './AlternativeMethods'; +import { HavingTrouble } from './HavingTrouble'; + +export const withHavingTrouble =

( + Component: React.ComponentType

, + props: AlternativeMethodsProps, +) => { + const [showHavingTrouble, setShowHavingTrouble] = React.useState(false); + const toggleHavingTrouble = React.useCallback(() => setShowHavingTrouble(s => !s), [setShowHavingTrouble]); + + if (showHavingTrouble) { + return ; + } + + return ( + + ); +}; diff --git a/packages/clerk-js/src/ui/contexts/ClerkUIComponentsContext.tsx b/packages/clerk-js/src/ui/contexts/ClerkUIComponentsContext.tsx index d155efc32d4..3d064bb273b 100644 --- a/packages/clerk-js/src/ui/contexts/ClerkUIComponentsContext.tsx +++ b/packages/clerk-js/src/ui/contexts/ClerkUIComponentsContext.tsx @@ -23,6 +23,7 @@ import type { SignUpCtx, UserButtonCtx, UserProfileCtx, + UserVerificationCtx, } from '../types'; import type { CustomPageContent } from '../utils'; import { @@ -239,6 +240,24 @@ export const useUserProfileContext = (): UserProfileContextType => { }; }; +export type UserVerificationContextType = UserVerificationCtx; + +export const useUserVerification = (): UserVerificationContextType => { + const { componentName, afterVerification, afterVerificationUrl, ...ctx } = (React.useContext(ComponentContext) || + {}) as UserVerificationCtx; + + if (componentName !== 'UserVerification') { + throw new Error('Clerk: useUserVerificationContext called outside of the mounted UserVerification component.'); + } + + return { + ...ctx, + afterVerification, + afterVerificationUrl, + componentName, + }; +}; + export const useUserButtonContext = () => { const { componentName, customMenuItems, ...ctx } = (React.useContext(ComponentContext) || {}) as UserButtonCtx; const clerk = useClerk(); diff --git a/packages/clerk-js/src/ui/elements/contexts/FlowMetadataContext.tsx b/packages/clerk-js/src/ui/elements/contexts/FlowMetadataContext.tsx index 22c6a20ee2e..3969b106558 100644 --- a/packages/clerk-js/src/ui/elements/contexts/FlowMetadataContext.tsx +++ b/packages/clerk-js/src/ui/elements/contexts/FlowMetadataContext.tsx @@ -7,6 +7,7 @@ type FlowMetadata = { | 'signUp' | 'userButton' | 'userProfile' + | 'userVerification' | 'organizationProfile' | 'createOrganization' | 'organizationSwitcher' diff --git a/packages/clerk-js/src/ui/hooks/useAlternativeStrategies.ts b/packages/clerk-js/src/ui/hooks/useAlternativeStrategies.ts index 0ff31c27cf1..1d8c814eb7c 100644 --- a/packages/clerk-js/src/ui/hooks/useAlternativeStrategies.ts +++ b/packages/clerk-js/src/ui/hooks/useAlternativeStrategies.ts @@ -1,29 +1,33 @@ import { isWebAuthnSupported } from '@clerk/shared/webauthn'; -import type { SignInFactor } from '@clerk/types'; +import type { SignInFactor, SignInFirstFactor } from '@clerk/types'; import { factorHasLocalStrategy, isResetPasswordStrategy } from '../components/SignIn/utils'; -import { useCoreSignIn } from '../contexts'; import { allStrategiesButtonsComparator } from '../utils'; import { useEnabledThirdPartyProviders } from './useEnabledThirdPartyProviders'; -export function useAlternativeStrategies({ filterOutFactor }: { filterOutFactor: SignInFactor | null | undefined }) { - const { supportedFirstFactors } = useCoreSignIn(); - +export function useAlternativeStrategies({ + filterOutFactor, + supportedFirstFactors: _supportedFirstFactors, +}: { + filterOutFactor: SignInFactor | null | undefined; + supportedFirstFactors: SignInFirstFactor[] | null | undefined; +}) { const { strategies: OAuthStrategies } = useEnabledThirdPartyProviders(); + const supportedFirstFactors = _supportedFirstFactors || []; - const firstFactors = supportedFirstFactors?.filter( + const firstFactors = supportedFirstFactors.filter( f => f.strategy !== filterOutFactor?.strategy && !isResetPasswordStrategy(f.strategy), ); - const shouldAllowForAlternativeStrategies = firstFactors && firstFactors.length + OAuthStrategies.length > 0; + const shouldAllowForAlternativeStrategies = firstFactors.length + OAuthStrategies.length > 0; const firstPartyFactors = supportedFirstFactors - ?.filter(f => !f.strategy.startsWith('oauth_') && !(f.strategy === filterOutFactor?.strategy)) + .filter(f => !f.strategy.startsWith('oauth_') && !(f.strategy === filterOutFactor?.strategy)) .filter(factor => factorHasLocalStrategy(factor)) // Only include passkey if the device supports it. // @ts-ignore Types are not public yet. .filter(factor => (factor.strategy === 'passkey' ? isWebAuthnSupported() : true)) - .sort(allStrategiesButtonsComparator); + .sort(allStrategiesButtonsComparator) as T[]; return { hasAnyStrategy: shouldAllowForAlternativeStrategies, diff --git a/packages/clerk-js/src/ui/hooks/useFetch.ts b/packages/clerk-js/src/ui/hooks/useFetch.ts index 6be263af7e0..ac3c7f63db3 100644 --- a/packages/clerk-js/src/ui/hooks/useFetch.ts +++ b/packages/clerk-js/src/ui/hooks/useFetch.ts @@ -61,10 +61,17 @@ const useCache = ( }; }; +/** + * An in-house simpler alternative to useSWR + * @param fetcher If fetcher is undefined no action will be performed + * @param params + * @param options + */ export const useFetch = ( fetcher: ((...args: any) => Promise) | undefined, params: K, options?: { + throttleTime?: number; onSuccess?: (data: T) => void; staleTime?: number; }, @@ -72,8 +79,13 @@ export const useFetch = ( const { subscribeCache, getCache, setCache } = useCache(params); const staleTime = options?.staleTime || 1000 * 60 * 2; //cache for 2 minutes by default + const throttleTime = options?.throttleTime || 0; const fetcherRef = useRef(fetcher); + if (throttleTime < 0) { + throw new Error('ClerkJS: A negative value for `throttleTime` is not allowed '); + } + const cached = useSyncExternalStore(subscribeCache, getCache); useEffect(() => { @@ -85,6 +97,8 @@ export const useFetch = ( return; } + const d = performance.now(); + setCache({ data: null, isLoading: !getCache(), @@ -95,14 +109,19 @@ export const useFetch = ( .then(result => { if (typeof result !== 'undefined') { const data = Array.isArray(result) ? result : typeof result === 'object' ? { ...result } : result; - setCache({ - data, - isLoading: false, - isValidating: false, - error: null, - cachedAt: Date.now(), - }); - options?.onSuccess?.(data); + const n = performance.now(); + const waitTime = throttleTime - (n - d); + + setTimeout(() => { + setCache({ + data, + isLoading: false, + isValidating: false, + error: null, + cachedAt: Date.now(), + }); + options?.onSuccess?.(data); + }, waitTime); } }) .catch(() => { @@ -118,5 +137,6 @@ export const useFetch = ( return { ...cached, + setCache, }; }; diff --git a/packages/clerk-js/src/ui/lazyModules/components.ts b/packages/clerk-js/src/ui/lazyModules/components.ts index 731a7f9802e..fa5c1fd54ef 100644 --- a/packages/clerk-js/src/ui/lazyModules/components.ts +++ b/packages/clerk-js/src/ui/lazyModules/components.ts @@ -13,6 +13,7 @@ const componentImportPaths = { OrganizationList: () => import(/* webpackChunkName: "organizationlist" */ './../components/OrganizationList'), ImpersonationFab: () => import(/* webpackChunkName: "impersonationfab" */ './../components/ImpersonationFab'), GoogleOneTap: () => import(/* webpackChunkName: "onetap" */ './../components/GoogleOneTap'), + UserVerification: () => import(/* webpackChunkName: "userverification" */ './../components/UserVerification'), } as const; export const SignIn = lazy(() => componentImportPaths.SignIn().then(module => ({ default: module.SignIn }))); @@ -22,6 +23,14 @@ export const GoogleOneTap = lazy(() => componentImportPaths.GoogleOneTap().then(module => ({ default: module.OneTap })), ); +export const UserVerification = lazy(() => + componentImportPaths.UserVerification().then(module => ({ default: module.UserVerification })), +); + +export const UserVerificationModal = lazy(() => + componentImportPaths.UserVerification().then(module => ({ default: module.UserVerificationModal })), +); + export const SignUp = lazy(() => componentImportPaths.SignUp().then(module => ({ default: module.SignUp }))); export const SignUpModal = lazy(() => componentImportPaths.SignUp().then(module => ({ default: module.SignUpModal }))); @@ -72,6 +81,7 @@ export const ClerkComponents = { SignUp, UserButton, UserProfile, + UserVerification, OrganizationSwitcher, OrganizationList, OrganizationProfile, @@ -81,6 +91,7 @@ export const ClerkComponents = { UserProfileModal, OrganizationProfileModal, CreateOrganizationModal, + UserVerificationModal, GoogleOneTap, }; diff --git a/packages/clerk-js/src/ui/types.ts b/packages/clerk-js/src/ui/types.ts index ce03e28c098..3b6035ceeba 100644 --- a/packages/clerk-js/src/ui/types.ts +++ b/packages/clerk-js/src/ui/types.ts @@ -1,4 +1,5 @@ import type { + __experimental_UserVerificationProps, CreateOrganizationProps, GoogleOneTapProps, OrganizationListProps, @@ -20,6 +21,7 @@ export type { OrganizationProfileProps, CreateOrganizationProps, OrganizationListProps, + __experimental_UserVerificationProps, }; export type AvailableComponentProps = @@ -30,7 +32,8 @@ export type AvailableComponentProps = | OrganizationSwitcherProps | OrganizationProfileProps | CreateOrganizationProps - | OrganizationListProps; + | OrganizationListProps + | __experimental_UserVerificationProps; type ComponentMode = 'modal' | 'mounted'; @@ -39,6 +42,11 @@ export type SignInCtx = SignInProps & { mode?: ComponentMode; }; +export type UserVerificationCtx = __experimental_UserVerificationProps & { + componentName: 'UserVerification'; + mode?: ComponentMode; +}; + export type UserProfileCtx = UserProfileProps & { componentName: 'UserProfile'; mode?: ComponentMode; @@ -83,6 +91,7 @@ export type AvailableComponentCtx = | SignUpCtx | UserButtonCtx | UserProfileCtx + | UserVerificationCtx | OrganizationProfileCtx | CreateOrganizationCtx | OrganizationSwitcherCtx diff --git a/packages/clerk-js/src/ui/utils/test/createFixtures.tsx b/packages/clerk-js/src/ui/utils/test/createFixtures.tsx index 9135864fcaf..df68b9920aa 100644 --- a/packages/clerk-js/src/ui/utils/test/createFixtures.tsx +++ b/packages/clerk-js/src/ui/utils/test/createFixtures.tsx @@ -67,6 +67,7 @@ const unboundCreateFixtures = [ const fixtures = { clerk: clerkMock, + user: clerkMock.user, signIn: clerkMock.client.signIn, signUp: clerkMock.client.signUp, environment: environmentMock, diff --git a/packages/localizations/src/en-US.ts b/packages/localizations/src/en-US.ts index e9686b12a03..b2ea2abb105 100644 --- a/packages/localizations/src/en-US.ts +++ b/packages/localizations/src/en-US.ts @@ -279,7 +279,7 @@ export const enUS: LocalizationResource = { getHelp: { blockButton__emailSupport: 'Email support', content: - 'If you’re experiencing difficulty signing into your account, email us and we will work with you to restore access as soon as possible.', + 'If you have trouble signing into your account, email us and we will work with you to restore access as soon as possible.', title: 'Get help', }, subtitle: 'Facing issues? You can use any of these methods to sign in.', @@ -548,6 +548,62 @@ export const enUS: LocalizationResource = { action__signOut: 'Sign out', action__signOutAll: 'Sign out of all accounts', }, + __experimental_userVerification: { + alternativeMethods: { + actionLink: 'Get help', + actionText: 'Don’t have any of these?', + blockButton__backupCode: 'Use a backup code', + blockButton__emailCode: 'Email code to {{identifier}}', + blockButton__password: 'Continue with your password', + blockButton__phoneCode: 'Send SMS code to {{identifier}}', + blockButton__totp: 'Use your authenticator app', + getHelp: { + blockButton__emailSupport: 'Email support', + content: + 'If you have trouble verifying your account, email us and we will work with you to restore access as soon as possible.', + title: 'Get help', + }, + subtitle: 'Facing issues? You can use any of these methods for verification.', + title: 'Use another method', + }, + backupCodeMfa: { + subtitle: 'Your backup code is the one you got when setting up two-step authentication.', + title: 'Enter a backup code', + }, + emailCode: { + formTitle: 'Verification code', + resendButton: "Didn't receive a code? Resend", + subtitle: 'to continue to {{applicationName}}', + title: 'Check your email', + }, + noAvailableMethods: { + message: "Cannot proceed with verification. There's no available authentication factor.", + subtitle: 'An error occurred', + title: 'Cannot verify your account', + }, + password: { + actionLink: 'Use another method', + subtitle: 'Enter the password associated with your account', + title: 'Enter your password', + }, + phoneCode: { + formTitle: 'Verification code', + resendButton: "Didn't receive a code? Resend", + subtitle: 'to continue to {{applicationName}}', + title: 'Check your phone', + }, + phoneCodeMfa: { + formTitle: 'Verification code', + resendButton: "Didn't receive a code? Resend", + subtitle: 'To continue, please enter the verification code sent to your phone', + title: 'Check your phone', + }, + totpMfa: { + formTitle: 'Verification code', + subtitle: 'To continue, please enter the verification code generated by your authenticator app', + title: 'Two-step verification', + }, + }, userProfile: { backupCodePage: { actionLabel__copied: 'Copied!', diff --git a/packages/nextjs/src/client-boundary/uiComponents.tsx b/packages/nextjs/src/client-boundary/uiComponents.tsx index 50599e5df8b..aee6d4dd1bd 100644 --- a/packages/nextjs/src/client-boundary/uiComponents.tsx +++ b/packages/nextjs/src/client-boundary/uiComponents.tsx @@ -26,6 +26,7 @@ export { SignOutButton, SignUpButton, UserButton, + __experimental_UserVerification, GoogleOneTap, } from '@clerk/clerk-react'; diff --git a/packages/nextjs/src/index.ts b/packages/nextjs/src/index.ts index 8a7e7513feb..25eb942ee14 100644 --- a/packages/nextjs/src/index.ts +++ b/packages/nextjs/src/index.ts @@ -30,6 +30,7 @@ export { SignUpButton, UserButton, UserProfile, + __experimental_UserVerification, GoogleOneTap, } from './client-boundary/uiComponents'; diff --git a/packages/react/src/components/index.ts b/packages/react/src/components/index.ts index 39b52fdda15..45780dfa7b9 100644 --- a/packages/react/src/components/index.ts +++ b/packages/react/src/components/index.ts @@ -3,6 +3,7 @@ export { SignIn, UserProfile, UserButton, + __experimental_UserVerification, OrganizationSwitcher, OrganizationProfile, CreateOrganization, diff --git a/packages/react/src/components/uiComponents.tsx b/packages/react/src/components/uiComponents.tsx index 919ec0d1736..1f68a59d898 100644 --- a/packages/react/src/components/uiComponents.tsx +++ b/packages/react/src/components/uiComponents.tsx @@ -1,6 +1,7 @@ import { logErrorInDevMode, without } from '@clerk/shared'; import { isDeeplyEqual } from '@clerk/shared/react'; import type { + __experimental_UserVerificationProps, CreateOrganizationProps, GoogleOneTapProps, OrganizationListProps, @@ -259,6 +260,20 @@ export const UserButton: UserButtonExportType = Object.assign(_UserButton, { Link: MenuLink, }); +export const __experimental_UserVerification = withClerk( + ({ clerk, ...props }: WithClerkProp>) => { + return ( + + ); + }, + '__experimental_UserVerification', +); + export function OrganizationProfilePage({ children }: PropsWithChildren) { logErrorInDevMode(organizationProfilePageRenderedError); return <>{children}; diff --git a/packages/react/src/isomorphicClerk.ts b/packages/react/src/isomorphicClerk.ts index af17158d5c7..12495497aad 100644 --- a/packages/react/src/isomorphicClerk.ts +++ b/packages/react/src/isomorphicClerk.ts @@ -3,6 +3,8 @@ import { handleValueOrFn } from '@clerk/shared/handleValueOrFn'; import { loadClerkJsScript } from '@clerk/shared/loadClerkJsScript'; import type { TelemetryCollector } from '@clerk/shared/telemetry'; import type { + __experimental_UserVerificationModalProps, + __experimental_UserVerificationProps, ActiveSessionResource, AuthenticateWithCoinbaseParams, AuthenticateWithGoogleOneTapParams, @@ -106,6 +108,7 @@ type IsomorphicLoadedClerk = Without< | 'mountSignUp' | 'mountSignIn' | 'mountUserProfile' + | '__experimental_mountUserVerification' | 'client' > & { // TODO: Align return type and parms @@ -151,6 +154,7 @@ type IsomorphicLoadedClerk = Without< mountSignUp: (node: HTMLDivElement, props: SignUpProps) => void; mountSignIn: (node: HTMLDivElement, props: SignInProps) => void; mountUserProfile: (node: HTMLDivElement, props: UserProfileProps) => void; + __experimental_mountUserVerification: (node: HTMLDivElement, props: __experimental_UserVerificationProps) => void; client: ClientResource | undefined; }; @@ -160,6 +164,7 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { private readonly Clerk: ClerkProp; private clerkjs: BrowserClerk | HeadlessBrowserClerk | null = null; private preopenOneTap?: null | GoogleOneTapProps = null; + private preopenUserVerification?: null | __experimental_UserVerificationProps = null; private preopenSignIn?: null | SignInProps = null; private preopenSignUp?: null | SignUpProps = null; private preopenUserProfile?: null | UserProfileProps = null; @@ -173,6 +178,7 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { private premountCreateOrganizationNodes = new Map(); private premountOrganizationSwitcherNodes = new Map(); private premountOrganizationListNodes = new Map(); + private premountUserVerificationNodes = new Map(); private premountMethodCalls = new Map, MethodCallback>(); // A separate Map of `addListener` method calls to handle multiple listeners. private premountAddListenerCalls = new Map< @@ -507,6 +513,10 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { clerkjs.openUserProfile(this.preopenUserProfile); } + if (this.preopenUserVerification !== null) { + clerkjs.__experimental_openUserVerification(this.preopenUserVerification); + } + if (this.preopenOneTap !== null) { clerkjs.openGoogleOneTap(this.preopenOneTap); } @@ -531,6 +541,10 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { clerkjs.mountUserProfile(node, props); }); + this.premountUserVerificationNodes.forEach((props: __experimental_UserVerificationProps, node: HTMLDivElement) => { + clerkjs.__experimental_mountUserVerification(node, props); + }); + this.premountUserButtonNodes.forEach((props: UserButtonProps, node: HTMLDivElement) => { clerkjs.mountUserButton(node, props); }); @@ -642,6 +656,22 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { } }; + __experimental_openUserVerification = (props?: __experimental_UserVerificationModalProps): void => { + if (this.clerkjs && this.#loaded) { + this.clerkjs.__experimental_openUserVerification(props); + } else { + this.preopenUserVerification = props; + } + }; + + __experimental_closeUserVerification = (): void => { + if (this.clerkjs && this.#loaded) { + this.clerkjs.__experimental_closeUserVerification(); + } else { + this.preopenUserVerification = null; + } + }; + openGoogleOneTap = (props?: GoogleOneTapProps): void => { if (this.clerkjs && this.#loaded) { this.clerkjs.openGoogleOneTap(props); @@ -738,6 +768,22 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { } }; + __experimental_mountUserVerification = (node: HTMLDivElement, props: __experimental_UserVerificationProps): void => { + if (this.clerkjs && this.#loaded) { + this.clerkjs.__experimental_mountUserVerification(node, props); + } else { + this.premountUserVerificationNodes.set(node, props); + } + }; + + __experimental_unmountUserVerification = (node: HTMLDivElement): void => { + if (this.clerkjs && this.#loaded) { + this.clerkjs.__experimental_unmountUserVerification(node); + } else { + this.premountUserVerificationNodes.delete(node); + } + }; + mountSignUp = (node: HTMLDivElement, props: SignUpProps): void => { if (this.clerkjs && this.#loaded) { this.clerkjs.mountSignUp(node, props); diff --git a/packages/types/src/appearance.ts b/packages/types/src/appearance.ts index 03b14920735..88b566cb383 100644 --- a/packages/types/src/appearance.ts +++ b/packages/types/src/appearance.ts @@ -629,6 +629,7 @@ export type OrganizationSwitcherTheme = Theme; export type OrganizationListTheme = Theme; export type OrganizationProfileTheme = Theme; export type CreateOrganizationTheme = Theme; +export type UserVerificationTheme = Theme; export type Appearance = T & { /** @@ -647,6 +648,10 @@ export type Appearance = T & { * Theme overrides that only apply to the `` component */ userProfile?: T; + /** + * Theme overrides that only apply to the `` component + */ + userVerification?: T; /** * Theme overrides that only apply to the `` component */ diff --git a/packages/types/src/clerk.ts b/packages/types/src/clerk.ts index 867f37755ec..36e7f767bdb 100644 --- a/packages/types/src/clerk.ts +++ b/packages/types/src/clerk.ts @@ -10,6 +10,7 @@ import type { SignUpTheme, UserButtonTheme, UserProfileTheme, + UserVerificationTheme, } from './appearance'; import type { ClientResource } from './client'; import type { CustomMenuItem } from './customMenuItems'; @@ -32,6 +33,7 @@ import type { SignUpForceRedirectUrl, } from './redirects'; import type { ActiveSessionResource } from './session'; +import type { __experimental_SessionVerificationLevel } from './sessionVerification'; import type { SignInResource } from './signIn'; import type { SignUpResource } from './signUp'; import type { Web3Strategy } from './strategies'; @@ -141,6 +143,19 @@ export interface Clerk { */ closeSignIn: () => void; + /** + * Opens the Clerk UserVerification component in a modal. + * @experimantal This API is still under active development and may change at any moment. + * @param props Optional user verification configuration parameters. + */ + __experimental_openUserVerification: (props?: __experimental_UserVerificationModalProps) => void; + + /** + * Closes the Clerk user verification modal. + * @experimantal This API is still under active development and may change at any moment. + */ + __experimental_closeUserVerification: () => void; + /** * Opens the Google One Tap component. * @param props Optional props that will be passed to the GoogleOneTap component. @@ -212,6 +227,27 @@ export interface Clerk { */ unmountSignIn: (targetNode: HTMLDivElement) => void; + /** + * Mounts a user reverification flow component at the target element. + * + * @experimantal This API is still under active development and may change at any moment. + * @param targetNode Target node to mount the UserVerification component from. + * @param props user verification configuration parameters. + */ + __experimental_mountUserVerification: ( + targetNode: HTMLDivElement, + props?: __experimental_UserVerificationProps, + ) => void; + + /** + * Unmount a user reverification flow component from the target element. + * If there is no component mounted at the target node, results in a noop. + * + * @experimantal This API is still under active development and may change at any moment. + * @param targetNode Target node to unmount the UserVerification component from. + */ + __experimental_unmountUserVerification: (targetNode: HTMLDivElement) => void; + /** * Mounts a sign up flow component at the target element. * @@ -737,7 +773,7 @@ export type SignInProps = RoutingOptions & { /** * Customisation options to fully match the Clerk components to your own brand. * These options serve as overrides and will be merged with the global `appearance` - * prop of ClerkProvided (if one is provided) + * prop of ClerkProvider (if one is provided) */ appearance?: SignInTheme; /** @@ -761,6 +797,32 @@ interface TransferableOption { export type SignInModalProps = WithoutRouting; +/** + * @experimantal + */ +export type __experimental_UserVerificationProps = RoutingOptions & { + // TODO(STEP-UP): Verify and write a description + afterVerification?: () => void; + // TODO(STEP-UP): Verify and write a description + afterVerificationUrl?: string; + + /** + * Defines the steps of the verification flow. + * When `L3.multiFactor` is used, the user will be prompt for a first factor flow followed by a second factor flow. + * @default `'L2.secondFactor'` + */ + level?: __experimental_SessionVerificationLevel; + + /** + * Customisation options to fully match the Clerk components to your own brand. + * These options serve as overrides and will be merged with the global `appearance` + * prop of ClerkProvider (if one is provided) + */ + appearance?: UserVerificationTheme; +}; + +export type __experimental_UserVerificationModalProps = WithoutRouting<__experimental_UserVerificationProps>; + type GoogleOneTapRedirectUrlProps = SignInForceRedirectUrl & SignUpForceRedirectUrl; export type GoogleOneTapProps = GoogleOneTapRedirectUrlProps & { @@ -807,7 +869,7 @@ export type SignUpProps = RoutingOptions & { /** * Customisation options to fully match the Clerk components to your own brand. * These options serve as overrides and will be merged with the global `appearance` - * prop of ClerkProvided (if one is provided) + * prop of ClerkProvider (if one is provided) */ appearance?: SignUpTheme; @@ -830,7 +892,7 @@ export type UserProfileProps = RoutingOptions & { /** * Customisation options to fully match the Clerk components to your own brand. * These options serve as overrides and will be merged with the global `appearance` - * prop of ClerkProvided (if one is provided) + * prop of ClerkProvider (if one is provided) */ appearance?: UserProfileTheme; /* @@ -860,7 +922,7 @@ export type OrganizationProfileProps = RoutingOptions & { /** * Customisation options to fully match the Clerk components to your own brand. * These options serve as overrides and will be merged with the global `appearance` - * prop of ClerkProvided (if one is provided) + * prop of ClerkProvider (if one is provided) */ appearance?: OrganizationProfileTheme; /* @@ -888,7 +950,7 @@ export type CreateOrganizationProps = RoutingOptions & { /** * Customisation options to fully match the Clerk components to your own brand. * These options serve as overrides and will be merged with the global `appearance` - * prop of ClerkProvided (if one is provided) + * prop of ClerkProvider (if one is provided) */ appearance?: CreateOrganizationTheme; /** @@ -944,7 +1006,7 @@ export type UserButtonProps = UserButtonProfileMode & { /** * Customisation options to fully match the Clerk components to your own brand. * These options serve as overrides and will be merged with the global `appearance` - * prop of ClerkProvided (if one is provided) + * prop of ClerkProvider (if one is provided) */ appearance?: UserButtonTheme; @@ -1034,7 +1096,7 @@ export type OrganizationSwitcherProps = CreateOrganizationMode & /** * Customisation options to fully match the Clerk components to your own brand. * These options serve as overrides and will be merged with the global `appearance` - * prop of ClerkProvided (if one is provided) + * prop of ClerkProvider(if one is provided) */ appearance?: OrganizationSwitcherTheme; /* @@ -1063,7 +1125,7 @@ export type OrganizationListProps = { /** * Customisation options to fully match the Clerk components to your own brand. * These options serve as overrides and will be merged with the global `appearance` - * prop of ClerkProvided (if one is provided) + * prop of ClerkProvider (if one is provided) */ appearance?: OrganizationListTheme; /** diff --git a/packages/types/src/localization.ts b/packages/types/src/localization.ts index 6c04fdbed95..40e73c3670c 100644 --- a/packages/types/src/localization.ts +++ b/packages/types/src/localization.ts @@ -283,6 +283,61 @@ type _LocalizationResource = { action__signOutAll: LocalizationValue; }; }; + __experimental_userVerification: { + password: { + title: LocalizationValue; + subtitle: LocalizationValue; + actionLink: LocalizationValue; + }; + emailCode: { + title: LocalizationValue; + subtitle: LocalizationValue; + formTitle: LocalizationValue; + resendButton: LocalizationValue; + }; + phoneCode: { + title: LocalizationValue; + subtitle: LocalizationValue; + formTitle: LocalizationValue; + resendButton: LocalizationValue; + }; + phoneCodeMfa: { + title: LocalizationValue; + subtitle: LocalizationValue; + formTitle: LocalizationValue; + resendButton: LocalizationValue; + }; + totpMfa: { + title: LocalizationValue; + subtitle: LocalizationValue; + formTitle: LocalizationValue; + }; + backupCodeMfa: { + title: LocalizationValue; + subtitle: LocalizationValue; + }; + alternativeMethods: { + title: LocalizationValue; + subtitle: LocalizationValue; + actionLink: LocalizationValue; + actionText: LocalizationValue; + blockButton__emailCode: LocalizationValue; + blockButton__phoneCode: LocalizationValue; + blockButton__password: LocalizationValue; + blockButton__totp: LocalizationValue; + blockButton__backupCode: LocalizationValue; + getHelp: { + title: LocalizationValue; + content: LocalizationValue; + blockButton__emailSupport: LocalizationValue; + }; + }; + noAvailableMethods: { + title: LocalizationValue; + subtitle: LocalizationValue; + message: LocalizationValue; + }; + }; userProfile: { mobileButton__menu: LocalizationValue; formButtonPrimary__continue: LocalizationValue;