Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
27bf018
utilities for generating the authorization error objects
panteliselef Oct 3, 2024
148be60
wip useReverification
panteliselef Oct 11, 2024
5290f20
fix setActive inside transitions for next >= 14
panteliselef Oct 15, 2024
4467912
only keep re-verification errors
panteliselef Oct 18, 2024
e1c3987
cleanup useReverification
panteliselef Oct 18, 2024
993ceaf
add changesets
panteliselef Oct 18, 2024
43fac9c
fix formatting
panteliselef Oct 18, 2024
b339816
update snapshot for clerk-extension
panteliselef Oct 18, 2024
a574620
update e2e tests for reverification
panteliselef Oct 18, 2024
fe92ddc
Merge branch 'refs/heads/main' into elef/user-896-introduce-usereveri…
panteliselef Oct 18, 2024
2238126
fix __unstable__onAfterSetActive definition
panteliselef Oct 22, 2024
3e87422
Merge branch 'refs/heads/main' into elef/user-896-introduce-usereveri…
panteliselef Oct 22, 2024
4b507aa
fix e2e tests
panteliselef Oct 22, 2024
5c300a3
Merge branch 'refs/heads/main' into elef/user-896-introduce-usereveri…
panteliselef Oct 22, 2024
853f884
Merge branch 'refs/heads/main' into elef/user-896-introduce-usereveri…
panteliselef Oct 22, 2024
07b93dc
Merge branch 'main' into elef/user-896-introduce-usereverification
panteliselef Oct 23, 2024
a0c108b
Merge branch 'main' into elef/user-896-introduce-usereverification
panteliselef Oct 23, 2024
42ad902
remove skipped test
panteliselef Oct 24, 2024
828dc90
revert global timeout extention
panteliselef Oct 24, 2024
dfad7e6
address PR review comments
panteliselef Oct 30, 2024
75902dc
Merge branch 'refs/heads/main' into elef/user-896-introduce-usereveri…
panteliselef Oct 30, 2024
835e972
update package-lock.json
panteliselef Oct 30, 2024
d9f4076
extend duration
panteliselef Oct 30, 2024
0637a82
Merge branch 'refs/heads/main' into elef/user-896-introduce-usereveri…
panteliselef Oct 30, 2024
9cf75a6
remove delay subtraction
panteliselef Oct 30, 2024
fe5b823
update dx of `useReverification`
panteliselef Oct 30, 2024
10e1430
replace useAssurance with useReverification
panteliselef Oct 30, 2024
a7129cf
Merge branch 'main' into elef/user-896-introduce-usereverification
panteliselef Oct 30, 2024
1e4e852
highlight experimental utilities
panteliselef Oct 30, 2024
b47124a
align usage for consistency
panteliselef Oct 30, 2024
58238d6
Merge branch 'main' into elef/user-896-introduce-usereverification
panteliselef Oct 30, 2024
51f3a0e
Merge branch 'main' into elef/user-896-introduce-usereverification
panteliselef Oct 31, 2024
f86c9cb
simplify types of `__experimental_useReverification`
panteliselef Oct 31, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/clever-lions-marry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@clerk/nextjs": minor
---

Bug fix: For next>=14 applications resolve `__unstable__onBeforeSetActive` once `invalidateCacheAction` resolves.
33 changes: 33 additions & 0 deletions .changeset/five-insects-grin.md
Original file line number Diff line number Diff line change
@@ -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() {
Comment thread
panteliselef marked this conversation as resolved.
const { user } = useUser();
const [deleteUserAccount] = useReverification(() => {
if (!user) return;
return user.delete()
});

return <>
<button
onClick={async () => {
await deleteUserAccount();
}}>
Delete account
</button>
</>
}

```
7 changes: 7 additions & 0 deletions .changeset/ninety-rabbits-itch.md
Original file line number Diff line number Diff line change
@@ -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`.
5 changes: 5 additions & 0 deletions .changeset/serious-poems-retire.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@clerk/clerk-js": patch
---

Chore: Replace beforeEmit with an explicit call after `setActive`, inside the experimental UserVerification.
2 changes: 1 addition & 1 deletion integration/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
8 changes: 8 additions & 0 deletions integration/presets/envs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', '')
Expand Down Expand Up @@ -106,6 +113,7 @@ export const envs = {
withEmailCodes_destroy_client,
withEmailLinks,
withCustomRoles,
withReverification,
withEmailCodesQuickstart,
withAPCore1ClerkLatest,
withAPCore1ClerkV4,
Expand Down
1 change: 1 addition & 0 deletions integration/presets/longRunningApps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down
4 changes: 3 additions & 1 deletion integration/presets/next.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'])
Expand All @@ -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()
Expand Down
3 changes: 3 additions & 0 deletions integration/templates/next-app-router/next.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ const nextConfig = {
eslint: {
ignoreDuringBuilds: true,
},
experimental: {
serverActions: true,
},
};

module.exports = nextConfig;
Original file line number Diff line number Diff line change
@@ -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 (
<>
<button
disabled={pending}
onClick={() => {
startTransition(async () => {
await logUserWithReverification().then(e => {
setRes(e as any);
});
});
}}
>
LogUserId
</button>
<pre>{JSON.stringify(res)}</pre>
</>
);
}

export default Page;
Original file line number Diff line number Diff line change
@@ -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 };
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
'use client';
import { useState, useTransition } from 'react';

export function ButtonAction({ action }: { action: () => Promise<any> }) {
const [pending, startTransition] = useTransition();
const [res, setRes] = useState(null);

return (
<>
<button
disabled={pending}
onClick={() => {
startTransition(async () => {
await action().then(setRes);
});
}}
>
LogUserId
</button>
<pre>{JSON.stringify(res)}</pre>
</>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { logUserIdActionReverification } from '../actions';
import { ButtonAction } from '../button-action';

function Page() {
return <ButtonAction action={logUserIdActionReverification} />;
}

export default Page;
2 changes: 2 additions & 0 deletions integration/testUtils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -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),
};
Expand Down
27 changes: 27 additions & 0 deletions integration/testUtils/userVerificationPageObject.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import type { Browser, BrowserContext } from '@playwright/test';

import type { createAppPageObject } from './appPageObject';
import { common } from './commonPageObject';

export type EnchancedPage = ReturnType<typeof createAppPageObject>;
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;
};
104 changes: 104 additions & 0 deletions integration/tests/reverification.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
},
);
1 change: 0 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ exports[`public exports should not include a breaking change 1`] = `
"SignedOut",
"UserButton",
"UserProfile",
"__experimental_useReverification",
"useAuth",
"useClerk",
"useEmailLink",
Expand Down
Loading