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 ? (
+ ({ 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 ? (
- ({ 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 };