From 27bf01855cad7840fca9a966c7bd2d552c797b09 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Thu, 3 Oct 2024 20:43:05 +0300 Subject: [PATCH 01/22] utilities for generating the authorization error objects --- packages/shared/package.json | 1 + packages/shared/src/authorization-errors.ts | 125 ++++++++++++++++++++ 2 files changed, 126 insertions(+) create mode 100644 packages/shared/src/authorization-errors.ts diff --git a/packages/shared/package.json b/packages/shared/package.json index 66bdd857e3b..33bd175c452 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -47,6 +47,7 @@ "dist", "scripts", "authorization", + "authorization-errors", "browser", "callWithRetry", "color", diff --git a/packages/shared/src/authorization-errors.ts b/packages/shared/src/authorization-errors.ts new file mode 100644 index 00000000000..5b9fc78a060 --- /dev/null +++ b/packages/shared/src/authorization-errors.ts @@ -0,0 +1,125 @@ +import type { __experimental_ReverificationConfig } from '@clerk/types'; + +type ClerkError = { + clerk_error: T; +}; + +// type Prettify = { +// [K in keyof T]: T[K]; +// } & {}; + +// const a:Prettify + +export type PermissionMismatchError = ClerkError< + { + type: 'forbidden'; + reason: 'permission-mismatch'; + } & M +>; + +export type RoleMismatchError = ClerkError< + { + type: 'forbidden'; + reason: 'role-mismatch'; + } & M +>; + +export type ReverificationMismatchError = ClerkError< + { + type: 'forbidden'; + reason: 'reverification-mismatch'; + } & M +>; + +export type RReverificationMismatchError = { + clerk_error: { + type: 'forbidden'; + reason: 'reverification-mismatch'; + metadata: { reverification: M }; + }; +}; + +export type PPermissionMismatchError = { + clerk_error: { + type: 'forbidden'; + reason: 'permission-mismatch'; + metadata: { permission: M }; + }; +}; + +export type SignedOutError = ClerkError<{ + type: 'unauthorized'; + reason: 'signed-out'; +}>; + +const signedOut = () => + ({ + clerk_error: { + type: 'unauthorized', + reason: 'signed-out', + }, + }) satisfies SignedOutError; + +const permissionMismatch = (missingPermission: MP) => + ({ + clerk_error: { + type: 'forbidden', + reason: 'permission-mismatch', + metadata: { + permission: missingPermission, + }, + }, + }) satisfies PermissionMismatchError; + +const roleMismatch = (missingRole: MR) => + ({ + clerk_error: { + type: 'forbidden', + reason: 'role-mismatch', + metadata: { + role: missingRole, + }, + }, + }) satisfies RoleMismatchError; + +const reverificationMismatch = (missingConfig: MC) => + ({ + clerk_error: { + type: 'forbidden', + reason: 'reverification-mismatch', + metadata: { + reverification: missingConfig, + }, + }, + }) satisfies ReverificationMismatchError; + +const signedOutResponse = () => + new Response(JSON.stringify(signedOut()), { + status: 401, + }); + +const permissionMismatchResponse = (...args: Parameters) => + new Response(JSON.stringify(permissionMismatch(...args)), { + status: 403, + }); + +const roleMismatchResponse = (...args: Parameters) => + new Response(JSON.stringify(roleMismatch(...args)), { + status: 403, + }); + +const reverificationMismatchResponse = (...args: Parameters) => + new Response(JSON.stringify(reverificationMismatch(...args)), { + status: 403, + }); + +export { + signedOut, + signedOutResponse, + permissionMismatch, + permissionMismatchResponse, + roleMismatch, + roleMismatchResponse, + reverificationMismatch, + reverificationMismatchResponse, +}; From 148be602a618d1d31464b26b8c4376fc5229de5a Mon Sep 17 00:00:00 2001 From: panteliselef Date: Fri, 11 Oct 2024 19:07:47 +0300 Subject: [PATCH 02/22] wip useReverification --- packages/nextjs/src/client-boundary/hooks.ts | 1 + packages/nextjs/src/index.ts | 1 + packages/react/src/hooks/index.ts | 1 + packages/react/src/hooks/useReverification.ts | 199 ++++++++++++++++++ packages/shared/src/authorization.ts | 2 +- 5 files changed, 203 insertions(+), 1 deletion(-) create mode 100644 packages/react/src/hooks/useReverification.ts diff --git a/packages/nextjs/src/client-boundary/hooks.ts b/packages/nextjs/src/client-boundary/hooks.ts index 10fbeb540cc..e708013a63e 100644 --- a/packages/nextjs/src/client-boundary/hooks.ts +++ b/packages/nextjs/src/client-boundary/hooks.ts @@ -11,6 +11,7 @@ export { useSignIn, useSignUp, useUser, + __experimental_useReverification } from '@clerk/clerk-react'; export { diff --git a/packages/nextjs/src/index.ts b/packages/nextjs/src/index.ts index 8a7e7513feb..f0287654503 100644 --- a/packages/nextjs/src/index.ts +++ b/packages/nextjs/src/index.ts @@ -48,6 +48,7 @@ export { useSignIn, useSignUp, useUser, + __experimental_useReverification, } from './client-boundary/hooks'; /** diff --git a/packages/react/src/hooks/index.ts b/packages/react/src/hooks/index.ts index 043d3011038..a0c55187285 100644 --- a/packages/react/src/hooks/index.ts +++ b/packages/react/src/hooks/index.ts @@ -2,6 +2,7 @@ export { useAuth } from './useAuth'; export { useEmailLink } from './useEmailLink'; export { useSignIn } from './useSignIn'; export { useSignUp } from './useSignUp'; +export { __experimental_useReverification } from './useReverification'; export { useClerk, useOrganization, diff --git a/packages/react/src/hooks/useReverification.ts b/packages/react/src/hooks/useReverification.ts new file mode 100644 index 00000000000..3f9025a0aab --- /dev/null +++ b/packages/react/src/hooks/useReverification.ts @@ -0,0 +1,199 @@ +import { reverificationMismatch } from '@clerk/shared/authorization-errors'; +import { ClerkRuntimeError, isClerkAPIResponseError, isClerkRuntimeError } from '@clerk/shared/error'; +import { useClerk } from '@clerk/shared/react'; +import type { __experimental_ReverificationConfig, Clerk } from '@clerk/types'; +import { useMemo } from 'react'; + +interface PromiseWithResolvers { + promise: Promise; + resolve: (value: T | PromiseLike) => void; + reject: (reason?: any) => void; +} + +// type AssuranceHint = { +// // clerk_error: { +// // type: 'forbidden'; +// // reason: 'assurance'; +// // }; +// clerk_error: { +// type: 'forbidden'; +// reason: 'assurance'; +// metadata: { +// level: __experimental_SessionVerificationLevel; +// maxAge: __experimental_SessionVerificationMaxAgeMinutes; +// }; +// }; +// }; + +// function generateAssuranceHint>(metadata?: Metadata): AssuranceHint; +// function generateAssuranceHint>(metadata: Metadata, as: 'response'): Response; +// function generateAssuranceHint>( +// metadata?: Metadata, +// as?: 'response', +// ): AssuranceHint | Response { +// //@ts-ignore +// const obj = { +// clerk_error: { +// type: 'forbidden', +// reason: 'assurance', +// metadata, +// }, +// } as AssuranceHint; +// +// if (as === 'response') { +// return new Response(JSON.stringify(obj), { status: 403 }); +// } +// +// return obj; +// } + +async function resolveResult(result: Promise): 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 reverificationMismatch({} as __experimental_ReverificationConfig); + } + + // rethrow + throw e; + }); +} + +/** + * Polyfill for Promise.withResolvers() + */ +function customPromiseWithResolves() { + let resolve: PromiseWithResolvers['resolve']; + let reject: PromiseWithResolvers['reject']; + + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + + return { + promise, + // @ts-ignore + resolve, + // @ts-ignore + reject, + }; +} + +// Utility type to exclude anything that contains "clerk_error" + +type RemovePropertyIfExists = K extends keyof T ? Omit : T; +// type ExcludeClerkError = T extends { clerk_error: any } ? never : T; +type ExcludeClerkError = Prettify>; + +// Applying the utility to type B +type WithoutClerkError = ExcludeClerkError; + +// type WithoutAssuranceHint = Exclude; + +const isReverificationHint = (result: any): result is ReturnType => { + return ( + result && + typeof result === 'object' && + 'clerk_error' in result && + result.clerk_error?.type === 'forbidden' && + result.clerk_error?.reason === 'reverification-mismatch' + ); +}; + +type InferParameters = T extends (...args: infer P) => any ? P : never; +type InferReturnType = T extends (...args: any[]) => infer R ? R : never; + +// ) => InferReturnType>(handler: H) => +// (...args: InferParameters): InferReturnType => { + +type Prettify = { + [K in keyof T]: T[K]; +} & {}; + +function createAssuranceHandler(params: { onOpenModal: Clerk['__experimental_openUserVerification'] }) { + // function assertAssurance(fetcher: (...args: Args[]) => Promise): (...args: Args[]) => Promise { + // function assertAssurance) => Promise>>(fetcher: H): H { + function assertAssurance) => InferReturnType>( + fetcher: H, + ): (...args: InferParameters) => Promise>>> { + // @ts-ignore + const f = async (...args) => { + // @ts-ignore + let result = await resolveResult(fetcher(...args)); + + try { + if (isReverificationHint(result)) { + /** + * Create a promise + */ + const resolvers = customPromiseWithResolves(); + + /** + * On success resolve the pending promise + * On cancel reject the pending promise + */ + params.onOpenModal?.({ + afterVerification() { + resolvers.resolve(true); + }, + afterVerificationCancelled() { + resolvers.reject( + new ClerkRuntimeError('User cancelled attempted verification', { + code: 'assurance_cancelled', + }), + ); + }, + }); + + /** + * Wait until the promise from above have been resolved or rejected + */ + await resolvers.promise; + + /** + * After the promise resolved successfully try the original request one more time + */ + // @ts-ignore + result = await resolveResult(fetcher(...args)); + } + } catch (e) { + if (!isClerkRuntimeError(e)) { + throw e; + } + } + + return result as InferReturnType; + }; + + // @ts-ignore + return f; + } + + return assertAssurance; +} + +const __experimental_useReverification = (): { + handleReverification: ReturnType; +} => { + const { __experimental_openUserVerification } = useClerk(); + const handleReverification = useMemo( + () => + createAssuranceHandler({ + onOpenModal: __experimental_openUserVerification, + }), + [__experimental_openUserVerification], + ); + return { + handleReverification, + }; +}; + +export { __experimental_useReverification }; diff --git a/packages/shared/src/authorization.ts b/packages/shared/src/authorization.ts index d50251a6ab0..d13d69b0cc0 100644 --- a/packages/shared/src/authorization.ts +++ b/packages/shared/src/authorization.ts @@ -37,7 +37,7 @@ const TYPES_TO_OBJECTS: TypesToConfig = { level: 'multiFactor', }, strict: { - afterMinutes: 10, + afterMinutes: 1, level: 'secondFactor', }, moderate: { From 5290f20417cb88953c6f1440dd6129241bc5f021 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Tue, 15 Oct 2024 22:29:17 +0300 Subject: [PATCH 03/22] fix setActive inside transitions for next >= 14 --- .../use-after-verification.ts | 21 ++++------------ .../src/app-router/client/ClerkProvider.tsx | 24 ++++++++++--------- packages/react/src/hooks/useReverification.ts | 4 +++- packages/shared/src/authorization.ts | 2 +- 4 files changed, 22 insertions(+), 29 deletions(-) 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 index e3e1d027779..07911e5e388 100644 --- a/packages/clerk-js/src/ui/components/UserVerification/use-after-verification.ts +++ b/packages/clerk-js/src/ui/components/UserVerification/use-after-verification.ts @@ -9,25 +9,12 @@ import { useRouter } from '../../router'; import { useUserVerificationSession } from './useUserVerificationSession'; const useAfterVerification = () => { - const { afterVerification, routing } = useUserVerification(); + const { afterVerification } = useUserVerification(); const supportEmail = useSupportEmail(); const { setActive } = useClerk(); const { setCache } = useUserVerificationSession(); const { navigate } = useRouter(); - const beforeEmit = useCallback(async () => { - if (routing === 'virtual') { - /** - * Moves the code below into the task queue and ensures that client and fva has been updated correctly before triggering the events - */ - setTimeout(() => { - afterVerification?.(); - }, 0); - } else { - throw 'afterVerification is only triggered in modals'; - } - }, [afterVerification, routing]); - const handleVerificationResponse = useCallback( async (sessionVerification: __experimental_SessionVerificationResource) => { setCache({ @@ -39,14 +26,16 @@ const useAfterVerification = () => { }); switch (sessionVerification.status) { case 'complete': - return setActive({ session: sessionVerification.session.id, beforeEmit }); + await setActive({ session: sessionVerification.session.id }); + return afterVerification?.(); + case 'needs_second_factor': return navigate('./factor-two'); default: return console.error(clerkInvalidFAPIResponse(sessionVerification.status, supportEmail)); } }, - [beforeEmit, navigate, setActive, supportEmail], + [navigate, setActive, supportEmail], ); return { diff --git a/packages/nextjs/src/app-router/client/ClerkProvider.tsx b/packages/nextjs/src/app-router/client/ClerkProvider.tsx index af71badee47..f6537541cba 100644 --- a/packages/nextjs/src/app-router/client/ClerkProvider.tsx +++ b/packages/nextjs/src/app-router/client/ClerkProvider.tsx @@ -91,20 +91,22 @@ export const ClientClerkProvider = (props: NextClerkProviderProps) => { */ return new Promise(res => { window.__clerk_internal_invalidateCachePromise = res; - startTransition(() => { - if (window.next?.version && typeof window.next.version === 'string' && window.next.version.startsWith('13')) { + + // NOTE: the following code will allow `useReverification()` to work properly when `handlerReverification` is called inside `startTransition` + if (window.next?.version && typeof window.next.version === 'string' && window.next.version.startsWith('13')) { + startTransition(() => { router.refresh(); - } else { - void invalidateCacheAction(); - } - }); + }); + } else { + void invalidateCacheAction().then(() => res()); + } }); - }; - window.__unstable__onAfterSetActive = () => { - if (__unstable_invokeMiddlewareOnAuthStateChange) { - return router.refresh(); - } + window.__unstable__onAfterSetActive = () => { + if (__unstable_invokeMiddlewareOnAuthStateChange) { + return router.refresh(); + } + }; }; }, []); diff --git a/packages/react/src/hooks/useReverification.ts b/packages/react/src/hooks/useReverification.ts index 3f9025a0aab..b7a787f0fc6 100644 --- a/packages/react/src/hooks/useReverification.ts +++ b/packages/react/src/hooks/useReverification.ts @@ -94,7 +94,7 @@ type RemovePropertyIfExists = K extends keyof T ? Omit = Prettify>; // Applying the utility to type B -type WithoutClerkError = ExcludeClerkError; +// type WithoutClerkError = ExcludeClerkError; // type WithoutAssuranceHint = Exclude; @@ -116,6 +116,7 @@ type InferReturnType = T extends (...args: any[]) => infer R ? R : never; type Prettify = { [K in keyof T]: T[K]; + // eslint-disable-next-line @typescript-eslint/ban-types } & {}; function createAssuranceHandler(params: { onOpenModal: Clerk['__experimental_openUserVerification'] }) { @@ -142,6 +143,7 @@ function createAssuranceHandler(params: { onOpenModal: Clerk['__experimental_ope */ params.onOpenModal?.({ afterVerification() { + console.log('useReverification', 'afterVerification'); resolvers.resolve(true); }, afterVerificationCancelled() { diff --git a/packages/shared/src/authorization.ts b/packages/shared/src/authorization.ts index d13d69b0cc0..d50251a6ab0 100644 --- a/packages/shared/src/authorization.ts +++ b/packages/shared/src/authorization.ts @@ -37,7 +37,7 @@ const TYPES_TO_OBJECTS: TypesToConfig = { level: 'multiFactor', }, strict: { - afterMinutes: 1, + afterMinutes: 10, level: 'secondFactor', }, moderate: { From 4467912c504dd0428bfac3070fd18f6139675777 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Fri, 18 Oct 2024 14:01:24 +0300 Subject: [PATCH 04/22] only keep re-verification errors --- packages/shared/src/authorization-errors.ts | 99 +-------------------- 1 file changed, 2 insertions(+), 97 deletions(-) diff --git a/packages/shared/src/authorization-errors.ts b/packages/shared/src/authorization-errors.ts index 5b9fc78a060..e4995c9c552 100644 --- a/packages/shared/src/authorization-errors.ts +++ b/packages/shared/src/authorization-errors.ts @@ -4,84 +4,13 @@ type ClerkError = { clerk_error: T; }; -// type Prettify = { -// [K in keyof T]: T[K]; -// } & {}; - -// const a:Prettify - -export type PermissionMismatchError = ClerkError< - { - type: 'forbidden'; - reason: 'permission-mismatch'; - } & M ->; - -export type RoleMismatchError = ClerkError< - { - type: 'forbidden'; - reason: 'role-mismatch'; - } & M ->; - -export type ReverificationMismatchError = ClerkError< +type ReverificationMismatchError = ClerkError< { type: 'forbidden'; reason: 'reverification-mismatch'; } & M >; -export type RReverificationMismatchError = { - clerk_error: { - type: 'forbidden'; - reason: 'reverification-mismatch'; - metadata: { reverification: M }; - }; -}; - -export type PPermissionMismatchError = { - clerk_error: { - type: 'forbidden'; - reason: 'permission-mismatch'; - metadata: { permission: M }; - }; -}; - -export type SignedOutError = ClerkError<{ - type: 'unauthorized'; - reason: 'signed-out'; -}>; - -const signedOut = () => - ({ - clerk_error: { - type: 'unauthorized', - reason: 'signed-out', - }, - }) satisfies SignedOutError; - -const permissionMismatch = (missingPermission: MP) => - ({ - clerk_error: { - type: 'forbidden', - reason: 'permission-mismatch', - metadata: { - permission: missingPermission, - }, - }, - }) satisfies PermissionMismatchError; - -const roleMismatch = (missingRole: MR) => - ({ - clerk_error: { - type: 'forbidden', - reason: 'role-mismatch', - metadata: { - role: missingRole, - }, - }, - }) satisfies RoleMismatchError; - const reverificationMismatch = (missingConfig: MC) => ({ clerk_error: { @@ -93,33 +22,9 @@ const reverificationMismatch = ( }, }) satisfies ReverificationMismatchError; -const signedOutResponse = () => - new Response(JSON.stringify(signedOut()), { - status: 401, - }); - -const permissionMismatchResponse = (...args: Parameters) => - new Response(JSON.stringify(permissionMismatch(...args)), { - status: 403, - }); - -const roleMismatchResponse = (...args: Parameters) => - new Response(JSON.stringify(roleMismatch(...args)), { - status: 403, - }); - const reverificationMismatchResponse = (...args: Parameters) => new Response(JSON.stringify(reverificationMismatch(...args)), { status: 403, }); -export { - signedOut, - signedOutResponse, - permissionMismatch, - permissionMismatchResponse, - roleMismatch, - roleMismatchResponse, - reverificationMismatch, - reverificationMismatchResponse, -}; +export { reverificationMismatch, reverificationMismatchResponse }; From e1c3987a57fab35bf9b1829a9a1e743c696ff7d7 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Fri, 18 Oct 2024 15:10:55 +0300 Subject: [PATCH 05/22] cleanup useReverification --- packages/react/src/hooks/useReverification.ts | 67 ++----------------- 1 file changed, 6 insertions(+), 61 deletions(-) diff --git a/packages/react/src/hooks/useReverification.ts b/packages/react/src/hooks/useReverification.ts index b7a787f0fc6..955ef67b5a6 100644 --- a/packages/react/src/hooks/useReverification.ts +++ b/packages/react/src/hooks/useReverification.ts @@ -10,43 +10,6 @@ interface PromiseWithResolvers { reject: (reason?: any) => void; } -// type AssuranceHint = { -// // clerk_error: { -// // type: 'forbidden'; -// // reason: 'assurance'; -// // }; -// clerk_error: { -// type: 'forbidden'; -// reason: 'assurance'; -// metadata: { -// level: __experimental_SessionVerificationLevel; -// maxAge: __experimental_SessionVerificationMaxAgeMinutes; -// }; -// }; -// }; - -// function generateAssuranceHint>(metadata?: Metadata): AssuranceHint; -// function generateAssuranceHint>(metadata: Metadata, as: 'response'): Response; -// function generateAssuranceHint>( -// metadata?: Metadata, -// as?: 'response', -// ): AssuranceHint | Response { -// //@ts-ignore -// const obj = { -// clerk_error: { -// type: 'forbidden', -// reason: 'assurance', -// metadata, -// }, -// } as AssuranceHint; -// -// if (as === 'response') { -// return new Response(JSON.stringify(obj), { status: 403 }); -// } -// -// return obj; -// } - async function resolveResult(result: Promise): Promise> { return result .then(r => { @@ -88,15 +51,7 @@ function customPromiseWithResolves() { } // Utility type to exclude anything that contains "clerk_error" - -type RemovePropertyIfExists = K extends keyof T ? Omit : T; -// type ExcludeClerkError = T extends { clerk_error: any } ? never : T; -type ExcludeClerkError = Prettify>; - -// Applying the utility to type B -// type WithoutClerkError = ExcludeClerkError; - -// type WithoutAssuranceHint = Exclude; +type ExcludeClerkError = T extends { clerk_error: any } ? never : T; const isReverificationHint = (result: any): result is ReturnType => { return ( @@ -111,18 +66,8 @@ const isReverificationHint = (result: any): result is ReturnType = T extends (...args: infer P) => any ? P : never; type InferReturnType = T extends (...args: any[]) => infer R ? R : never; -// ) => InferReturnType>(handler: H) => -// (...args: InferParameters): InferReturnType => { - -type Prettify = { - [K in keyof T]: T[K]; - // eslint-disable-next-line @typescript-eslint/ban-types -} & {}; - -function createAssuranceHandler(params: { onOpenModal: Clerk['__experimental_openUserVerification'] }) { - // function assertAssurance(fetcher: (...args: Args[]) => Promise): (...args: Args[]) => Promise { - // function assertAssurance) => Promise>>(fetcher: H): H { - function assertAssurance) => InferReturnType>( +function createReverificationHandler(params: { onOpenModal: Clerk['__experimental_openUserVerification'] }) { + function assertReverification) => InferReturnType>( fetcher: H, ): (...args: InferParameters) => Promise>>> { // @ts-ignore @@ -179,16 +124,16 @@ function createAssuranceHandler(params: { onOpenModal: Clerk['__experimental_ope return f; } - return assertAssurance; + return assertReverification; } const __experimental_useReverification = (): { - handleReverification: ReturnType; + handleReverification: ReturnType; } => { const { __experimental_openUserVerification } = useClerk(); const handleReverification = useMemo( () => - createAssuranceHandler({ + createReverificationHandler({ onOpenModal: __experimental_openUserVerification, }), [__experimental_openUserVerification], From 993ceaf223adb8e3350754938e89eb126c1054be Mon Sep 17 00:00:00 2001 From: panteliselef Date: Fri, 18 Oct 2024 15:37:58 +0300 Subject: [PATCH 06/22] add changesets --- .changeset/clever-lions-marry.md | 5 +++++ .changeset/five-insects-grin.md | 34 ++++++++++++++++++++++++++++++ .changeset/ninety-rabbits-itch.md | 7 ++++++ .changeset/serious-poems-retire.md | 5 +++++ 4 files changed, 51 insertions(+) create mode 100644 .changeset/clever-lions-marry.md create mode 100644 .changeset/five-insects-grin.md create mode 100644 .changeset/ninety-rabbits-itch.md create mode 100644 .changeset/serious-poems-retire.md diff --git a/.changeset/clever-lions-marry.md b/.changeset/clever-lions-marry.md new file mode 100644 index 00000000000..755d063eadb --- /dev/null +++ b/.changeset/clever-lions-marry.md @@ -0,0 +1,5 @@ +--- +"@clerk/nextjs": minor +--- + +Bug fix: For next>=14 applications resolve `__unstable__onBeforeSetActive` once `invalidateCacheAction` resolves. diff --git a/.changeset/five-insects-grin.md b/.changeset/five-insects-grin.md new file mode 100644 index 00000000000..cec9bd59a03 --- /dev/null +++ b/.changeset/five-insects-grin.md @@ -0,0 +1,34 @@ +--- +"@clerk/nextjs": minor +"@clerk/clerk-react": minor +--- + +Introduce a new experimental hook called `useReverification` that makes it easy to handle reverification errors. +It returns a high order function (HOF) and allows developers to wrap any function that triggers a fetch request which might fail due to a user's session verification status. +When such error is returned, the recommended UX is to offer a way to the user to recover by re-verifying their credentials. +This helper will automatically handle this flow in the developer's behalf, by displaying a modal the end-user can interact with. +Upon completion, the original request that previously failed, will be retried (only once). + +Example with clerk-js methods. +```tsx +function DeleteAccount() { + const { handleReverification } = useReverification(); + const { user } = useUser(); + + const deleteUserAccount = async () => { + if (!user) return; + const verifyAndDelete = handleReverification(user.delete); + return verifyAndDelete(); + }; + + return <> + + +} + +``` diff --git a/.changeset/ninety-rabbits-itch.md b/.changeset/ninety-rabbits-itch.md new file mode 100644 index 00000000000..bed9e085519 --- /dev/null +++ b/.changeset/ninety-rabbits-itch.md @@ -0,0 +1,7 @@ +--- +"@clerk/shared": minor +--- + +Introduce reverification error helpers. +- `reverificationMismatch` returns the error as an object which can later be used as a return value from a React Server Action. +- `reverificationMismatchResponse` returns a Response with the above object serialized. It can be used in any Backend Javascript frameworks that supports `Response`. diff --git a/.changeset/serious-poems-retire.md b/.changeset/serious-poems-retire.md new file mode 100644 index 00000000000..926b7544362 --- /dev/null +++ b/.changeset/serious-poems-retire.md @@ -0,0 +1,5 @@ +--- +"@clerk/clerk-js": patch +--- + +Chore: Replace beforeEmit with an explicity call after `setActive`, inside the experimental UserVerification. From 43fac9c7423493065dd421fdcc53de07acf53f7f Mon Sep 17 00:00:00 2001 From: panteliselef Date: Fri, 18 Oct 2024 16:08:28 +0300 Subject: [PATCH 07/22] fix formatting --- packages/nextjs/src/client-boundary/hooks.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/nextjs/src/client-boundary/hooks.ts b/packages/nextjs/src/client-boundary/hooks.ts index e708013a63e..c3acdef29e1 100644 --- a/packages/nextjs/src/client-boundary/hooks.ts +++ b/packages/nextjs/src/client-boundary/hooks.ts @@ -11,7 +11,7 @@ export { useSignIn, useSignUp, useUser, - __experimental_useReverification + __experimental_useReverification, } from '@clerk/clerk-react'; export { From b33981600f1b637e0ae065f000304f6419dd390a Mon Sep 17 00:00:00 2001 From: panteliselef Date: Fri, 18 Oct 2024 20:35:46 +0300 Subject: [PATCH 08/22] update snapshot for clerk-extension --- .../src/__tests__/__snapshots__/exports.test.ts.snap | 1 + 1 file changed, 1 insertion(+) 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..cf8c94d6bcd 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_useReverification", "useAuth", "useClerk", "useEmailLink", From a574620071871641c1687ec2e28400bbc0b0cfda Mon Sep 17 00:00:00 2001 From: panteliselef Date: Fri, 18 Oct 2024 23:43:50 +0300 Subject: [PATCH 09/22] update e2e tests for reverification --- integration/playwright.config.ts | 2 +- integration/presets/envs.ts | 8 + integration/presets/longRunningApps.ts | 1 + integration/presets/next.ts | 4 +- .../templates/next-app-router/next.config.js | 3 + .../action-with-use-reverification/page.tsx | 31 ++++ .../src/app/(reverification)/actions.ts | 27 ++++ .../app/(reverification)/button-action.tsx | 23 +++ .../requires-re-verification/page.tsx | 9 ++ integration/testUtils/index.ts | 2 + .../testUtils/userVerificationPageObject.ts | 27 ++++ integration/tests/reverification.test.ts | 137 ++++++++++++++++++ 12 files changed, 272 insertions(+), 2 deletions(-) create mode 100644 integration/templates/next-app-router/src/app/(reverification)/action-with-use-reverification/page.tsx create mode 100644 integration/templates/next-app-router/src/app/(reverification)/actions.ts create mode 100644 integration/templates/next-app-router/src/app/(reverification)/button-action.tsx create mode 100644 integration/templates/next-app-router/src/app/(reverification)/requires-re-verification/page.tsx create mode 100644 integration/testUtils/userVerificationPageObject.ts create mode 100644 integration/tests/reverification.test.ts diff --git a/integration/playwright.config.ts b/integration/playwright.config.ts index 583757c9d56..4680f98b754 100644 --- a/integration/playwright.config.ts +++ b/integration/playwright.config.ts @@ -13,7 +13,7 @@ export const common: PlaywrightTestConfig = { fullyParallel: true, forbidOnly: !!process.env.CI, retries: process.env.CI ? 2 : 0, - timeout: 90000, + timeout: 180_000, maxFailures: process.env.CI ? 1 : undefined, workers: process.env.CI ? '50%' : '70%', reporter: process.env.CI ? 'line' : 'list', diff --git a/integration/presets/envs.ts b/integration/presets/envs.ts index 200df9927d6..7596fba3fde 100644 --- a/integration/presets/envs.ts +++ b/integration/presets/envs.ts @@ -51,6 +51,13 @@ const withCustomRoles = base .setEnvVariable('private', 'CLERK_SECRET_KEY', instanceKeys.get('with-custom-roles').sk) .setEnvVariable('public', 'CLERK_PUBLISHABLE_KEY', instanceKeys.get('with-custom-roles').pk); +const withReverification = base + .clone() + .setId('withReverification') + .setEnvVariable('private', 'CLERK_SECRET_KEY', instanceKeys.get('with-reverification').sk) + .setEnvVariable('public', 'CLERK_PUBLISHABLE_KEY', instanceKeys.get('with-reverification').pk) + .setEnvVariable('private', 'CLERK_ENCRYPTION_KEY', constants.E2E_CLERK_ENCRYPTION_KEY || 'a-key'); + const withEmailCodesQuickstart = withEmailCodes .clone() .setEnvVariable('public', 'CLERK_SIGN_IN_URL', '') @@ -100,6 +107,7 @@ export const envs = { withEmailCodes_destroy_client, withEmailLinks, withCustomRoles, + withReverification, withEmailCodesQuickstart, withAPCore1ClerkLatest, withAPCore1ClerkV4, diff --git a/integration/presets/longRunningApps.ts b/integration/presets/longRunningApps.ts index eee5510608a..9dd6c4a0cd6 100644 --- a/integration/presets/longRunningApps.ts +++ b/integration/presets/longRunningApps.ts @@ -29,6 +29,7 @@ export const createLongRunningApps = () => { env: envs.withEmailCodes_destroy_client, }, { id: 'next.appRouter.withCustomRoles', config: next.appRouter, env: envs.withCustomRoles }, + { id: 'next.appRouter.withReverification', config: next.appRouter, env: envs.withReverification }, { id: 'quickstart.next.appRouter', config: next.appRouterQuickstart, env: envs.withEmailCodesQuickstart }, { id: 'elements.next.appRouter', config: elements.nextAppRouter, env: envs.withEmailCodes }, { id: 'astro.node.withCustomRoles', config: astro.node, env: envs.withCustomRoles }, diff --git a/integration/presets/next.ts b/integration/presets/next.ts index 68366b7ffa4..20dba1e613c 100644 --- a/integration/presets/next.ts +++ b/integration/presets/next.ts @@ -3,6 +3,7 @@ import { applicationConfig } from '../models/applicationConfig.js'; import { templates } from '../templates/index.js'; const clerkNextjsLocal = `file:${process.cwd()}/packages/nextjs`; +const clerkSharedLocal = `file:${process.cwd()}/packages/shared`; const appRouter = applicationConfig() .setName('next-app-router') .useTemplate(templates['next-app-router']) @@ -14,7 +15,8 @@ const appRouter = applicationConfig() .addDependency('next', constants.E2E_NEXTJS_VERSION) .addDependency('react', constants.E2E_REACT_VERSION) .addDependency('react-dom', constants.E2E_REACT_DOM_VERSION) - .addDependency('@clerk/nextjs', constants.E2E_CLERK_VERSION || clerkNextjsLocal); + .addDependency('@clerk/nextjs', constants.E2E_CLERK_VERSION || clerkNextjsLocal) + .addDependency('@clerk/shared', clerkSharedLocal); const appRouterTurbo = appRouter .clone() diff --git a/integration/templates/next-app-router/next.config.js b/integration/templates/next-app-router/next.config.js index 954fac0d40b..35b183b6064 100644 --- a/integration/templates/next-app-router/next.config.js +++ b/integration/templates/next-app-router/next.config.js @@ -3,6 +3,9 @@ const nextConfig = { eslint: { ignoreDuringBuilds: true, }, + experimental: { + serverActions: true, + }, }; module.exports = nextConfig; diff --git a/integration/templates/next-app-router/src/app/(reverification)/action-with-use-reverification/page.tsx b/integration/templates/next-app-router/src/app/(reverification)/action-with-use-reverification/page.tsx new file mode 100644 index 00000000000..ed9ffb7161b --- /dev/null +++ b/integration/templates/next-app-router/src/app/(reverification)/action-with-use-reverification/page.tsx @@ -0,0 +1,31 @@ +'use client'; +import { useState, useTransition } from 'react'; +import { __experimental_useReverification as useReverification } from '@clerk/nextjs'; +import { logUserIdActionReverification } from '@/app/(reverification)/actions'; + +function Page() { + const { handleReverification } = useReverification(); + const [pending, startTransition] = useTransition(); + const [res, setRes] = useState(null); + + return ( + <> + +
{JSON.stringify(res)}
+ + ); +} + +export default Page; diff --git a/integration/templates/next-app-router/src/app/(reverification)/actions.ts b/integration/templates/next-app-router/src/app/(reverification)/actions.ts new file mode 100644 index 00000000000..8be736e3965 --- /dev/null +++ b/integration/templates/next-app-router/src/app/(reverification)/actions.ts @@ -0,0 +1,27 @@ +'use server'; + +import { auth } from '@clerk/nextjs/server'; +import { reverificationMismatch } from '@clerk/shared/authorization-errors'; + +const logUserIdActionReverification = async () => { + const { userId } = auth().protect(); + + const config = { + level: 'secondFactor', + afterMinutes: 1, + } as const; + + if ( + !auth().has({ + __experimental_reverification: config, + }) + ) { + return reverificationMismatch(config); + } + + return { + userId, + }; +}; + +export { logUserIdActionReverification }; diff --git a/integration/templates/next-app-router/src/app/(reverification)/button-action.tsx b/integration/templates/next-app-router/src/app/(reverification)/button-action.tsx new file mode 100644 index 00000000000..cbdb0ce0135 --- /dev/null +++ b/integration/templates/next-app-router/src/app/(reverification)/button-action.tsx @@ -0,0 +1,23 @@ +'use client'; +import { useState, useTransition } from 'react'; + +export function ButtonAction({ action }: { action: () => Promise }) { + const [pending, startTransition] = useTransition(); + const [res, setRes] = useState(null); + + return ( + <> + +
{JSON.stringify(res)}
+ + ); +} diff --git a/integration/templates/next-app-router/src/app/(reverification)/requires-re-verification/page.tsx b/integration/templates/next-app-router/src/app/(reverification)/requires-re-verification/page.tsx new file mode 100644 index 00000000000..1bf186e8a25 --- /dev/null +++ b/integration/templates/next-app-router/src/app/(reverification)/requires-re-verification/page.tsx @@ -0,0 +1,9 @@ +import { logUserIdActionReverification } from '../actions'; +import { ButtonAction } from '../button-action'; + +function Page() { + // @ts-ignore + return ; +} + +export default Page; diff --git a/integration/testUtils/index.ts b/integration/testUtils/index.ts index 21058006142..99503c38855 100644 --- a/integration/testUtils/index.ts +++ b/integration/testUtils/index.ts @@ -14,6 +14,7 @@ import { createUserButtonPageObject } from './userButtonPageObject'; import { createUserProfileComponentPageObject } from './userProfilePageObject'; import type { FakeOrganization, FakeUser } from './usersService'; import { createUserService } from './usersService'; +import { createUserVerificationComponentPageObject } from './userVerificationPageObject'; export type { FakeUser, FakeOrganization }; const createClerkClient = (app: Application) => { @@ -91,6 +92,7 @@ export const createTestUtils = < userProfile: createUserProfileComponentPageObject(testArgs), organizationSwitcher: createOrganizationSwitcherComponentPageObject(testArgs), userButton: createUserButtonPageObject(testArgs), + userVerification: createUserVerificationComponentPageObject(testArgs), expect: createExpectPageObject(testArgs), clerk: createClerkUtils(testArgs), }; diff --git a/integration/testUtils/userVerificationPageObject.ts b/integration/testUtils/userVerificationPageObject.ts new file mode 100644 index 00000000000..d54fe1a7ea8 --- /dev/null +++ b/integration/testUtils/userVerificationPageObject.ts @@ -0,0 +1,27 @@ +import type { Browser, BrowserContext } from '@playwright/test'; + +import type { createAppPageObject } from './appPageObject'; +import { common } from './commonPageObject'; + +export type EnchancedPage = ReturnType; +export type TestArgs = { page: EnchancedPage; context: BrowserContext; browser: Browser }; + +export const createUserVerificationComponentPageObject = (testArgs: TestArgs) => { + const { page } = testArgs; + const self = { + ...common(testArgs), + waitForMounted: (selector = '.cl-userVerification-root') => { + return page.waitForSelector(selector, { state: 'attached' }); + }, + getUseAnotherMethodLink: () => { + return page.getByRole('link', { name: /use another method/i }); + }, + getAltMethodsEmailCodeButton: () => { + return page.getByRole('button', { name: /email code to/i }); + }, + getAltMethodsEmailLinkButton: () => { + return page.getByRole('button', { name: /email link to/i }); + }, + }; + return self; +}; diff --git a/integration/tests/reverification.test.ts b/integration/tests/reverification.test.ts new file mode 100644 index 00000000000..1073250f2d2 --- /dev/null +++ b/integration/tests/reverification.test.ts @@ -0,0 +1,137 @@ +import type { OrganizationMembershipRole } from '@clerk/backend'; +import { expect, test } from '@playwright/test'; + +import { appConfigs } from '../presets'; +import type { FakeOrganization, FakeUser } from '../testUtils'; +import { createTestUtils, testAgainstRunningApps } from '../testUtils'; + +const utils = [ + 'action', + // , 'route' +]; +const capitalize = (type: string) => type[0].toUpperCase() + type.slice(1); +testAgainstRunningApps({ withEnv: [appConfigs.envs.withReverification] })( + '@nextjs require re-verification', + ({ app }) => { + test.describe.configure({ mode: 'parallel' }); + + let fakeAdmin: FakeUser; + let fakeViewer: FakeUser; + let fakeOrganization: FakeOrganization; + + test.beforeAll(async () => { + const m = createTestUtils({ app }); + fakeAdmin = m.services.users.createFakeUser(); + const admin = await m.services.users.createBapiUser(fakeAdmin); + fakeOrganization = await m.services.users.createFakeOrganization(admin.id); + fakeViewer = m.services.users.createFakeUser(); + const viewer = await m.services.users.createBapiUser(fakeViewer); + await m.services.clerk.organizations.createOrganizationMembership({ + organizationId: fakeOrganization.organization.id, + role: 'org:viewer' as OrganizationMembershipRole, + userId: viewer.id, + }); + }); + + test.afterAll(async () => { + await fakeOrganization.delete(); + await fakeViewer.deleteIfExists(); + await fakeAdmin.deleteIfExists(); + await app.teardown(); + }); + + utils.forEach(type => { + test(`reverification error from ${capitalize(type)}`, async ({ page, context }) => { + test.setTimeout(180_000); + const u = createTestUtils({ app, page, context }); + + await u.po.signIn.goTo(); + await u.po.signIn.waitForMounted(); + await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeAdmin.email, password: fakeAdmin.password }); + await u.po.expect.toBeSignedIn(); + + await u.po.organizationSwitcher.goTo(); + await u.po.organizationSwitcher.waitForMounted(); + await u.po.organizationSwitcher.waitForAnOrganizationToSelected(); + + const startTime = performance.mark('startTime'); + + await u.page.goToRelative(`/requires-re-verification`); + await u.page.getByRole('button', { name: /LogUserId/i }).click(); + await expect(u.page.getByText(/\{\s*"userId"\s*:\s*"user_[^"]+"\s*\}/i)).toBeVisible(); + + performance.mark('endTime'); + const delay = performance.measure('dwa', startTime, 'endTime'); + + const total = 1000 * 120 - delay.duration; + await page.waitForTimeout(total / 3); + await page.waitForTimeout(total / 3); + await u.po.userProfile.goTo(); + await page.waitForTimeout(total / 3); + await u.page.goToRelative(`/requires-re-verification`); + await u.page.getByRole('button', { name: /LogUserId/i }).click(); + await expect( + u.page.getByText( + /\{\s*"clerk_error"\s*:\s*\{\s*"type"\s*:\s*"forbidden"\s*,\s*"reason"\s*:\s*"reverification-mismatch"\s*,\s*"metadata"\s*:\s*\{\s*"reverification"\s*:\s*\{\s*"level"\s*:\s*"secondFactor"\s*,\s*"afterMinutes"\s*:\s*1\s*\}\s*\}\s*\}\s*\}/i, + ), + ).toBeVisible(); + }); + + test(`reverification recovery from ${capitalize(type)}`, async ({ page, context }) => { + test.setTimeout(180_000); + const u = createTestUtils({ app, page, context }); + + await u.po.signIn.goTo(); + await u.po.signIn.waitForMounted(); + await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeAdmin.email, password: fakeAdmin.password }); + await u.po.expect.toBeSignedIn(); + + await u.po.organizationSwitcher.goTo(); + await u.po.organizationSwitcher.waitForMounted(); + await u.po.organizationSwitcher.waitForAnOrganizationToSelected(); + + const startTime = performance.mark('startTime'); + + await u.page.goToRelative(`/requires-re-verification`); + await u.page.getByRole('button', { name: /LogUserId/i }).click(); + await expect(u.page.getByText(/\{\s*"userId"\s*:\s*"user_[^"]+"\s*\}/i)).toBeVisible(); + + performance.mark('endTime'); + const delay = performance.measure('dwa', startTime, 'endTime'); + + const total = 1000 * 120 - delay.duration; + await page.waitForTimeout(total / 3); + await page.waitForTimeout(total / 3); + await u.po.userProfile.goTo(); + await page.waitForTimeout(total / 3); + await u.page.goToRelative(`/action-with-use-reverification`); + await u.po.expect.toBeSignedIn(); + await u.page.getByRole('button', { name: /LogUserId/i }).click(); + await u.po.userVerification.waitForMounted(); + }); + }); + + utils.forEach(type => { + test.skip(`protect${capitalize(type)} returned values as ${'`signed-out user`'}`, async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + + const routes = [ + `/protect-${type}/signed-out`, + `/protect-${type}/signed-out-with-role`, + `/protect-${type}/signed-out-with-reverification`, + `/protect-${type}/signed-out-with-all`, + ]; + + for (const route of routes) { + await u.page.goToRelative(route); + await u.page.getByRole('button', { name: /LogUserId/i }).click(); + await expect( + u.page.getByText( + /\{\s*"clerk_error"\s*:\s*\{\s*"type"\s*:\s*"unauthorized"\s*,\s*"reason"\s*:\s*"signed-out"\s*\}\s*\}/i, + ), + ).toBeVisible(); + } + }); + }); + }, +); From 22381262286630f4f098afad7bea74e31c97fa0e Mon Sep 17 00:00:00 2001 From: panteliselef Date: Tue, 22 Oct 2024 08:29:28 -0400 Subject: [PATCH 10/22] fix __unstable__onAfterSetActive definition --- .../nextjs/src/app-router/client/ClerkProvider.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/nextjs/src/app-router/client/ClerkProvider.tsx b/packages/nextjs/src/app-router/client/ClerkProvider.tsx index f6537541cba..a5540cf9b34 100644 --- a/packages/nextjs/src/app-router/client/ClerkProvider.tsx +++ b/packages/nextjs/src/app-router/client/ClerkProvider.tsx @@ -101,12 +101,12 @@ export const ClientClerkProvider = (props: NextClerkProviderProps) => { void invalidateCacheAction().then(() => res()); } }); + }; - window.__unstable__onAfterSetActive = () => { - if (__unstable_invokeMiddlewareOnAuthStateChange) { - return router.refresh(); - } - }; + window.__unstable__onAfterSetActive = () => { + if (__unstable_invokeMiddlewareOnAuthStateChange) { + return router.refresh(); + } }; }, []); From 4b507aa76c392c3e67ebde80fde4ebb11b89cc2d Mon Sep 17 00:00:00 2001 From: panteliselef Date: Tue, 22 Oct 2024 12:00:09 -0400 Subject: [PATCH 11/22] fix e2e tests --- integration/presets/longRunningApps.ts | 4 +- integration/presets/next.ts | 108 +++++++++--------- .../src/app/(reverification)/actions.ts | 4 +- integration/tests/next-build.test.ts | 2 +- 4 files changed, 59 insertions(+), 59 deletions(-) diff --git a/integration/presets/longRunningApps.ts b/integration/presets/longRunningApps.ts index 7edf7e44f7f..d5921cb4be5 100644 --- a/integration/presets/longRunningApps.ts +++ b/integration/presets/longRunningApps.ts @@ -23,7 +23,7 @@ export const createLongRunningApps = () => { { id: 'react.vite.withEmailLinks', config: react.vite, env: envs.withEmailLinks }, { id: 'remix.node.withEmailCodes', config: remix.remixNode, env: envs.withEmailCodes }, { id: 'next.appRouter.withEmailCodes', config: next.appRouter, env: envs.withEmailCodes }, - { id: 'next.appRouter.15RCwithEmailCodes', config: next.appRouter15Rc, env: envs.withEmailCodes }, + // { id: 'next.appRouter.15RCwithEmailCodes', config: next.appRouter15Rc, env: envs.withEmailCodes }, { id: 'next.appRouter.withEmailCodes_persist_client', config: next.appRouter, @@ -31,7 +31,7 @@ export const createLongRunningApps = () => { }, { id: 'next.appRouter.withCustomRoles', config: next.appRouter, env: envs.withCustomRoles }, { id: 'next.appRouter.withReverification', config: next.appRouter, env: envs.withReverification }, - { id: 'next.appRouter.15RCwithCustomRoles', config: next.appRouter15Rc, env: envs.withCustomRoles }, + // { id: 'next.appRouter.15RCwithCustomRoles', config: next.appRouter15Rc, env: envs.withCustomRoles }, { id: 'quickstart.next.appRouter', config: next.appRouterQuickstart, env: envs.withEmailCodesQuickstart }, { id: 'elements.next.appRouter', config: elements.nextAppRouter, env: envs.withEmailCodes }, { id: 'astro.node.withCustomRoles', config: astro.node, env: envs.withCustomRoles }, diff --git a/integration/presets/next.ts b/integration/presets/next.ts index 280208cfdd9..1e00f0b66f5 100644 --- a/integration/presets/next.ts +++ b/integration/presets/next.ts @@ -18,59 +18,59 @@ const appRouter = applicationConfig() .addDependency('@clerk/nextjs', constants.E2E_CLERK_VERSION || clerkNextjsLocal) .addDependency('@clerk/shared', clerkSharedLocal); -const appRouter15Rc = applicationConfig() - .setName('next-app-router') - .useTemplate(templates['next-app-router']) - .setEnvFormatter('public', key => `NEXT_PUBLIC_${key}`) - .addScript('setup', constants.E2E_NPM_FORCE ? 'npm i --force' : 'npm i') - .addScript('dev', 'npm run dev') - .addScript('build', 'npm run build') - .addScript('serve', 'npm run start') - .addDependency('next', 'rc') - .addDependency('react', 'rc') - .addDependency('react-dom', 'rc') - .addDependency('@clerk/nextjs', constants.E2E_CLERK_VERSION || clerkNextjsLocal) - .addFile( - 'src/middleware.ts', - () => `import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server'; -import { unstable_rethrow } from 'next/navigation'; - -const csp = \`default-src 'self'; - script-src 'self' 'unsafe-inline' 'unsafe-eval' 'strict-dynamic' 'nonce-deadbeef'; - img-src 'self' https://img.clerk.com; - worker-src 'self' blob:; - style-src 'self' 'unsafe-inline'; - frame-src 'self' https://challenges.cloudflare.com; -\`; - -const isProtectedRoute = createRouteMatcher(['/protected(.*)', '/user(.*)', '/switcher(.*)']); -const isAdminRoute = createRouteMatcher(['/only-admin(.*)']); -const isCSPRoute = createRouteMatcher(['/csp']); - -export default clerkMiddleware(async (auth, req) => { - if (isProtectedRoute(req)) { - try { await auth.protect() } - catch (e) { unstable_rethrow(e) } - } - - if (isAdminRoute(req)) { - try { await auth.protect({role: 'admin'}) } - catch (e) { unstable_rethrow(e) } - } - - if (isCSPRoute(req)) { - req.headers.set('Content-Security-Policy', csp.replace(/\\n/g, '')); - } -}); - -export const config = { - matcher: [ - '/((?!.*\\\\..*|_next).*)', // Don't run middleware on static files - '/', // Run middleware on index page - '/(api|trpc)(.*)', - ], // Run middleware on API routes -};`, - ); +// const appRouter15Rc = applicationConfig() +// .setName('next-app-router') +// .useTemplate(templates['next-app-router']) +// .setEnvFormatter('public', key => `NEXT_PUBLIC_${key}`) +// .addScript('setup', constants.E2E_NPM_FORCE ? 'npm i --force' : 'npm i') +// .addScript('dev', 'npm run dev') +// .addScript('build', 'npm run build') +// .addScript('serve', 'npm run start') +// .addDependency('next', 'rc') +// .addDependency('react', 'rc') +// .addDependency('react-dom', 'rc') +// .addDependency('@clerk/nextjs', constants.E2E_CLERK_VERSION || clerkNextjsLocal) +// .addFile( +// 'src/middleware.ts', +// () => `import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server'; +// import { unstable_rethrow } from 'next/navigation'; +// +// const csp = \`default-src 'self'; +// script-src 'self' 'unsafe-inline' 'unsafe-eval' 'strict-dynamic' 'nonce-deadbeef'; +// img-src 'self' https://img.clerk.com; +// worker-src 'self' blob:; +// style-src 'self' 'unsafe-inline'; +// frame-src 'self' https://challenges.cloudflare.com; +// \`; +// +// const isProtectedRoute = createRouteMatcher(['/protected(.*)', '/user(.*)', '/switcher(.*)']); +// const isAdminRoute = createRouteMatcher(['/only-admin(.*)']); +// const isCSPRoute = createRouteMatcher(['/csp']); +// +// export default clerkMiddleware(async (auth, req) => { +// if (isProtectedRoute(req)) { +// try { await auth.protect() } +// catch (e) { unstable_rethrow(e) } +// } +// +// if (isAdminRoute(req)) { +// try { await auth.protect({role: 'admin'}) } +// catch (e) { unstable_rethrow(e) } +// } +// +// if (isCSPRoute(req)) { +// req.headers.set('Content-Security-Policy', csp.replace(/\\n/g, '')); +// } +// }); +// +// export const config = { +// matcher: [ +// '/((?!.*\\\\..*|_next).*)', // Don't run middleware on static files +// '/', // Run middleware on index page +// '/(api|trpc)(.*)', +// ], // Run middleware on API routes +// };`, +// ); const appRouterTurbo = appRouter .clone() @@ -104,7 +104,7 @@ const appRouterAPWithClerkNextV4 = appRouterQuickstart export const next = { appRouter, - appRouter15Rc, + // appRouter15Rc, appRouterTurbo, appRouterQuickstart, appRouterAPWithClerkNextLatest, diff --git a/integration/templates/next-app-router/src/app/(reverification)/actions.ts b/integration/templates/next-app-router/src/app/(reverification)/actions.ts index 8be736e3965..6a41f21f863 100644 --- a/integration/templates/next-app-router/src/app/(reverification)/actions.ts +++ b/integration/templates/next-app-router/src/app/(reverification)/actions.ts @@ -4,7 +4,7 @@ import { auth } from '@clerk/nextjs/server'; import { reverificationMismatch } from '@clerk/shared/authorization-errors'; const logUserIdActionReverification = async () => { - const { userId } = auth().protect(); + const { userId } = await auth.protect(); const config = { level: 'secondFactor', @@ -12,7 +12,7 @@ const logUserIdActionReverification = async () => { } as const; if ( - !auth().has({ + !(await auth()).has({ __experimental_reverification: config, }) ) { diff --git a/integration/tests/next-build.test.ts b/integration/tests/next-build.test.ts index 4e03db4f80c..494d3e4d672 100644 --- a/integration/tests/next-build.test.ts +++ b/integration/tests/next-build.test.ts @@ -3,7 +3,7 @@ import { expect, test } from '@playwright/test'; import type { Application } from '../models/application'; import { appConfigs } from '../presets'; -test.describe('next build @nextjs', () => { +test.describe.skip('next build @nextjs', () => { test.describe.configure({ mode: 'parallel' }); let app: Application; From 42ad902b59439aa230d0c8c271f75f46a1f60004 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Thu, 24 Oct 2024 16:04:00 -0400 Subject: [PATCH 12/22] remove skipped test --- integration/presets/next.ts | 4 +++- integration/tests/next-build.test.ts | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/integration/presets/next.ts b/integration/presets/next.ts index 68366b7ffa4..20dba1e613c 100644 --- a/integration/presets/next.ts +++ b/integration/presets/next.ts @@ -3,6 +3,7 @@ import { applicationConfig } from '../models/applicationConfig.js'; import { templates } from '../templates/index.js'; const clerkNextjsLocal = `file:${process.cwd()}/packages/nextjs`; +const clerkSharedLocal = `file:${process.cwd()}/packages/shared`; const appRouter = applicationConfig() .setName('next-app-router') .useTemplate(templates['next-app-router']) @@ -14,7 +15,8 @@ const appRouter = applicationConfig() .addDependency('next', constants.E2E_NEXTJS_VERSION) .addDependency('react', constants.E2E_REACT_VERSION) .addDependency('react-dom', constants.E2E_REACT_DOM_VERSION) - .addDependency('@clerk/nextjs', constants.E2E_CLERK_VERSION || clerkNextjsLocal); + .addDependency('@clerk/nextjs', constants.E2E_CLERK_VERSION || clerkNextjsLocal) + .addDependency('@clerk/shared', clerkSharedLocal); const appRouterTurbo = appRouter .clone() diff --git a/integration/tests/next-build.test.ts b/integration/tests/next-build.test.ts index 494d3e4d672..4e03db4f80c 100644 --- a/integration/tests/next-build.test.ts +++ b/integration/tests/next-build.test.ts @@ -3,7 +3,7 @@ import { expect, test } from '@playwright/test'; import type { Application } from '../models/application'; import { appConfigs } from '../presets'; -test.describe.skip('next build @nextjs', () => { +test.describe('next build @nextjs', () => { test.describe.configure({ mode: 'parallel' }); let app: Application; From 828dc90005cb49001fec1b46de06c4deeb267386 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Thu, 24 Oct 2024 16:19:21 -0400 Subject: [PATCH 13/22] revert global timeout extention --- integration/playwright.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/integration/playwright.config.ts b/integration/playwright.config.ts index 4680f98b754..ce5363f3c24 100644 --- a/integration/playwright.config.ts +++ b/integration/playwright.config.ts @@ -13,7 +13,7 @@ export const common: PlaywrightTestConfig = { fullyParallel: true, forbidOnly: !!process.env.CI, retries: process.env.CI ? 2 : 0, - timeout: 180_000, + timeout: 90_000, maxFailures: process.env.CI ? 1 : undefined, workers: process.env.CI ? '50%' : '70%', reporter: process.env.CI ? 'line' : 'list', From dfad7e69c5918bd493c6207cb837c1921c548642 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Wed, 30 Oct 2024 11:06:01 +0200 Subject: [PATCH 14/22] address PR review comments --- .changeset/five-insects-grin.md | 7 +-- .changeset/serious-poems-retire.md | 2 +- integration/presets/longRunningApps.ts | 1 + .../requires-re-verification/page.tsx | 1 - integration/tests/reverification.test.ts | 23 ------- packages/react/src/hooks/useReverification.ts | 63 +++---------------- packages/shared/src/authorization-errors.ts | 14 ++++- 7 files changed, 26 insertions(+), 85 deletions(-) diff --git a/.changeset/five-insects-grin.md b/.changeset/five-insects-grin.md index cec9bd59a03..2bfe8072143 100644 --- a/.changeset/five-insects-grin.md +++ b/.changeset/five-insects-grin.md @@ -15,11 +15,10 @@ function DeleteAccount() { const { handleReverification } = useReverification(); const { user } = useUser(); - const deleteUserAccount = async () => { + const deleteUserAccount = handleReverification(() => { if (!user) return; - const verifyAndDelete = handleReverification(user.delete); - return verifyAndDelete(); - }; + return user.delete() + }) return <> + ); +}; + +const AddMfa = (props: AddMfaProps) => { + const { onSuccess, onReset, title, onAddPhoneClick, onUnverifiedPhoneClick, resourceRef } = props; + const { user } = useUser(); + + if (!user) { + return null; + } + + const availableMethods = user.phoneNumbers.filter(p => !p.reservedForSecondFactor); + return ( { /> {availableMethods.length > 0 && ( - {availableMethods.map(phone => { - const { country } = getCountryFromPhoneString(phone.phoneNumber); - - const formattedPhone = stringToFormattedPhoneString(phone.phoneNumber); - - return ( - - ); - })} + {availableMethods.map(phone => ( + + ))} )} diff --git a/packages/clerk-js/src/ui/components/UserProfile/PasskeySection.tsx b/packages/clerk-js/src/ui/components/UserProfile/PasskeySection.tsx index a40fb562098..a01eb98e0e6 100644 --- a/packages/clerk-js/src/ui/components/UserProfile/PasskeySection.tsx +++ b/packages/clerk-js/src/ui/components/UserProfile/PasskeySection.tsx @@ -1,4 +1,4 @@ -import { useClerk, useUser } from '@clerk/shared/react'; +import { __experimental_useReverification as useReverification, useClerk, useUser } from '@clerk/shared/react'; import type { PasskeyResource } from '@clerk/types'; import React from 'react'; @@ -15,7 +15,6 @@ import { } from '../../elements'; import { Action } from '../../elements/Action'; import { useActionContext } from '../../elements/Action/ActionRoot'; -import { useAssurance } from '../../hooks/useAssurance'; import type { PropsOfComponent } from '../../styledSystem'; import { mqu } from '../../styledSystem'; import { getRelativeToNowDateKey, handleError, useFormControl } from '../../utils'; @@ -192,14 +191,19 @@ const AddPasskeyButton = () => { const card = useCardState(); const { isSatellite } = useClerk(); const { user } = useUser(); - const { handleAssurance } = useAssurance(); + const [createPasskey] = useReverification(() => { + if (!user) { + return Promise.resolve(undefined); + } + return user.createPasskey(); + }); const handleCreatePasskey = async () => { if (!user) { return; } try { - await handleAssurance(user.createPasskey); + await createPasskey(); } catch (e) { handleError(e, [], card.setError); } diff --git a/packages/clerk-js/src/ui/components/UserProfile/PasswordForm.tsx b/packages/clerk-js/src/ui/components/UserProfile/PasswordForm.tsx index c98b55092da..2e1639e6502 100644 --- a/packages/clerk-js/src/ui/components/UserProfile/PasswordForm.tsx +++ b/packages/clerk-js/src/ui/components/UserProfile/PasswordForm.tsx @@ -1,4 +1,4 @@ -import { useSession, useUser } from '@clerk/shared/react'; +import { __experimental_useReverification as useReverification, useSession, useUser } from '@clerk/shared/react'; import { useRef } from 'react'; import { useEnvironment } from '../../contexts'; @@ -14,7 +14,6 @@ import { withCardStateProvider, } from '../../elements'; import { useConfirmPassword } from '../../hooks'; -import { useAssurance } from '../../hooks/useAssurance'; import { createPasswordError, handleError, useFormControl } from '../../utils'; const generateSuccessPageText = (userHasPassword: boolean, sessionSignOut: boolean) => { @@ -37,7 +36,19 @@ type PasswordFormProps = FormProps; export const PasswordForm = withCardStateProvider((props: PasswordFormProps) => { const { onSuccess, onReset } = props; const { user } = useUser(); - const { handleAssurance } = useAssurance(); + 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); + }); if (!user) { return null; @@ -105,12 +116,6 @@ export const PasswordForm = withCardStateProvider((props: PasswordFormProps) => }; const updatePassword = async () => { - const opts = { - newPassword: passwordField.value, - signOutOfOtherSessions: sessionsField.checked, - currentPassword: user.passwordEnabled ? currentPasswordField.value : undefined, - } satisfies Parameters[0]; - try { successPagePropsRef.current = { title: user.passwordEnabled @@ -119,7 +124,7 @@ export const PasswordForm = withCardStateProvider((props: PasswordFormProps) => text: generateSuccessPageText(user.passwordEnabled, !!sessionsField.checked), }; - await handleAssurance(() => user.updatePassword(opts)); + await updatePasswordWithReverification(); 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 571d9e9eea3..0627e33dafd 100644 --- a/packages/clerk-js/src/ui/components/UserProfile/PhoneForm.tsx +++ b/packages/clerk-js/src/ui/components/UserProfile/PhoneForm.tsx @@ -1,4 +1,4 @@ -import { useUser } from '@clerk/shared/react'; +import { __experimental_useReverification as useReverification, useUser } from '@clerk/shared/react'; import type { PhoneNumberResource } from '@clerk/types'; import React from 'react'; @@ -7,7 +7,6 @@ import type { LocalizationKey } from '../../customizables'; import { Button, Flex, localizationKeys, Text } from '../../customizables'; import type { FormProps } from '../../elements'; import { Form, FormButtons, FormContainer, useCardState, withCardStateProvider } from '../../elements'; -import { useAssurance } from '../../hooks/useAssurance'; import { handleError, useFormControl } from '../../utils'; import { VerifyWithCode } from './VerifyWithCode'; @@ -50,7 +49,12 @@ export const AddPhone = (props: AddPhoneProps) => { const { title, onSuccess, onReset, onUseExistingNumberClick, resourceRef } = props; const card = useCardState(); const { user } = useUser(); - const { handleAssurance } = useAssurance(); + const [createPhoneNumber] = useReverification(() => { + if (!user) { + return Promise.resolve(undefined); + } + return user.createPhoneNumber({ phoneNumber: phoneField.value }); + }); const phoneField = useFormControl('phoneNumber', '', { type: 'tel', @@ -66,7 +70,7 @@ export const AddPhone = (props: AddPhoneProps) => { if (!user) { return; } - return handleAssurance(() => user.createPhoneNumber({ phoneNumber: phoneField.value })) + return createPhoneNumber() .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 69eb304df41..44c0023b462 100644 --- a/packages/clerk-js/src/ui/components/UserProfile/UsernameForm.tsx +++ b/packages/clerk-js/src/ui/components/UserProfile/UsernameForm.tsx @@ -1,39 +1,44 @@ -import { useUser } from '@clerk/shared/react'; +import { __experimental_useReverification as useReverification, useUser } from '@clerk/shared/react'; import { useEnvironment } from '../../contexts'; import { localizationKeys } from '../../customizables'; import type { FormProps } from '../../elements'; import { Form, FormButtons, FormContainer, useCardState, withCardStateProvider } from '../../elements'; -import { useAssurance } from '../../hooks/useAssurance'; import { handleError, useFormControl } from '../../utils'; type UsernameFormProps = FormProps; export const UsernameForm = withCardStateProvider((props: UsernameFormProps) => { const { onSuccess, onReset } = props; - const { handleAssurance } = useAssurance(); const { user } = useUser(); - if (!user) { - return null; - } + const [updateUsername] = useReverification(() => { + if (!user) { + return Promise.resolve(undefined); + } + return user.update({ username: usernameField.value }); + }); const { userSettings } = useEnvironment(); const card = useCardState(); - const usernameField = useFormControl('username', user.username || '', { + const usernameField = useFormControl('username', user?.username || '', { type: 'text', label: localizationKeys('formFieldLabel__username'), placeholder: localizationKeys('formFieldInputPlaceholder__username'), }); + if (!user) { + return null; + } + const isUsernameRequired = userSettings.attributes.username.required; const canSubmit = (isUsernameRequired ? usernameField.value.length > 0 : true) && user.username !== usernameField.value; - const updatePassword = async () => { + const submitUpdate = async () => { try { - await handleAssurance(() => user.update({ username: usernameField.value })); + await updateUsername(); onSuccess(); } catch (e) { handleError(e, [usernameField], card.setError); @@ -48,7 +53,7 @@ export const UsernameForm = withCardStateProvider((props: UsernameFormProps) => : localizationKeys('userProfile.usernamePage.title__set') } > - + { - promise: Promise; - resolve: (value: T | PromiseLike) => void; - reject: (reason?: any) => void; -} - -type AssuranceHint = { - clerk_error: { - type: 'forbidden'; - reason: 'assurance'; - }; -}; - -function generateAssuranceHint>(metadata?: Metadata): AssuranceHint; -function generateAssuranceHint>(metadata: Metadata, as: 'response'): Response; -function generateAssuranceHint>( - metadata?: Metadata, - as?: 'response', -): AssuranceHint | Response { - const obj = { - clerk_error: { - type: 'forbidden', - reason: 'assurance', - metadata, - }, - } as AssuranceHint; - - if (as === 'response') { - return new Response(JSON.stringify(obj), { status: 403 }); - } - - return obj; -} - -async function resolveResult(result: Promise): Promise { - return result.catch(e => { - // Treat fapi assurance as an assurance hint - if (isClerkAPIResponseError(e) && e.errors.find(({ code }) => code == 'session_step_up_verification_required')) { - return generateAssuranceHint(); - } - - // rethrow - throw e; - }); -} - -/** - * Polyfill for Promise.withResolvers() - */ -function customPromiseWithResolvers() { - let resolve: PromiseWithResolvers['resolve']; - let reject: PromiseWithResolvers['reject']; - - const promise = new Promise((res, rej) => { - resolve = res; - reject = rej; - }); - - return { - promise, - // @ts-ignore - resolve, - // @ts-ignore - reject, - }; -} - -type WithoutAssuranceHint = Exclude; - -const isAssuranceHint = (result: any): result is AssuranceHint => { - return ( - result && - typeof result === 'object' && - 'clerk_error' in result && - result.clerk_error?.type === 'forbidden' && - result.clerk_error?.reason === 'assurance' - ); -}; - -function createAssuranceHandler(params: { onOpenModal: Clerk['__experimental_openUserVerification'] }) { - async function assertAssurance(fetcher: () => Promise): Promise { - let result = await resolveResult(fetcher()); - - if (isAssuranceHint(result)) { - /** - * Create a promise - */ - const resolvers = customPromiseWithResolvers(); - - /** - * On success resolve the pending promise - * On cancel reject the pending promise - */ - params.onOpenModal?.({ - afterVerification() { - resolvers.resolve(true); - }, - afterVerificationCancelled() { - resolvers.reject( - new ClerkRuntimeError('User cancelled attempted verification', { - code: 'assurance_cancelled', - }), - ); - }, - }); - - /** - * Wait until the promise from above have been resolved or rejected - */ - await resolvers.promise; - - /** - * After the promise resolved successfully try the original request one more time - */ - result = await resolveResult(fetcher()); - } - - return result as WithoutAssuranceHint; - } - - return assertAssurance; -} - -const useAssurance = (): { - handleAssurance: ReturnType; -} => { - const { __experimental_openUserVerification } = useClerk(); - const handleAssurance = useMemo( - () => - createAssuranceHandler({ - onOpenModal: __experimental_openUserVerification, - }), - [__experimental_openUserVerification], - ); - return { - handleAssurance, - }; -}; - -export { useAssurance }; diff --git a/packages/clerk-js/src/ui/utils/errorHandler.ts b/packages/clerk-js/src/ui/utils/errorHandler.ts index e0c361d2f26..2f7a7bff54b 100644 --- a/packages/clerk-js/src/ui/utils/errorHandler.ts +++ b/packages/clerk-js/src/ui/utils/errorHandler.ts @@ -80,7 +80,8 @@ export const handleError: HandleError = (err, fieldStates, setGlobalError) => { return handleClerkApiError(err, fieldStates, setGlobalError); } - if (isClerkRuntimeError(err) && err.code === 'assurance_cancelled') { + if (isClerkRuntimeError(err) && err.code === 'reverification_cancelled') { + // Don't log or display an error for cancelled reverification, the user simply closed the modal. return; } From 1e4e852cb370e3058e5039a2445427901d47e835 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Wed, 30 Oct 2024 17:18:59 +0200 Subject: [PATCH 20/22] highlight experimental utilities --- .changeset/ninety-rabbits-itch.md | 2 +- .../src/app/(reverification)/actions.ts | 2 +- packages/shared/src/authorization-errors.ts | 18 +++++++++++++----- .../src/react/hooks/useReverification.ts | 10 ++++++---- 4 files changed, 21 insertions(+), 11 deletions(-) diff --git a/.changeset/ninety-rabbits-itch.md b/.changeset/ninety-rabbits-itch.md index bed9e085519..002248f6b22 100644 --- a/.changeset/ninety-rabbits-itch.md +++ b/.changeset/ninety-rabbits-itch.md @@ -2,6 +2,6 @@ "@clerk/shared": minor --- -Introduce reverification error helpers. +Introduce experimental reverification error helpers. - `reverificationMismatch` returns the error as an object which can later be used as a return value from a React Server Action. - `reverificationMismatchResponse` returns a Response with the above object serialized. It can be used in any Backend Javascript frameworks that supports `Response`. diff --git a/integration/templates/next-app-router/src/app/(reverification)/actions.ts b/integration/templates/next-app-router/src/app/(reverification)/actions.ts index 0c0533d3237..a28f785e2e1 100644 --- a/integration/templates/next-app-router/src/app/(reverification)/actions.ts +++ b/integration/templates/next-app-router/src/app/(reverification)/actions.ts @@ -1,7 +1,7 @@ 'use server'; import { auth } from '@clerk/nextjs/server'; -import { reverificationMismatch } from '@clerk/shared/authorization-errors'; +import { __experimental_reverificationMismatch as reverificationMismatch } from '@clerk/shared/authorization-errors'; const logUserIdActionReverification = async () => { const { userId, has } = await auth.protect(); diff --git a/packages/shared/src/authorization-errors.ts b/packages/shared/src/authorization-errors.ts index be0e8654f9a..350e40dcb92 100644 --- a/packages/shared/src/authorization-errors.ts +++ b/packages/shared/src/authorization-errors.ts @@ -11,7 +11,7 @@ type ReverificationMismatchError; -const reverificationMismatch = (missingConfig?: MC) => +const __experimental_reverificationMismatch = (missingConfig?: MC) => ({ clerk_error: { type: 'forbidden', @@ -22,12 +22,16 @@ const reverificationMismatch = ( }, }) satisfies ReverificationMismatchError; -const reverificationMismatchResponse = (...args: Parameters) => - new Response(JSON.stringify(reverificationMismatch(...args)), { +const __experimental_reverificationMismatchResponse = ( + ...args: Parameters +) => + new Response(JSON.stringify(__experimental_reverificationMismatch(...args)), { status: 403, }); -const isReverificationHint = (result: any): result is ReturnType => { +const __experimental_isReverificationHint = ( + result: any, +): result is ReturnType => { return ( result && typeof result === 'object' && @@ -37,4 +41,8 @@ const isReverificationHint = (result: any): result is ReturnType(result: Promise): Promise> { +async function resolveResult( + result: Promise, +): Promise> { return result .then(r => { if (r instanceof Response) { @@ -18,7 +20,7 @@ async function resolveResult(result: Promise): Promise { // Treat fapi assurance as an assurance hint if (isClerkAPIResponseError(e) && e.errors.find(({ code }) => code == 'session_step_up_verification_required')) { - return reverificationMismatch(); + return __experimental_reverificationMismatch(); } // rethrow @@ -33,7 +35,7 @@ function createReverificationHandler(params: { onOpenModal: Clerk['__experimenta return async (...args) => { let result = await resolveResult(fetcher(...args)); - if (isReverificationHint(result)) { + if (__experimental_isReverificationHint(result)) { /** * Create a promise */ From b47124a954d7b24b28976f9eda837dd4ca90049a Mon Sep 17 00:00:00 2001 From: panteliselef Date: Wed, 30 Oct 2024 18:52:17 +0200 Subject: [PATCH 21/22] align usage for consistency --- .../src/ui/components/UserProfile/DeleteUserForm.tsx | 6 +++--- .../clerk-js/src/ui/components/UserProfile/EmailForm.tsx | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/clerk-js/src/ui/components/UserProfile/DeleteUserForm.tsx b/packages/clerk-js/src/ui/components/UserProfile/DeleteUserForm.tsx index 477facd95e0..25fc2999198 100644 --- a/packages/clerk-js/src/ui/components/UserProfile/DeleteUserForm.tsx +++ b/packages/clerk-js/src/ui/components/UserProfile/DeleteUserForm.tsx @@ -1,4 +1,4 @@ -import { __experimental_useReverification, useClerk, useUser } from '@clerk/shared/react'; +import { __experimental_useReverification as useReverification, useClerk, useUser } from '@clerk/shared/react'; import { useSignOutContext } from '../../contexts'; import { Col, localizationKeys, Text, useLocalizations } from '../../customizables'; @@ -16,9 +16,9 @@ export const DeleteUserForm = withCardStateProvider((props: DeleteUserFormProps) const { t } = useLocalizations(); const { otherSessions } = useMultipleSessions({ user }); const { setActive } = useClerk(); - const [deleteUserWithReverification] = __experimental_useReverification(() => { + const [deleteUserWithReverification] = useReverification(() => { if (!user) { - throw Error('user is not defined'); + return Promise.resolve(undefined); } return user.delete(); diff --git a/packages/clerk-js/src/ui/components/UserProfile/EmailForm.tsx b/packages/clerk-js/src/ui/components/UserProfile/EmailForm.tsx index acf4cbdf607..deeb86b8daf 100644 --- a/packages/clerk-js/src/ui/components/UserProfile/EmailForm.tsx +++ b/packages/clerk-js/src/ui/components/UserProfile/EmailForm.tsx @@ -24,7 +24,7 @@ export const EmailForm = withCardStateProvider((props: EmailFormProps) => { const [createEmailAddress] = useReverification(() => { if (!user) { - return Promise.reject(); + return Promise.resolve(undefined); } return user.createEmailAddress({ email: emailField.value }); }); From f86c9cb2be9004ccdb866fe51770967813cf9d1a Mon Sep 17 00:00:00 2001 From: panteliselef Date: Thu, 31 Oct 2024 13:19:12 +0200 Subject: [PATCH 22/22] simplify types of `__experimental_useReverification` --- .../src/react/hooks/useReverification.ts | 31 +++++++++---------- 1 file changed, 14 insertions(+), 17 deletions(-) diff --git a/packages/shared/src/react/hooks/useReverification.ts b/packages/shared/src/react/hooks/useReverification.ts index 8f9cd5bac32..091ded297bc 100644 --- a/packages/shared/src/react/hooks/useReverification.ts +++ b/packages/shared/src/react/hooks/useReverification.ts @@ -29,10 +29,8 @@ async function resolveResult( } function createReverificationHandler(params: { onOpenModal: Clerk['__experimental_openUserVerification'] }) { - function assertReverification) => Promise>( - fetcher: Fetcher, - ): (...args: Parameters) => Promise>> { - return async (...args) => { + function assertReverification Promise>(fetcher: Fetcher): Fetcher { + return (async (...args) => { let result = await resolveResult(fetcher(...args)); if (__experimental_isReverificationHint(result)) { @@ -70,30 +68,29 @@ function createReverificationHandler(params: { onOpenModal: Clerk['__experimenta } return result; - }; + }) as Fetcher; } return assertReverification; } -const __experimental_useReverification = ) => Promise>( - fetcher: Fetcher, -): [(...args: Parameters) => Promise>>] => { +function __experimental_useReverification Promise>(fetcher: Fetcher): readonly [Fetcher] { const { __experimental_openUserVerification } = useClerk(); const fetcherRef = useRef(fetcher); - const handleReverification = useMemo( - () => - createReverificationHandler({ - onOpenModal: __experimental_openUserVerification, - })(fetcherRef.current), - [__experimental_openUserVerification, fetcherRef.current], - ); + const handleReverification = useMemo(() => { + const handler = createReverificationHandler({ + onOpenModal: __experimental_openUserVerification, + })(fetcherRef.current); + return [handler] as const; + }, [__experimental_openUserVerification, fetcherRef.current]); + + // Keep fetcher ref in sync useSafeLayoutEffect(() => { fetcherRef.current = fetcher; }); - return [handleReverification]; -}; + return handleReverification; +} export { __experimental_useReverification };