From 6b0961765e1f3d09679be4b163fa13ac7dd97191 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Wed, 20 Nov 2024 11:50:00 -0500 Subject: [PATCH] chore(shared): Update signature of `useReverification` for error handling configuration (#4564) --- .changeset/silent-bears-worry.md | 5 + .../UserProfile/AddAuthenticatorApp.tsx | 7 +- .../UserProfile/ConnectedAccountsMenu.tsx | 6 +- .../UserProfile/ConnectedAccountsSection.tsx | 12 +- .../components/UserProfile/DeleteUserForm.tsx | 8 +- .../ui/components/UserProfile/EmailForm.tsx | 7 +- .../UserProfile/MfaBackupCodeCreateForm.tsx | 7 +- .../components/UserProfile/PasskeySection.tsx | 7 +- .../components/UserProfile/PasswordForm.tsx | 25 ++-- .../ui/components/UserProfile/PhoneForm.tsx | 13 +- .../components/UserProfile/UsernameForm.tsx | 7 +- packages/shared/src/authorization.ts | 12 +- .../react/__tests__/useReverification.test.ts | 61 +++++++++ .../src/react/hooks/useReverification.ts | 119 +++++++++++++----- 14 files changed, 189 insertions(+), 107 deletions(-) create mode 100644 .changeset/silent-bears-worry.md create mode 100644 packages/shared/src/react/__tests__/useReverification.test.ts diff --git a/.changeset/silent-bears-worry.md b/.changeset/silent-bears-worry.md new file mode 100644 index 0000000000..9af5406880 --- /dev/null +++ b/.changeset/silent-bears-worry.md @@ -0,0 +1,5 @@ +--- +'@clerk/shared': minor +--- + +Change `useReverification` to handle error in a callback, but still allow an error to be thrown via options. diff --git a/packages/clerk-js/src/ui/components/UserProfile/AddAuthenticatorApp.tsx b/packages/clerk-js/src/ui/components/UserProfile/AddAuthenticatorApp.tsx index 997916cee4..3393e31e99 100644 --- a/packages/clerk-js/src/ui/components/UserProfile/AddAuthenticatorApp.tsx +++ b/packages/clerk-js/src/ui/components/UserProfile/AddAuthenticatorApp.tsx @@ -28,12 +28,7 @@ export const AddAuthenticatorApp = withCardStateProvider((props: AddAuthenticato const { title, onSuccess, onReset } = props; const { user } = useUser(); const card = useCardState(); - const [createTOTP] = useReverification(() => { - if (!user) { - return Promise.resolve(undefined); - } - return user.createTOTP(); - }); + const [createTOTP] = useReverification(() => user?.createTOTP()); const { close } = useActionContext(); const [totp, setTOTP] = React.useState(undefined); const [displayFormat, setDisplayFormat] = React.useState('qr'); diff --git a/packages/clerk-js/src/ui/components/UserProfile/ConnectedAccountsMenu.tsx b/packages/clerk-js/src/ui/components/UserProfile/ConnectedAccountsMenu.tsx index fbb2aab974..9845cf6bf1 100644 --- a/packages/clerk-js/src/ui/components/UserProfile/ConnectedAccountsMenu.tsx +++ b/packages/clerk-js/src/ui/components/UserProfile/ConnectedAccountsMenu.tsx @@ -20,17 +20,13 @@ const ConnectMenuButton = (props: { strategy: OAuthStrategy }) => { const isModal = mode === 'modal'; const [createExternalAccount] = useReverification(() => { - if (!user) { - return Promise.resolve(undefined); - } - const socialProvider = strategy.replace('oauth_', '') as OAuthProvider; const redirectUrl = isModal ? appendModalState({ url: window.location.href, componentName, socialProvider: socialProvider }) : window.location.href; const additionalScopes = additionalOAuthScopes ? additionalOAuthScopes[socialProvider] : []; - return user.createExternalAccount({ + return user?.createExternalAccount({ strategy, redirectUrl, additionalScopes, diff --git a/packages/clerk-js/src/ui/components/UserProfile/ConnectedAccountsSection.tsx b/packages/clerk-js/src/ui/components/UserProfile/ConnectedAccountsSection.tsx index 75335459de..ab61bcd687 100644 --- a/packages/clerk-js/src/ui/components/UserProfile/ConnectedAccountsSection.tsx +++ b/packages/clerk-js/src/ui/components/UserProfile/ConnectedAccountsSection.tsx @@ -98,17 +98,13 @@ const ConnectedAccount = ({ account }: { account: ExternalAccountResource }) => }) : window.location.href; - const [createExternalAccount] = useReverification(() => { - if (!user) { - return Promise.resolve(undefined); - } - - return user.createExternalAccount({ + const [createExternalAccount] = useReverification(() => + user?.createExternalAccount({ strategy: account.verification!.strategy as OAuthStrategy, redirectUrl, additionalScopes, - }); - }); + }), + ); if (!user) { return null; diff --git a/packages/clerk-js/src/ui/components/UserProfile/DeleteUserForm.tsx b/packages/clerk-js/src/ui/components/UserProfile/DeleteUserForm.tsx index 25fc299919..edbe4b8244 100644 --- a/packages/clerk-js/src/ui/components/UserProfile/DeleteUserForm.tsx +++ b/packages/clerk-js/src/ui/components/UserProfile/DeleteUserForm.tsx @@ -16,13 +16,7 @@ export const DeleteUserForm = withCardStateProvider((props: DeleteUserFormProps) const { t } = useLocalizations(); const { otherSessions } = useMultipleSessions({ user }); const { setActive } = useClerk(); - const [deleteUserWithReverification] = useReverification(() => { - if (!user) { - return Promise.resolve(undefined); - } - - return user.delete(); - }); + const [deleteUserWithReverification] = useReverification(() => user?.delete()); const confirmationField = useFormControl('deleteConfirmation', '', { type: 'text', diff --git a/packages/clerk-js/src/ui/components/UserProfile/EmailForm.tsx b/packages/clerk-js/src/ui/components/UserProfile/EmailForm.tsx index deeb86b8da..4c7e0bf44e 100644 --- a/packages/clerk-js/src/ui/components/UserProfile/EmailForm.tsx +++ b/packages/clerk-js/src/ui/components/UserProfile/EmailForm.tsx @@ -22,12 +22,7 @@ export const EmailForm = withCardStateProvider((props: EmailFormProps) => { const environment = useEnvironment(); const preferEmailLinks = emailLinksEnabledForInstance(environment); - const [createEmailAddress] = useReverification(() => { - if (!user) { - return Promise.resolve(undefined); - } - return user.createEmailAddress({ email: emailField.value }); - }); + const [createEmailAddress] = useReverification(() => user?.createEmailAddress({ email: emailField.value })); const emailAddressRef = React.useRef(user?.emailAddresses.find(a => a.id === id)); const wizard = useWizard({ diff --git a/packages/clerk-js/src/ui/components/UserProfile/MfaBackupCodeCreateForm.tsx b/packages/clerk-js/src/ui/components/UserProfile/MfaBackupCodeCreateForm.tsx index 0dd61ccb35..4f9cac0c06 100644 --- a/packages/clerk-js/src/ui/components/UserProfile/MfaBackupCodeCreateForm.tsx +++ b/packages/clerk-js/src/ui/components/UserProfile/MfaBackupCodeCreateForm.tsx @@ -19,12 +19,7 @@ export const MfaBackupCodeCreateForm = withCardStateProvider((props: MfaBackupCo const { onSuccess } = props; const { user } = useUser(); const card = useCardState(); - const [createBackupCode] = useReverification(() => { - if (!user) { - return Promise.resolve(undefined); - } - return user.createBackupCode(); - }); + const [createBackupCode] = useReverification(() => user?.createBackupCode()); const [backupCode, setBackupCode] = React.useState(undefined); React.useEffect(() => { diff --git a/packages/clerk-js/src/ui/components/UserProfile/PasskeySection.tsx b/packages/clerk-js/src/ui/components/UserProfile/PasskeySection.tsx index a01eb98e0e..4059a396a1 100644 --- a/packages/clerk-js/src/ui/components/UserProfile/PasskeySection.tsx +++ b/packages/clerk-js/src/ui/components/UserProfile/PasskeySection.tsx @@ -191,12 +191,7 @@ const AddPasskeyButton = () => { const card = useCardState(); const { isSatellite } = useClerk(); const { user } = useUser(); - const [createPasskey] = useReverification(() => { - if (!user) { - return Promise.resolve(undefined); - } - return user.createPasskey(); - }); + const [createPasskey] = useReverification(() => user?.createPasskey()); const handleCreatePasskey = async () => { if (!user) { diff --git a/packages/clerk-js/src/ui/components/UserProfile/PasswordForm.tsx b/packages/clerk-js/src/ui/components/UserProfile/PasswordForm.tsx index 07f6fb7e2a..8b9b22a5fc 100644 --- a/packages/clerk-js/src/ui/components/UserProfile/PasswordForm.tsx +++ b/packages/clerk-js/src/ui/components/UserProfile/PasswordForm.tsx @@ -1,4 +1,5 @@ import { __experimental_useReverification as useReverification, useSession, useUser } from '@clerk/shared/react'; +import type { UserResource } from '@clerk/types'; import { useRef } from 'react'; import { useEnvironment } from '../../contexts'; @@ -36,19 +37,9 @@ type PasswordFormProps = FormProps; export const PasswordForm = withCardStateProvider((props: PasswordFormProps) => { const { onSuccess, onReset } = props; const { user } = useUser(); - const [updatePasswordWithReverification] = useReverification(() => { - if (!user) { - return Promise.resolve(undefined); - } - - const opts = { - newPassword: passwordField.value, - signOutOfOtherSessions: sessionsField.checked, - currentPassword: user.passwordEnabled ? currentPasswordField.value : undefined, - } satisfies Parameters[0]; - - return user.updatePassword(opts); - }); + const [updatePasswordWithReverification] = useReverification( + (user: UserResource, opts: Parameters) => user.updatePassword(...opts), + ); if (!user) { return null; @@ -124,7 +115,13 @@ export const PasswordForm = withCardStateProvider((props: PasswordFormProps) => text: generateSuccessPageText(user.passwordEnabled, !!sessionsField.checked), }; - await updatePasswordWithReverification(); + const opts = { + newPassword: passwordField.value, + signOutOfOtherSessions: sessionsField.checked, + currentPassword: user.passwordEnabled ? currentPasswordField.value : undefined, + } satisfies Parameters[0]; + + await updatePasswordWithReverification(user, [opts]); onSuccess(); } catch (e) { handleError(e, [currentPasswordField, passwordField, confirmField], card.setError); diff --git a/packages/clerk-js/src/ui/components/UserProfile/PhoneForm.tsx b/packages/clerk-js/src/ui/components/UserProfile/PhoneForm.tsx index 0627e33daf..6b1baeb9ef 100644 --- a/packages/clerk-js/src/ui/components/UserProfile/PhoneForm.tsx +++ b/packages/clerk-js/src/ui/components/UserProfile/PhoneForm.tsx @@ -1,5 +1,5 @@ import { __experimental_useReverification as useReverification, useUser } from '@clerk/shared/react'; -import type { PhoneNumberResource } from '@clerk/types'; +import type { PhoneNumberResource, UserResource } from '@clerk/types'; import React from 'react'; import { useWizard, Wizard } from '../../common'; @@ -49,12 +49,9 @@ export const AddPhone = (props: AddPhoneProps) => { const { title, onSuccess, onReset, onUseExistingNumberClick, resourceRef } = props; const card = useCardState(); const { user } = useUser(); - const [createPhoneNumber] = useReverification(() => { - if (!user) { - return Promise.resolve(undefined); - } - return user.createPhoneNumber({ phoneNumber: phoneField.value }); - }); + const [createPhoneNumber] = useReverification( + (user: UserResource, opt: Parameters[0]) => user.createPhoneNumber(opt), + ); const phoneField = useFormControl('phoneNumber', '', { type: 'tel', @@ -70,7 +67,7 @@ export const AddPhone = (props: AddPhoneProps) => { if (!user) { return; } - return createPhoneNumber() + return createPhoneNumber(user, { phoneNumber: phoneField.value }) .then(res => { resourceRef.current = res; onSuccess(); diff --git a/packages/clerk-js/src/ui/components/UserProfile/UsernameForm.tsx b/packages/clerk-js/src/ui/components/UserProfile/UsernameForm.tsx index 44c0023b46..fc4876ded1 100644 --- a/packages/clerk-js/src/ui/components/UserProfile/UsernameForm.tsx +++ b/packages/clerk-js/src/ui/components/UserProfile/UsernameForm.tsx @@ -12,12 +12,7 @@ export const UsernameForm = withCardStateProvider((props: UsernameFormProps) => const { onSuccess, onReset } = props; const { user } = useUser(); - const [updateUsername] = useReverification(() => { - if (!user) { - return Promise.resolve(undefined); - } - return user.update({ username: usernameField.value }); - }); + const [updateUsername] = useReverification(() => user?.update({ username: usernameField.value })); const { userSettings } = useEnvironment(); const card = useCardState(); diff --git a/packages/shared/src/authorization.ts b/packages/shared/src/authorization.ts index 888e73c6ae..489067bbfb 100644 --- a/packages/shared/src/authorization.ts +++ b/packages/shared/src/authorization.ts @@ -94,11 +94,11 @@ const validateReverificationConfig = (config: __experimental_ReverificationConfi return config; }; - if (typeof config === 'string' && isValidVerificationType(config)) { - return convertConfigToObject.bind(null, config); - } + const isValidStringValue = typeof config === 'string' && isValidVerificationType(config); + const isValidObjectValue = + typeof config === 'object' && isValidLevel(config.level) && isValidMaxAge(config.afterMinutes); - if (typeof config === 'object' && isValidLevel(config.level) && isValidMaxAge(config.afterMinutes)) { + if (isValidStringValue || isValidObjectValue) { return convertConfigToObject.bind(null, config); } @@ -145,7 +145,7 @@ const checkStepUpAuthorization: CheckStepUpAuthorization = (params, { __experime * The returned function authorizes if both checks pass, or if at least one passes * when the other is indeterminate. Fails if userId is missing. */ -export const createCheckAuthorization = (options: AuthorizationOptions): CheckAuthorizationWithCustomPermissions => { +const createCheckAuthorization = (options: AuthorizationOptions): CheckAuthorizationWithCustomPermissions => { return (params): boolean => { if (!options.userId) { return false; @@ -161,3 +161,5 @@ export const createCheckAuthorization = (options: AuthorizationOptions): CheckAu return [orgAuthorization, stepUpAuthorization].every(a => a === true); }; }; + +export { createCheckAuthorization, validateReverificationConfig }; diff --git a/packages/shared/src/react/__tests__/useReverification.test.ts b/packages/shared/src/react/__tests__/useReverification.test.ts new file mode 100644 index 0000000000..113ea3ee51 --- /dev/null +++ b/packages/shared/src/react/__tests__/useReverification.test.ts @@ -0,0 +1,61 @@ +import { expectTypeOf } from 'expect-type'; + +import { __experimental_reverificationError } from '../../authorization-errors'; +import type { __experimental_useReverification as useReverification } from '../hooks/useReverification'; + +type ExcludeClerkError = T extends { clerk_error: any } ? never : T; + +const fetcher = async (key: string, options: { id: string }) => { + return { + key, + options, + }; +}; + +const fetcherWithHelper = async (key: string, options: { id: string }) => { + if (key == 'a') { + return __experimental_reverificationError(); + } + + return { + key, + options, + }; +}; + +type Fetcher = typeof fetcherWithHelper; + +describe('useReverification type tests', () => { + it('allow pass through types', () => { + type UseReverificationWithFetcher = typeof useReverification; + type VerifiedFetcher = ReturnType[0]; + expectTypeOf(fetcher).toEqualTypeOf(); + }); + + it('returned callback with clerk error excluded and possible null in case of cancelled flow', () => { + type UseReverificationWithFetcherHelper = typeof useReverification; + type VerifiedFetcherHelper = ReturnType[0]; + + expectTypeOf(fetcherWithHelper).not.toEqualTypeOf(); + + expectTypeOf>().toEqualTypeOf>(); + expectTypeOf>().not.toEqualTypeOf>(); + expectTypeOf>> | null>().toEqualTypeOf< + Awaited> + >(); + }); + + it('returned callback with clerk error excluded but without null since we throw', () => { + type UseReverificationWithFetcherHelperThrow = typeof useReverification< + typeof fetcherWithHelper, + { + throwOnCancel: true; + } + >; + type VerifiedFetcherHelperThrow = ReturnType[0]; + expectTypeOf(fetcherWithHelper).not.toEqualTypeOf(); + expectTypeOf>>>().toEqualTypeOf< + Awaited> + >(); + }); +}); diff --git a/packages/shared/src/react/hooks/useReverification.ts b/packages/shared/src/react/hooks/useReverification.ts index 74f3b87a82..7efb0d844e 100644 --- a/packages/shared/src/react/hooks/useReverification.ts +++ b/packages/shared/src/react/hooks/useReverification.ts @@ -1,36 +1,51 @@ import type { Clerk } from '@clerk/types'; import { useMemo, useRef } from 'react'; +import { validateReverificationConfig } from '../../authorization'; import { __experimental_isReverificationHint, __experimental_reverificationError } from '../../authorization-errors'; -import { ClerkRuntimeError, isClerkAPIResponseError } from '../../error'; +import { ClerkRuntimeError, isClerkAPIResponseError, isClerkRuntimeError } from '../../error'; import { createDeferredPromise } from '../../utils/createDeferredPromise'; import { useClerk } from './useClerk'; import { useSafeLayoutEffect } from './useSafeLayoutEffect'; async function resolveResult( - result: Promise, + result: Promise | T, ): Promise> { - return result - .then(r => { - if (r instanceof Response) { - return r.json(); - } - return r; - }) - .catch(e => { - // Treat fapi assurance as an assurance hint - if (isClerkAPIResponseError(e) && e.errors.find(({ code }) => code == 'session_step_up_verification_required')) { - return __experimental_reverificationError(); - } + try { + const r = await result; + if (r instanceof Response) { + return r.json(); + } + return r; + } catch (e) { + // Treat fapi assurance as an assurance hint + if (isClerkAPIResponseError(e) && e.errors.find(({ code }) => code == 'session_step_up_verification_required')) { + return __experimental_reverificationError(); + } - // rethrow - throw e; - }); + // rethrow + throw e; + } } -function createReverificationHandler(params: { onOpenModal: Clerk['__experimental_openUserVerification'] }) { - function assertReverification Promise>(fetcher: Fetcher): Fetcher { - return (async (...args) => { +type ExcludeClerkError = T extends { clerk_error: any } ? (P extends { throwOnCancel: true } ? never : null) : T; + +type UseReverificationOptions = { + onCancel?: () => void; + throwOnCancel?: boolean; +}; + +type CreateReverificationHandlerParams = UseReverificationOptions & { + openUIComponent: Clerk['__experimental_openUserVerification']; +}; + +function createReverificationHandler(params: CreateReverificationHandlerParams) { + function assertReverification Promise | undefined>( + fetcher: Fetcher, + ): ( + ...args: Parameters + ) => Promise>, Parameters[1]>> { + return (async (...args: Parameters) => { let result = await resolveResult(fetcher(...args)); if (__experimental_isReverificationHint(result)) { @@ -39,11 +54,14 @@ function createReverificationHandler(params: { onOpenModal: Clerk['__experimenta */ const resolvers = createDeferredPromise(); + const isValidMetadata = validateReverificationConfig(result.clerk_error.metadata.reverification); + /** * On success resolve the pending promise * On cancel reject the pending promise */ - params.onOpenModal?.({ + params.openUIComponent?.({ + level: isValidMetadata ? isValidMetadata().level : undefined, afterVerification() { resolvers.resolve(true); }, @@ -56,10 +74,22 @@ function createReverificationHandler(params: { onOpenModal: Clerk['__experimenta }, }); - /** - * Wait until the promise from above have been resolved or rejected - */ - await resolvers.promise; + try { + /** + * Wait until the promise from above have been resolved or rejected + */ + await resolvers.promise; + } catch (e) { + if (params.onCancel) { + params.onCancel(); + } + + if (isClerkRuntimeError(e) && e.code === 'reverification_cancelled' && params.throwOnCancel) { + throw e; + } + + return null; + } /** * After the promise resolved successfully try the original request one more time @@ -68,26 +98,55 @@ function createReverificationHandler(params: { onOpenModal: Clerk['__experimenta } return result; - }) as Fetcher; + }) as ExcludeClerkError>, Parameters[1]>; } return assertReverification; } -function __experimental_useReverification Promise>(fetcher: Fetcher): readonly [Fetcher] { +type UseReverificationResult< + Fetcher extends (...args: any[]) => Promise | undefined, + Options extends UseReverificationOptions, +> = readonly [(...args: Parameters) => Promise>, Options>>]; + +/** + * Receives a fetcher async function and returned an enhanced fetcher that automatically handles the reverification flow + * by displaying a prebuilt UI component when the request from the fetcher fails with a reverification error response. + * + * While the UI component is displayed the promise is still pending. + * On success: the original request is retried one more time. + * On error: + * (1) by default the fetcher will return `null` and the `onCancel` callback will be executed. + * (2) when `throwOnCancel: true` instead of returning null, the returned fetcher will throw a `ClerkRuntimeError`. + * + * @example + * A simple example: + * + * function Hello() { + * const [fetchBalance] = useReverification(()=> fetch('/transfer-balance',{method:"POST"})); + * return + * } + */ +function __experimental_useReverification< + Fetcher extends (...args: any[]) => Promise | undefined, + Options extends UseReverificationOptions, +>(fetcher: Fetcher, options?: Options): UseReverificationResult { const { __experimental_openUserVerification } = useClerk(); const fetcherRef = useRef(fetcher); + const optionsRef = useRef(options); const handleReverification = useMemo(() => { const handler = createReverificationHandler({ - onOpenModal: __experimental_openUserVerification, + openUIComponent: __experimental_openUserVerification, + ...optionsRef.current, })(fetcherRef.current); return [handler] as const; - }, [__experimental_openUserVerification, fetcherRef.current]); + }, [__experimental_openUserVerification, fetcherRef.current, optionsRef.current]); - // Keep fetcher ref in sync + // Keep fetcher and options ref in sync useSafeLayoutEffect(() => { fetcherRef.current = fetcher; + optionsRef.current = options; }); return handleReverification;