Skip to content

Commit

Permalink
feat(clerk-react,nextjs,shared): Introduce experimental `useReverific…
Browse files Browse the repository at this point in the history
…ation` (#4362)
  • Loading branch information
panteliselef authored Oct 31, 2024
1 parent 24cd779 commit 08c5a2a
Show file tree
Hide file tree
Showing 42 changed files with 690 additions and 338 deletions.
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() {
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

0 comments on commit 08c5a2a

Please sign in to comment.