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..e539913994b --- /dev/null +++ b/.changeset/five-insects-grin.md @@ -0,0 +1,33 @@ +--- +"@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 +import { __experimental_useReverification as useReverification } from '@clerk/nextjs'; + +function DeleteAccount() { + const { user } = useUser(); + const [deleteUserAccount] = useReverification(() => { + if (!user) return; + return user.delete() + }); + + return <> + + +} + +``` diff --git a/.changeset/ninety-rabbits-itch.md b/.changeset/ninety-rabbits-itch.md new file mode 100644 index 00000000000..002248f6b22 --- /dev/null +++ b/.changeset/ninety-rabbits-itch.md @@ -0,0 +1,7 @@ +--- +"@clerk/shared": minor +--- + +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/.changeset/serious-poems-retire.md b/.changeset/serious-poems-retire.md new file mode 100644 index 00000000000..25f3ff3af5f --- /dev/null +++ b/.changeset/serious-poems-retire.md @@ -0,0 +1,5 @@ +--- +"@clerk/clerk-js": patch +--- + +Chore: Replace beforeEmit with an explicit call after `setActive`, inside the experimental UserVerification. diff --git a/integration/playwright.config.ts b/integration/playwright.config.ts index 583757c9d56..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: 90000, + timeout: 90_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 bbac79557d9..a0de0b5258b 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', '') @@ -106,6 +113,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..8ac2db94ba9 --- /dev/null +++ b/integration/templates/next-app-router/src/app/(reverification)/action-with-use-reverification/page.tsx @@ -0,0 +1,30 @@ +'use client'; +import { useState, useTransition } from 'react'; +import { __experimental_useReverification as useReverification } from '@clerk/nextjs'; +import { logUserIdActionReverification } from '@/app/(reverification)/actions'; + +function Page() { + const [logUserWithReverification] = useReverification(logUserIdActionReverification); + 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..a28f785e2e1 --- /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 { __experimental_reverificationMismatch as reverificationMismatch } from '@clerk/shared/authorization-errors'; + +const logUserIdActionReverification = async () => { + const { userId, has } = await auth.protect(); + + const config = { + level: 'secondFactor', + afterMinutes: 1, + } as const; + + const userNeedsReverification = !has({ + __experimental_reverification: config, + }); + + if (userNeedsReverification) { + 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..6100d3f521e --- /dev/null +++ b/integration/templates/next-app-router/src/app/(reverification)/requires-re-verification/page.tsx @@ -0,0 +1,8 @@ +import { logUserIdActionReverification } from '../actions'; +import { ButtonAction } from '../button-action'; + +function Page() { + 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..2cd6fc96607 --- /dev/null +++ b/integration/tests/reverification.test.ts @@ -0,0 +1,104 @@ +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(270_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(); + + 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(); + + const total = 1000 * 120; + 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(270_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(); + + 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(); + + const total = 1000 * 120; + 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(); + }); + }); + }, +); diff --git a/package-lock.json b/package-lock.json index 0a8a854f6bd..6027b829756 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13348,7 +13348,6 @@ "node_modules/@rollup/plugin-alias": { "version": "5.1.0", "dev": true, - "license": "MIT", "dependencies": { "slash": "^4.0.0" }, 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", diff --git a/packages/clerk-js/src/ui/common/RemoveResourceForm.tsx b/packages/clerk-js/src/ui/common/RemoveResourceForm.tsx index 8b6cad6e0b7..1959fe6ad8a 100644 --- a/packages/clerk-js/src/ui/common/RemoveResourceForm.tsx +++ b/packages/clerk-js/src/ui/common/RemoveResourceForm.tsx @@ -1,7 +1,8 @@ +import { __experimental_useReverification as useReverification } from '@clerk/shared/react'; + import { localizationKeys, Text } from '../customizables'; import type { FormProps } from '../elements'; import { Form, FormButtons, FormContainer, useCardState, withCardStateProvider } from '../elements'; -import { useAssurance } from '../hooks/useAssurance'; import type { LocalizationKey } from '../localization'; import { handleError } from '../utils'; @@ -16,11 +17,11 @@ type RemoveFormProps = FormProps & { export const RemoveResourceForm = withCardStateProvider((props: RemoveFormProps) => { const { title, messageLine1, messageLine2, deleteResource, onSuccess, onReset } = props; const card = useCardState(); - const { handleAssurance } = useAssurance(); + const [deleteWithReverification] = useReverification(deleteResource); const handleSubmit = async () => { try { - await handleAssurance(deleteResource).then(onSuccess); + await deleteWithReverification().then(onSuccess); } catch (e) { handleError(e, [], card.setError); } diff --git a/packages/clerk-js/src/ui/components/UserProfile/ActiveDevicesSection.tsx b/packages/clerk-js/src/ui/components/UserProfile/ActiveDevicesSection.tsx index b60c0b3114f..3d57b8b32ca 100644 --- a/packages/clerk-js/src/ui/components/UserProfile/ActiveDevicesSection.tsx +++ b/packages/clerk-js/src/ui/components/UserProfile/ActiveDevicesSection.tsx @@ -1,10 +1,9 @@ -import { useSession, useUser } from '@clerk/shared/react'; +import { __experimental_useReverification as useReverification, useSession, useUser } from '@clerk/shared/react'; import type { SessionWithActivitiesResource } from '@clerk/types'; import { Badge, Col, descriptors, Flex, Icon, localizationKeys, Text, useLocalizations } from '../../customizables'; import { FullHeightLoader, ProfileSection, ThreeDotsMenu } from '../../elements'; import { useFetch, useLoadingStatus } from '../../hooks'; -import { useAssurance } from '../../hooks/useAssurance'; import { DeviceLaptop, DeviceMobile } from '../../icons'; import { mqu, type PropsOfComponent } from '../../styledSystem'; import { getRelativeToNowDateKey } from '../../utils'; @@ -49,7 +48,7 @@ export const ActiveDevicesSection = () => { const DeviceItem = ({ session }: { session: SessionWithActivitiesResource }) => { const isCurrent = useSession().session?.id === session.id; const status = useLoadingStatus(); - const { handleAssurance } = useAssurance(); + const [revokeSession] = useReverification(session.revoke); const revoke = async () => { if (isCurrent || !session) { @@ -57,7 +56,7 @@ const DeviceItem = ({ session }: { session: SessionWithActivitiesResource }) => } status.setLoading(); return ( - handleAssurance(() => session.revoke()) + revokeSession() // TODO-STEPUP: Properly handler the response with a setCardError .catch(() => {}) .finally(() => status.setIdle()) diff --git a/packages/clerk-js/src/ui/components/UserProfile/AddAuthenticatorApp.tsx b/packages/clerk-js/src/ui/components/UserProfile/AddAuthenticatorApp.tsx index 8b71e7ba476..78dcbc6abcd 100644 --- a/packages/clerk-js/src/ui/components/UserProfile/AddAuthenticatorApp.tsx +++ b/packages/clerk-js/src/ui/components/UserProfile/AddAuthenticatorApp.tsx @@ -1,5 +1,5 @@ import { isClerkRuntimeError } from '@clerk/shared'; -import { useUser } from '@clerk/shared/react'; +import { __experimental_useReverification as useReverification, useUser } from '@clerk/shared/react'; import type { TOTPResource } from '@clerk/types'; import React from 'react'; @@ -16,7 +16,6 @@ import { withCardStateProvider, } from '../../elements'; import { useActionContext } from '../../elements/Action/ActionRoot'; -import { useAssurance } from '../../hooks/useAssurance'; import { handleError } from '../../utils'; type AddAuthenticatorAppProps = FormProps & { @@ -29,7 +28,12 @@ export const AddAuthenticatorApp = withCardStateProvider((props: AddAuthenticato const { title, onSuccess, onReset } = props; const { user } = useUser(); const card = useCardState(); - const { handleAssurance } = useAssurance(); + const [createTOTP] = useReverification(() => { + if (!user) { + return Promise.resolve(undefined); + } + return user.createTOTP(); + }); const { close } = useActionContext(); const [totp, setTOTP] = React.useState(undefined); const [displayFormat, setDisplayFormat] = React.useState('qr'); @@ -41,10 +45,10 @@ export const AddAuthenticatorApp = withCardStateProvider((props: AddAuthenticato return; } - void handleAssurance(user.createTOTP) - .then((totp: TOTPResource) => setTOTP(totp)) + void createTOTP() + .then(totp => setTOTP(totp)) .catch(err => { - if (isClerkRuntimeError(err) && err.code === 'assurance_cancelled') { + if (isClerkRuntimeError(err) && err.code === 'reverification_cancelled') { return close(); } return handleError(err, [], card.setError); diff --git a/packages/clerk-js/src/ui/components/UserProfile/ConnectedAccountsMenu.tsx b/packages/clerk-js/src/ui/components/UserProfile/ConnectedAccountsMenu.tsx index 6c7fa6ad2e2..fbb2aab9743 100644 --- a/packages/clerk-js/src/ui/components/UserProfile/ConnectedAccountsMenu.tsx +++ b/packages/clerk-js/src/ui/components/UserProfile/ConnectedAccountsMenu.tsx @@ -1,4 +1,4 @@ -import { useUser } from '@clerk/shared/react'; +import { __experimental_useReverification as useReverification, useUser } from '@clerk/shared/react'; import type { OAuthProvider, OAuthStrategy } from '@clerk/types'; import { appendModalState } from '../../../utils'; @@ -7,49 +7,66 @@ import { useUserProfileContext } from '../../contexts'; import { descriptors, Image, localizationKeys } from '../../customizables'; import { ProfileSection, useCardState } from '../../elements'; import { useEnabledThirdPartyProviders } from '../../hooks'; -import { useAssurance } from '../../hooks/useAssurance'; import { useRouter } from '../../router'; import { handleError, sleep } from '../../utils'; -export const AddConnectedAccount = () => { +const ConnectMenuButton = (props: { strategy: OAuthStrategy }) => { + const { strategy } = props; const card = useCardState(); const { user } = useUser(); const { navigate } = useRouter(); - const { strategies, strategyToDisplayData } = useEnabledThirdPartyProviders(); + const { strategyToDisplayData } = useEnabledThirdPartyProviders(); const { additionalOAuthScopes, componentName, mode } = useUserProfileContext(); - - const { handleAssurance } = useAssurance(); const isModal = mode === 'modal'; - const enabledStrategies = strategies.filter(s => s.startsWith('oauth')) as OAuthStrategy[]; - const connectedStrategies = user?.verifiedExternalAccounts.map(a => `oauth_${a.provider}`) as OAuthStrategy[]; - - const unconnectedStrategies = enabledStrategies.filter(provider => { - return !connectedStrategies.includes(provider); - }); - - const connect = (strategy: OAuthStrategy) => { + const [createExternalAccount] = useReverification(() => { if (!user) { - return; + 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({ + strategy, + redirectUrl, + additionalScopes, + }); + }); + + const imageOrInitial = strategyToDisplayData[strategy].iconUrl ? ( + {`Connect ({ width: theme.sizes.$4 })} + /> + ) : ( + + ); + + const connect = () => { + if (!user) { + return; + } + // TODO: Decide if we should keep using this strategy // If yes, refactor and cleanup: card.setLoading(strategy); - return handleAssurance(() => - user.createExternalAccount({ - strategy, - redirectUrl, - additionalScopes, - }), - ) + return createExternalAccount() .then(res => { - if (res.verification?.externalVerificationRedirectURL) { + if (res && res.verification?.externalVerificationRedirectURL) { void sleep(2000).then(() => card.setIdle(strategy)); void navigate(res.verification.externalVerificationRedirectURL.href); } @@ -60,6 +77,39 @@ export const AddConnectedAccount = () => { }); }; + return ( + ({ + justifyContent: 'start', + gap: t.space.$2, + })} + leftIcon={imageOrInitial} + /> + ); +}; + +export const AddConnectedAccount = () => { + const { user } = useUser(); + const { strategies } = useEnabledThirdPartyProviders(); + + const enabledStrategies = strategies.filter(s => s.startsWith('oauth')) as OAuthStrategy[]; + const connectedStrategies = user?.verifiedExternalAccounts.map(a => `oauth_${a.provider}`) as OAuthStrategy[]; + + const unconnectedStrategies = enabledStrategies.filter(provider => { + return !connectedStrategies.includes(provider); + }); + if (unconnectedStrategies.length === 0) { return null; } @@ -69,47 +119,12 @@ export const AddConnectedAccount = () => { triggerLocalizationKey={localizationKeys('userProfile.start.connectedAccountsSection.primaryButton')} id='connectedAccounts' > - {unconnectedStrategies.map(strategy => { - const imageOrInitial = strategyToDisplayData[strategy].iconUrl ? ( - {`Connect ({ width: theme.sizes.$4 })} - /> - ) : ( - - ); - - return ( - connect(strategy)} - isDisabled={card.isLoading} - variant='ghost' - isLoading={card.loadingMetadata === strategy} - focusRing={false} - closeAfterClick={false} - localizationKey={localizationKeys('userProfile.connectedAccountPage.socialButtonsBlockButton', { - provider: strategyToDisplayData[strategy].name, - })} - sx={t => ({ - justifyContent: 'start', - gap: t.space.$2, - })} - leftIcon={imageOrInitial} - /> - ); - })} + {unconnectedStrategies.map(strategy => ( + + ))} ); }; diff --git a/packages/clerk-js/src/ui/components/UserProfile/ConnectedAccountsSection.tsx b/packages/clerk-js/src/ui/components/UserProfile/ConnectedAccountsSection.tsx index fa31a77fa37..75335459de3 100644 --- a/packages/clerk-js/src/ui/components/UserProfile/ConnectedAccountsSection.tsx +++ b/packages/clerk-js/src/ui/components/UserProfile/ConnectedAccountsSection.tsx @@ -1,4 +1,4 @@ -import { useUser } from '@clerk/shared/react'; +import { __experimental_useReverification as useReverification, useUser } from '@clerk/shared/react'; import type { ExternalAccountResource, OAuthProvider, OAuthScope, OAuthStrategy } from '@clerk/types'; import { appendModalState } from '../../../utils'; @@ -9,7 +9,6 @@ import { Card, ProfileSection, ThreeDotsMenu, useCardState, withCardStateProvide import { Action } from '../../elements/Action'; import { useActionContext } from '../../elements/Action/ActionRoot'; import { useEnabledThirdPartyProviders } from '../../hooks'; -import { useAssurance } from '../../hooks/useAssurance'; import { useRouter } from '../../router'; import type { PropsOfComponent } from '../../styledSystem'; import { handleError } from '../../utils'; @@ -90,13 +89,31 @@ const ConnectedAccount = ({ account }: { account: ExternalAccountResource }) => const { navigate } = useRouter(); const { user } = useUser(); const card = useCardState(); - const { handleAssurance } = useAssurance(); + + const isModal = mode === 'modal'; + const redirectUrl = isModal + ? appendModalState({ + url: window.location.href, + componentName, + }) + : window.location.href; + + const [createExternalAccount] = useReverification(() => { + if (!user) { + return Promise.resolve(undefined); + } + + return user.createExternalAccount({ + strategy: account.verification!.strategy as OAuthStrategy, + redirectUrl, + additionalScopes, + }); + }); if (!user) { return null; } - const isModal = mode === 'modal'; const { providerToDisplayData } = useEnabledThirdPartyProviders(); const label = account.username || account.emailAddress; const fallbackErrorMessage = account.verification?.error?.longMessage; @@ -113,20 +130,16 @@ const ConnectedAccount = ({ account }: { account: ExternalAccountResource }) => const redirectUrl = isModal ? appendModalState({ url: window.location.href, componentName }) : window.location.href; try { - let response: ExternalAccountResource; + let response: ExternalAccountResource | undefined; if (reauthorizationRequired) { response = await account.reauthorize({ additionalScopes, redirectUrl }); } else { - response = await handleAssurance(() => - user.createExternalAccount({ - strategy: account.verification!.strategy as OAuthStrategy, - redirectUrl, - additionalScopes, - }), - ); + response = await createExternalAccount(); } - await navigate(response.verification!.externalVerificationRedirectURL?.href || ''); + if (response) { + await navigate(response.verification!.externalVerificationRedirectURL?.href || ''); + } } catch (err) { handleError(err, [], card.setError); } diff --git a/packages/clerk-js/src/ui/components/UserProfile/DeleteUserForm.tsx b/packages/clerk-js/src/ui/components/UserProfile/DeleteUserForm.tsx index 75b3de7a288..25fc2999198 100644 --- a/packages/clerk-js/src/ui/components/UserProfile/DeleteUserForm.tsx +++ b/packages/clerk-js/src/ui/components/UserProfile/DeleteUserForm.tsx @@ -1,10 +1,9 @@ -import { 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'; import type { FormProps } from '../../elements'; import { Form, FormButtons, FormContainer, useCardState, withCardStateProvider } from '../../elements'; -import { useAssurance } from '../../hooks/useAssurance'; import { useMultipleSessions } from '../../hooks/useMultipleSessions'; import { handleError, useFormControl } from '../../utils'; @@ -17,7 +16,13 @@ export const DeleteUserForm = withCardStateProvider((props: DeleteUserFormProps) const { t } = useLocalizations(); const { otherSessions } = useMultipleSessions({ user }); const { setActive } = useClerk(); - const { handleAssurance } = useAssurance(); + const [deleteUserWithReverification] = useReverification(() => { + if (!user) { + return Promise.resolve(undefined); + } + + return user.delete(); + }); const confirmationField = useFormControl('deleteConfirmation', '', { type: 'text', @@ -36,11 +41,7 @@ export const DeleteUserForm = withCardStateProvider((props: DeleteUserFormProps) } try { - if (!user) { - throw Error('user is not defined'); - } - - await handleAssurance(user.delete); + await deleteUserWithReverification(); const redirectUrl = otherSessions.length === 0 ? afterSignOutUrl : afterMultiSessionSingleSignOutUrl; return await setActive({ diff --git a/packages/clerk-js/src/ui/components/UserProfile/EmailForm.tsx b/packages/clerk-js/src/ui/components/UserProfile/EmailForm.tsx index 730b69ddd37..deeb86b8daf 100644 --- a/packages/clerk-js/src/ui/components/UserProfile/EmailForm.tsx +++ b/packages/clerk-js/src/ui/components/UserProfile/EmailForm.tsx @@ -1,4 +1,4 @@ -import { useUser } from '@clerk/shared/react'; +import { __experimental_useReverification as useReverification, useUser } from '@clerk/shared/react'; import type { EmailAddressResource } from '@clerk/types'; import React from 'react'; @@ -7,7 +7,6 @@ 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'; import { emailLinksEnabledForInstance } from './utils'; import { VerifyWithCode } from './VerifyWithCode'; @@ -20,10 +19,16 @@ export const EmailForm = withCardStateProvider((props: EmailFormProps) => { const { emailId: id, onSuccess, onReset } = props; const card = useCardState(); const { user } = useUser(); - const { handleAssurance } = useAssurance(); const environment = useEnvironment(); const preferEmailLinks = emailLinksEnabledForInstance(environment); + const [createEmailAddress] = useReverification(() => { + if (!user) { + return Promise.resolve(undefined); + } + return user.createEmailAddress({ email: emailField.value }); + }); + const emailAddressRef = React.useRef(user?.emailAddresses.find(a => a.id === id)); const wizard = useWizard({ defaultStep: emailAddressRef.current ? 1 : 0, @@ -44,7 +49,7 @@ export const EmailForm = withCardStateProvider((props: EmailFormProps) => { if (!user) { return; } - return handleAssurance(() => user.createEmailAddress({ email: emailField.value })) + return createEmailAddress() .then(res => { emailAddressRef.current = res; wizard.nextStep(); diff --git a/packages/clerk-js/src/ui/components/UserProfile/MfaBackupCodeCreateForm.tsx b/packages/clerk-js/src/ui/components/UserProfile/MfaBackupCodeCreateForm.tsx index 4143d4335ea..0dd61ccb359 100644 --- a/packages/clerk-js/src/ui/components/UserProfile/MfaBackupCodeCreateForm.tsx +++ b/packages/clerk-js/src/ui/components/UserProfile/MfaBackupCodeCreateForm.tsx @@ -1,4 +1,4 @@ -import { useUser } from '@clerk/shared/react'; +import { __experimental_useReverification as useReverification, useUser } from '@clerk/shared/react'; import type { BackupCodeResource } from '@clerk/types'; import React from 'react'; @@ -11,7 +11,6 @@ import { useCardState, withCardStateProvider, } from '../../elements'; -import { useAssurance } from '../../hooks/useAssurance'; import { handleError } from '../../utils'; import { MfaBackupCodeList } from './MfaBackupCodeList'; @@ -20,7 +19,12 @@ export const MfaBackupCodeCreateForm = withCardStateProvider((props: MfaBackupCo const { onSuccess } = props; const { user } = useUser(); const card = useCardState(); - const { handleAssurance } = useAssurance(); + const [createBackupCode] = useReverification(() => { + if (!user) { + return Promise.resolve(undefined); + } + return user.createBackupCode(); + }); const [backupCode, setBackupCode] = React.useState(undefined); React.useEffect(() => { @@ -28,8 +32,8 @@ export const MfaBackupCodeCreateForm = withCardStateProvider((props: MfaBackupCo return; } - void handleAssurance(user.createBackupCode) - .then((backupCode: BackupCodeResource) => setBackupCode(backupCode)) + void createBackupCode() + .then(backupCode => setBackupCode(backupCode)) .catch(err => handleError(err, [], card.setError)); }, []); diff --git a/packages/clerk-js/src/ui/components/UserProfile/MfaPhoneCodeScreen.tsx b/packages/clerk-js/src/ui/components/UserProfile/MfaPhoneCodeScreen.tsx index 0a882799438..7dc1762ddde 100644 --- a/packages/clerk-js/src/ui/components/UserProfile/MfaPhoneCodeScreen.tsx +++ b/packages/clerk-js/src/ui/components/UserProfile/MfaPhoneCodeScreen.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'; @@ -15,7 +15,6 @@ import { useCardState, withCardStateProvider, } from '../../elements'; -import { useAssurance } from '../../hooks/useAssurance'; import { Plus } from '../../icons'; import { getCountryFromPhoneString, handleError, stringToFormattedPhoneString } from '../../utils'; import { MfaBackupCodeList } from './MfaBackupCodeList'; @@ -77,26 +76,26 @@ type AddMfaProps = FormProps & { resourceRef: React.MutableRefObject; }; -const AddMfa = (props: AddMfaProps) => { - const { onSuccess, onReset, title, onAddPhoneClick, onUnverifiedPhoneClick, resourceRef } = props; +const EnableMFAButtonForPhone = ( + props: { + phone: PhoneNumberResource; + } & Pick, +) => { + const { phone, onSuccess, onUnverifiedPhoneClick, resourceRef } = props; const card = useCardState(); - const { user } = useUser(); - const { handleAssurance } = useAssurance(); + const [setReservedForSecondFactor] = useReverification(() => phone.setReservedForSecondFactor({ reserved: true })); - if (!user) { - return null; - } + const { country } = getCountryFromPhoneString(phone.phoneNumber); + const formattedPhone = stringToFormattedPhoneString(phone.phoneNumber); - const availableMethods = user.phoneNumbers.filter(p => !p.reservedForSecondFactor); - - const enableMfa = async (phone: PhoneNumberResource) => { + const enableMfa = async () => { if (phone.verification.status !== 'verified') { return onUnverifiedPhoneClick(phone); } card.setLoading(phone.id); try { - await handleAssurance(() => phone.setReservedForSecondFactor({ reserved: true })); + await setReservedForSecondFactor(); resourceRef.current = phone; onSuccess(); } catch (err) { @@ -106,6 +105,31 @@ const AddMfa = (props: AddMfaProps) => { } }; + 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') } > - + { - 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/clerk-js/src/ui/hooks/useAssurance.ts b/packages/clerk-js/src/ui/hooks/useAssurance.ts deleted file mode 100644 index 53460b44902..00000000000 --- a/packages/clerk-js/src/ui/hooks/useAssurance.ts +++ /dev/null @@ -1,145 +0,0 @@ -import { ClerkRuntimeError, isClerkAPIResponseError } from '@clerk/shared/error'; -import { useClerk } from '@clerk/shared/react'; -import type { 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'; - }; -}; - -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; } diff --git a/packages/nextjs/src/app-router/client/ClerkProvider.tsx b/packages/nextjs/src/app-router/client/ClerkProvider.tsx index 8b9099a6198..3faaaefb26c 100644 --- a/packages/nextjs/src/app-router/client/ClerkProvider.tsx +++ b/packages/nextjs/src/app-router/client/ClerkProvider.tsx @@ -97,13 +97,15 @@ 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()); + } }); }; diff --git a/packages/nextjs/src/client-boundary/hooks.ts b/packages/nextjs/src/client-boundary/hooks.ts index b3756943a47..3ef5c09f780 100644 --- a/packages/nextjs/src/client-boundary/hooks.ts +++ b/packages/nextjs/src/client-boundary/hooks.ts @@ -10,6 +10,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..788a84b1e2e 100644 --- a/packages/react/src/hooks/index.ts +++ b/packages/react/src/hooks/index.ts @@ -9,4 +9,5 @@ export { useSessionList, useUser, useSession, + __experimental_useReverification, } from '@clerk/shared/react'; diff --git a/packages/shared/package.json b/packages/shared/package.json index a64120664b9..b9edb2cef40 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..350e40dcb92 --- /dev/null +++ b/packages/shared/src/authorization-errors.ts @@ -0,0 +1,48 @@ +import type { __experimental_ReverificationConfig } from '@clerk/types'; + +type ClerkError = { + clerk_error: T; +}; + +type ReverificationMismatchError = ClerkError< + { + type: 'forbidden'; + reason: 'reverification-mismatch'; + } & M +>; + +const __experimental_reverificationMismatch = (missingConfig?: MC) => + ({ + clerk_error: { + type: 'forbidden', + reason: 'reverification-mismatch', + metadata: { + reverification: missingConfig, + }, + }, + }) satisfies ReverificationMismatchError; + +const __experimental_reverificationMismatchResponse = ( + ...args: Parameters +) => + new Response(JSON.stringify(__experimental_reverificationMismatch(...args)), { + status: 403, + }); + +const __experimental_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' + ); +}; + +export { + __experimental_reverificationMismatch, + __experimental_reverificationMismatchResponse, + __experimental_isReverificationHint, +}; diff --git a/packages/shared/src/react/hooks/index.ts b/packages/shared/src/react/hooks/index.ts index b55def59478..61665439f82 100644 --- a/packages/shared/src/react/hooks/index.ts +++ b/packages/shared/src/react/hooks/index.ts @@ -7,3 +7,4 @@ export { useSessionList } from './useSessionList'; export { useUser } from './useUser'; export { useClerk } from './useClerk'; export { useDeepEqualMemo, isDeeplyEqual } from './useDeepEqualMemo'; +export { __experimental_useReverification } from './useReverification'; diff --git a/packages/shared/src/react/hooks/useReverification.ts b/packages/shared/src/react/hooks/useReverification.ts new file mode 100644 index 00000000000..091ded297bc --- /dev/null +++ b/packages/shared/src/react/hooks/useReverification.ts @@ -0,0 +1,96 @@ +import type { Clerk } from '@clerk/types'; +import { useMemo, useRef } from 'react'; + +import { __experimental_isReverificationHint, __experimental_reverificationMismatch } from '../../authorization-errors'; +import { ClerkRuntimeError, isClerkAPIResponseError } from '../../error'; +import { createDeferredPromise } from '../../utils/createDeferredPromise'; +import { useClerk } from './useClerk'; +import { useSafeLayoutEffect } from './useSafeLayoutEffect'; + +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 __experimental_reverificationMismatch(); + } + + // rethrow + throw e; + }); +} + +function createReverificationHandler(params: { onOpenModal: Clerk['__experimental_openUserVerification'] }) { + function assertReverification Promise>(fetcher: Fetcher): Fetcher { + return (async (...args) => { + let result = await resolveResult(fetcher(...args)); + + if (__experimental_isReverificationHint(result)) { + /** + * Create a promise + */ + const resolvers = createDeferredPromise(); + + /** + * 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: 'reverification_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(...args)); + } + + return result; + }) as Fetcher; + } + + return assertReverification; +} + +function __experimental_useReverification Promise>(fetcher: Fetcher): readonly [Fetcher] { + const { __experimental_openUserVerification } = useClerk(); + const fetcherRef = useRef(fetcher); + + 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; +} + +export { __experimental_useReverification };