Skip to content

Commit 08c5a2a

Browse files
authored
feat(clerk-react,nextjs,shared): Introduce experimental useReverification (#4362)
1 parent 24cd779 commit 08c5a2a

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

42 files changed

+690
-338
lines changed

.changeset/clever-lions-marry.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@clerk/nextjs": minor
3+
---
4+
5+
Bug fix: For next>=14 applications resolve `__unstable__onBeforeSetActive` once `invalidateCacheAction` resolves.

.changeset/five-insects-grin.md

+33
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
---
2+
"@clerk/nextjs": minor
3+
"@clerk/clerk-react": minor
4+
---
5+
6+
Introduce a new experimental hook called `useReverification` that makes it easy to handle reverification errors.
7+
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.
8+
When such error is returned, the recommended UX is to offer a way to the user to recover by re-verifying their credentials.
9+
This helper will automatically handle this flow in the developer's behalf, by displaying a modal the end-user can interact with.
10+
Upon completion, the original request that previously failed, will be retried (only once).
11+
12+
Example with clerk-js methods.
13+
```tsx
14+
import { __experimental_useReverification as useReverification } from '@clerk/nextjs';
15+
16+
function DeleteAccount() {
17+
const { user } = useUser();
18+
const [deleteUserAccount] = useReverification(() => {
19+
if (!user) return;
20+
return user.delete()
21+
});
22+
23+
return <>
24+
<button
25+
onClick={async () => {
26+
await deleteUserAccount();
27+
}}>
28+
Delete account
29+
</button>
30+
</>
31+
}
32+
33+
```

.changeset/ninety-rabbits-itch.md

+7
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@clerk/shared": minor
3+
---
4+
5+
Introduce experimental reverification error helpers.
6+
- `reverificationMismatch` returns the error as an object which can later be used as a return value from a React Server Action.
7+
- `reverificationMismatchResponse` returns a Response with the above object serialized. It can be used in any Backend Javascript frameworks that supports `Response`.

.changeset/serious-poems-retire.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@clerk/clerk-js": patch
3+
---
4+
5+
Chore: Replace beforeEmit with an explicit call after `setActive`, inside the experimental UserVerification.

integration/playwright.config.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ export const common: PlaywrightTestConfig = {
1313
fullyParallel: true,
1414
forbidOnly: !!process.env.CI,
1515
retries: process.env.CI ? 2 : 0,
16-
timeout: 90000,
16+
timeout: 90_000,
1717
maxFailures: process.env.CI ? 1 : undefined,
1818
workers: process.env.CI ? '50%' : '70%',
1919
reporter: process.env.CI ? 'line' : 'list',

integration/presets/envs.ts

+8
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,13 @@ const withCustomRoles = base
5151
.setEnvVariable('private', 'CLERK_SECRET_KEY', instanceKeys.get('with-custom-roles').sk)
5252
.setEnvVariable('public', 'CLERK_PUBLISHABLE_KEY', instanceKeys.get('with-custom-roles').pk);
5353

54+
const withReverification = base
55+
.clone()
56+
.setId('withReverification')
57+
.setEnvVariable('private', 'CLERK_SECRET_KEY', instanceKeys.get('with-reverification').sk)
58+
.setEnvVariable('public', 'CLERK_PUBLISHABLE_KEY', instanceKeys.get('with-reverification').pk)
59+
.setEnvVariable('private', 'CLERK_ENCRYPTION_KEY', constants.E2E_CLERK_ENCRYPTION_KEY || 'a-key');
60+
5461
const withEmailCodesQuickstart = withEmailCodes
5562
.clone()
5663
.setEnvVariable('public', 'CLERK_SIGN_IN_URL', '')
@@ -106,6 +113,7 @@ export const envs = {
106113
withEmailCodes_destroy_client,
107114
withEmailLinks,
108115
withCustomRoles,
116+
withReverification,
109117
withEmailCodesQuickstart,
110118
withAPCore1ClerkLatest,
111119
withAPCore1ClerkV4,

integration/presets/longRunningApps.ts

+1
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ export const createLongRunningApps = () => {
2929
env: envs.withEmailCodes_destroy_client,
3030
},
3131
{ id: 'next.appRouter.withCustomRoles', config: next.appRouter, env: envs.withCustomRoles },
32+
{ id: 'next.appRouter.withReverification', config: next.appRouter, env: envs.withReverification },
3233
{ id: 'quickstart.next.appRouter', config: next.appRouterQuickstart, env: envs.withEmailCodesQuickstart },
3334
{ id: 'elements.next.appRouter', config: elements.nextAppRouter, env: envs.withEmailCodes },
3435
{ id: 'astro.node.withCustomRoles', config: astro.node, env: envs.withCustomRoles },

integration/presets/next.ts

+3-1
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { applicationConfig } from '../models/applicationConfig.js';
33
import { templates } from '../templates/index.js';
44

55
const clerkNextjsLocal = `file:${process.cwd()}/packages/nextjs`;
6+
const clerkSharedLocal = `file:${process.cwd()}/packages/shared`;
67
const appRouter = applicationConfig()
78
.setName('next-app-router')
89
.useTemplate(templates['next-app-router'])
@@ -14,7 +15,8 @@ const appRouter = applicationConfig()
1415
.addDependency('next', constants.E2E_NEXTJS_VERSION)
1516
.addDependency('react', constants.E2E_REACT_VERSION)
1617
.addDependency('react-dom', constants.E2E_REACT_DOM_VERSION)
17-
.addDependency('@clerk/nextjs', constants.E2E_CLERK_VERSION || clerkNextjsLocal);
18+
.addDependency('@clerk/nextjs', constants.E2E_CLERK_VERSION || clerkNextjsLocal)
19+
.addDependency('@clerk/shared', clerkSharedLocal);
1820

1921
const appRouterTurbo = appRouter
2022
.clone()

integration/templates/next-app-router/next.config.js

+3
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@ const nextConfig = {
33
eslint: {
44
ignoreDuringBuilds: true,
55
},
6+
experimental: {
7+
serverActions: true,
8+
},
69
};
710

811
module.exports = nextConfig;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
'use client';
2+
import { useState, useTransition } from 'react';
3+
import { __experimental_useReverification as useReverification } from '@clerk/nextjs';
4+
import { logUserIdActionReverification } from '@/app/(reverification)/actions';
5+
6+
function Page() {
7+
const [logUserWithReverification] = useReverification(logUserIdActionReverification);
8+
const [pending, startTransition] = useTransition();
9+
const [res, setRes] = useState(null);
10+
11+
return (
12+
<>
13+
<button
14+
disabled={pending}
15+
onClick={() => {
16+
startTransition(async () => {
17+
await logUserWithReverification().then(e => {
18+
setRes(e as any);
19+
});
20+
});
21+
}}
22+
>
23+
LogUserId
24+
</button>
25+
<pre>{JSON.stringify(res)}</pre>
26+
</>
27+
);
28+
}
29+
30+
export default Page;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
'use server';
2+
3+
import { auth } from '@clerk/nextjs/server';
4+
import { __experimental_reverificationMismatch as reverificationMismatch } from '@clerk/shared/authorization-errors';
5+
6+
const logUserIdActionReverification = async () => {
7+
const { userId, has } = await auth.protect();
8+
9+
const config = {
10+
level: 'secondFactor',
11+
afterMinutes: 1,
12+
} as const;
13+
14+
const userNeedsReverification = !has({
15+
__experimental_reverification: config,
16+
});
17+
18+
if (userNeedsReverification) {
19+
return reverificationMismatch(config);
20+
}
21+
22+
return {
23+
userId,
24+
};
25+
};
26+
27+
export { logUserIdActionReverification };
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
'use client';
2+
import { useState, useTransition } from 'react';
3+
4+
export function ButtonAction({ action }: { action: () => Promise<any> }) {
5+
const [pending, startTransition] = useTransition();
6+
const [res, setRes] = useState(null);
7+
8+
return (
9+
<>
10+
<button
11+
disabled={pending}
12+
onClick={() => {
13+
startTransition(async () => {
14+
await action().then(setRes);
15+
});
16+
}}
17+
>
18+
LogUserId
19+
</button>
20+
<pre>{JSON.stringify(res)}</pre>
21+
</>
22+
);
23+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { logUserIdActionReverification } from '../actions';
2+
import { ButtonAction } from '../button-action';
3+
4+
function Page() {
5+
return <ButtonAction action={logUserIdActionReverification} />;
6+
}
7+
8+
export default Page;

integration/testUtils/index.ts

+2
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { createUserButtonPageObject } from './userButtonPageObject';
1414
import { createUserProfileComponentPageObject } from './userProfilePageObject';
1515
import type { FakeOrganization, FakeUser } from './usersService';
1616
import { createUserService } from './usersService';
17+
import { createUserVerificationComponentPageObject } from './userVerificationPageObject';
1718

1819
export type { FakeUser, FakeOrganization };
1920
const createClerkClient = (app: Application) => {
@@ -91,6 +92,7 @@ export const createTestUtils = <
9192
userProfile: createUserProfileComponentPageObject(testArgs),
9293
organizationSwitcher: createOrganizationSwitcherComponentPageObject(testArgs),
9394
userButton: createUserButtonPageObject(testArgs),
95+
userVerification: createUserVerificationComponentPageObject(testArgs),
9496
expect: createExpectPageObject(testArgs),
9597
clerk: createClerkUtils(testArgs),
9698
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import type { Browser, BrowserContext } from '@playwright/test';
2+
3+
import type { createAppPageObject } from './appPageObject';
4+
import { common } from './commonPageObject';
5+
6+
export type EnchancedPage = ReturnType<typeof createAppPageObject>;
7+
export type TestArgs = { page: EnchancedPage; context: BrowserContext; browser: Browser };
8+
9+
export const createUserVerificationComponentPageObject = (testArgs: TestArgs) => {
10+
const { page } = testArgs;
11+
const self = {
12+
...common(testArgs),
13+
waitForMounted: (selector = '.cl-userVerification-root') => {
14+
return page.waitForSelector(selector, { state: 'attached' });
15+
},
16+
getUseAnotherMethodLink: () => {
17+
return page.getByRole('link', { name: /use another method/i });
18+
},
19+
getAltMethodsEmailCodeButton: () => {
20+
return page.getByRole('button', { name: /email code to/i });
21+
},
22+
getAltMethodsEmailLinkButton: () => {
23+
return page.getByRole('button', { name: /email link to/i });
24+
},
25+
};
26+
return self;
27+
};
+104
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import type { OrganizationMembershipRole } from '@clerk/backend';
2+
import { expect, test } from '@playwright/test';
3+
4+
import { appConfigs } from '../presets';
5+
import type { FakeOrganization, FakeUser } from '../testUtils';
6+
import { createTestUtils, testAgainstRunningApps } from '../testUtils';
7+
8+
const utils = [
9+
'action',
10+
// , 'route'
11+
];
12+
const capitalize = (type: string) => type[0].toUpperCase() + type.slice(1);
13+
testAgainstRunningApps({ withEnv: [appConfigs.envs.withReverification] })(
14+
'@nextjs require re-verification',
15+
({ app }) => {
16+
test.describe.configure({ mode: 'parallel' });
17+
18+
let fakeAdmin: FakeUser;
19+
let fakeViewer: FakeUser;
20+
let fakeOrganization: FakeOrganization;
21+
22+
test.beforeAll(async () => {
23+
const m = createTestUtils({ app });
24+
fakeAdmin = m.services.users.createFakeUser();
25+
const admin = await m.services.users.createBapiUser(fakeAdmin);
26+
fakeOrganization = await m.services.users.createFakeOrganization(admin.id);
27+
fakeViewer = m.services.users.createFakeUser();
28+
const viewer = await m.services.users.createBapiUser(fakeViewer);
29+
await m.services.clerk.organizations.createOrganizationMembership({
30+
organizationId: fakeOrganization.organization.id,
31+
role: 'org:viewer' as OrganizationMembershipRole,
32+
userId: viewer.id,
33+
});
34+
});
35+
36+
test.afterAll(async () => {
37+
await fakeOrganization.delete();
38+
await fakeViewer.deleteIfExists();
39+
await fakeAdmin.deleteIfExists();
40+
await app.teardown();
41+
});
42+
43+
utils.forEach(type => {
44+
test(`reverification error from ${capitalize(type)}`, async ({ page, context }) => {
45+
test.setTimeout(270_000);
46+
const u = createTestUtils({ app, page, context });
47+
48+
await u.po.signIn.goTo();
49+
await u.po.signIn.waitForMounted();
50+
await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeAdmin.email, password: fakeAdmin.password });
51+
await u.po.expect.toBeSignedIn();
52+
53+
await u.po.organizationSwitcher.goTo();
54+
await u.po.organizationSwitcher.waitForMounted();
55+
await u.po.organizationSwitcher.waitForAnOrganizationToSelected();
56+
57+
await u.page.goToRelative(`/requires-re-verification`);
58+
await u.page.getByRole('button', { name: /LogUserId/i }).click();
59+
await expect(u.page.getByText(/\{\s*"userId"\s*:\s*"user_[^"]+"\s*\}/i)).toBeVisible();
60+
61+
const total = 1000 * 120;
62+
await page.waitForTimeout(total / 3);
63+
await page.waitForTimeout(total / 3);
64+
await u.po.userProfile.goTo();
65+
await page.waitForTimeout(total / 3);
66+
await u.page.goToRelative(`/requires-re-verification`);
67+
await u.page.getByRole('button', { name: /LogUserId/i }).click();
68+
await expect(
69+
u.page.getByText(
70+
/\{\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,
71+
),
72+
).toBeVisible();
73+
});
74+
75+
test(`reverification recovery from ${capitalize(type)}`, async ({ page, context }) => {
76+
test.setTimeout(270_000);
77+
const u = createTestUtils({ app, page, context });
78+
79+
await u.po.signIn.goTo();
80+
await u.po.signIn.waitForMounted();
81+
await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeAdmin.email, password: fakeAdmin.password });
82+
await u.po.expect.toBeSignedIn();
83+
84+
await u.po.organizationSwitcher.goTo();
85+
await u.po.organizationSwitcher.waitForMounted();
86+
await u.po.organizationSwitcher.waitForAnOrganizationToSelected();
87+
88+
await u.page.goToRelative(`/requires-re-verification`);
89+
await u.page.getByRole('button', { name: /LogUserId/i }).click();
90+
await expect(u.page.getByText(/\{\s*"userId"\s*:\s*"user_[^"]+"\s*\}/i)).toBeVisible();
91+
92+
const total = 1000 * 120;
93+
await page.waitForTimeout(total / 3);
94+
await page.waitForTimeout(total / 3);
95+
await u.po.userProfile.goTo();
96+
await page.waitForTimeout(total / 3);
97+
await u.page.goToRelative(`/action-with-use-reverification`);
98+
await u.po.expect.toBeSignedIn();
99+
await u.page.getByRole('button', { name: /LogUserId/i }).click();
100+
await u.po.userVerification.waitForMounted();
101+
});
102+
});
103+
},
104+
);

package-lock.json

-1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/chrome-extension/src/__tests__/__snapshots__/exports.test.ts.snap

+1
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ exports[`public exports should not include a breaking change 1`] = `
2727
"SignedOut",
2828
"UserButton",
2929
"UserProfile",
30+
"__experimental_useReverification",
3031
"useAuth",
3132
"useClerk",
3233
"useEmailLink",

0 commit comments

Comments
 (0)