diff --git a/.changeset/funny-monkeys-brush.md b/.changeset/funny-monkeys-brush.md new file mode 100644 index 00000000000..9bdad19f66d --- /dev/null +++ b/.changeset/funny-monkeys-brush.md @@ -0,0 +1,15 @@ +--- +"@clerk/types": minor +"@clerk/clerk-js": minor +--- + +**Experimental:** Persist the Clerk client after signing out a user. +This allows for matching a user's device with a client. To try out this new feature, enable it in your `` or `clerk.load()` call. + +```js +// React + + +// Vanilla JS +await clerk.load({ experimental: { persistClient: true } }) +``` diff --git a/integration/presets/envs.ts b/integration/presets/envs.ts index 58bb086b1a5..e2a9f8030da 100644 --- a/integration/presets/envs.ts +++ b/integration/presets/envs.ts @@ -35,6 +35,10 @@ const withEmailCodes = base .setEnvVariable('public', 'CLERK_PUBLISHABLE_KEY', instanceKeys.get('with-email-codes').pk) .setEnvVariable('private', 'CLERK_ENCRYPTION_KEY', constants.E2E_CLERK_ENCRYPTION_KEY || 'a-key'); +const withEmailCodes_persist_client = withEmailCodes + .clone() + .setEnvVariable('public', 'EXPERIMENTAL_PERSIST_CLIENT', 'true'); + const withEmailLinks = base .clone() .setId('withEmailLinks') @@ -87,6 +91,7 @@ const withDynamicKeys = withEmailCodes export const envs = { base, withEmailCodes, + withEmailCodes_persist_client, withEmailLinks, withCustomRoles, withEmailCodesQuickstart, diff --git a/integration/presets/longRunningApps.ts b/integration/presets/longRunningApps.ts index e166a148c72..718b2915ca6 100644 --- a/integration/presets/longRunningApps.ts +++ b/integration/presets/longRunningApps.ts @@ -19,9 +19,15 @@ export const createLongRunningApps = () => { const configs = [ { id: 'express.vite.withEmailCodes', config: express.vite, env: envs.withEmailCodes }, { id: 'react.vite.withEmailCodes', config: react.vite, env: envs.withEmailCodes }, + { id: 'react.vite.withEmailCodes_persist_client', config: react.vite, env: envs.withEmailCodes_persist_client }, { id: 'react.vite.withEmailLinks', config: react.vite, env: envs.withEmailLinks }, { id: 'remix.node.withEmailCodes', config: remix.remixNode, env: envs.withEmailCodes }, { id: 'next.appRouter.withEmailCodes', config: next.appRouter, env: envs.withEmailCodes }, + { + id: 'next.appRouter.withEmailCodes_persist_client', + config: next.appRouter, + env: envs.withEmailCodes_persist_client, + }, { id: 'next.appRouter.withCustomRoles', config: next.appRouter, env: envs.withCustomRoles }, { id: 'quickstart.next.appRouter', config: next.appRouterQuickstart, env: envs.withEmailCodesQuickstart }, { id: 'elements.next.appRouter', config: elements.nextAppRouter, env: envs.withEmailCodes }, diff --git a/integration/templates/next-app-router/src/app/client-id.tsx b/integration/templates/next-app-router/src/app/client-id.tsx new file mode 100644 index 00000000000..d376dbfdc1e --- /dev/null +++ b/integration/templates/next-app-router/src/app/client-id.tsx @@ -0,0 +1,15 @@ +'use client'; +import { useClerk, useSession } from '@clerk/nextjs'; +import React from 'react'; + +export function ClientId() { + const clerk = useClerk(); + // For re-rendering + useSession(); + return ( + <> + {clerk?.client?.id &&

{clerk?.client?.id}

} + {clerk?.client?.lastActiveSessionId &&

{clerk?.client?.lastActiveSessionId}

} + + ); +} diff --git a/integration/templates/next-app-router/src/app/layout.tsx b/integration/templates/next-app-router/src/app/layout.tsx index 29ddd566bdb..d73009ca875 100644 --- a/integration/templates/next-app-router/src/app/layout.tsx +++ b/integration/templates/next-app-router/src/app/layout.tsx @@ -11,7 +11,11 @@ export const metadata = { export default function RootLayout({ children }: { children: React.ReactNode }) { return ( - + {children} diff --git a/integration/templates/next-app-router/src/app/page.tsx b/integration/templates/next-app-router/src/app/page.tsx index db74677ae17..72108e95801 100644 --- a/integration/templates/next-app-router/src/app/page.tsx +++ b/integration/templates/next-app-router/src/app/page.tsx @@ -1,10 +1,12 @@ import { SignedIn, SignedOut, SignIn, UserButton, Protect } from '@clerk/nextjs'; import Link from 'next/link'; +import { ClientId } from './client-id'; export default function Home() { return (
+ SignedIn SignedOut SignedIn from protect diff --git a/integration/templates/react-vite/src/App.tsx b/integration/templates/react-vite/src/App.tsx index 47b38346dec..98e3530af67 100644 --- a/integration/templates/react-vite/src/App.tsx +++ b/integration/templates/react-vite/src/App.tsx @@ -1,11 +1,13 @@ import { OrganizationSwitcher, SignedIn, SignedOut, UserButton } from '@clerk/clerk-react'; import React from 'react'; +import { ClientId } from './client-id'; function App() { return (
+ SignedOut SignedIn
diff --git a/integration/templates/react-vite/src/client-id.tsx b/integration/templates/react-vite/src/client-id.tsx new file mode 100644 index 00000000000..49a76a45dcb --- /dev/null +++ b/integration/templates/react-vite/src/client-id.tsx @@ -0,0 +1,14 @@ +import { useClerk, useSession } from '@clerk/clerk-react'; +import React from 'react'; + +export function ClientId() { + const clerk = useClerk(); + // For re-rendering + useSession(); + return ( + <> + {clerk?.client?.id &&

{clerk?.client?.id}

} + {clerk?.client?.lastActiveSessionId &&

{clerk?.client?.lastActiveSessionId}

} + + ); +} diff --git a/integration/templates/react-vite/src/main.tsx b/integration/templates/react-vite/src/main.tsx index 224c68b1496..4b151e3f2f3 100644 --- a/integration/templates/react-vite/src/main.tsx +++ b/integration/templates/react-vite/src/main.tsx @@ -18,6 +18,9 @@ const Root = () => { clerkJSUrl={import.meta.env.VITE_CLERK_JS_URL as string} routerPush={(to: string) => navigate(to)} routerReplace={(to: string) => navigate(to, { replace: true })} + experimental={{ + persistClient: import.meta.env.VITE_EXPERIMENTAL_PERSIST_CLIENT === 'true', + }} > diff --git a/integration/tests/sign-out-smoke.test.ts b/integration/tests/sign-out-smoke.test.ts index 0a317438249..27381295be3 100644 --- a/integration/tests/sign-out-smoke.test.ts +++ b/integration/tests/sign-out-smoke.test.ts @@ -1,4 +1,4 @@ -import { test } from '@playwright/test'; +import { expect, test } from '@playwright/test'; import { appConfigs } from '../presets'; import type { FakeUser } from '../testUtils'; @@ -45,4 +45,68 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withEmailCodes] })('sign out await mainTab.po.expect.toBeSignedOut(); }); + + test('sign out destroying client', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + await u.po.signIn.goTo(); + await u.po.signIn.setIdentifier(fakeUser.email); + await u.po.signIn.continue(); + await u.po.signIn.setPassword(fakeUser.password); + await u.po.signIn.continue(); + await u.po.expect.toBeSignedIn(); + await u.page.goToAppHome(); + + await u.page.waitForSelector('p[data-clerk-id]', { state: 'attached' }); + + await u.page.evaluate(async () => { + await window.Clerk.signOut(); + }); + + await u.po.expect.toBeSignedOut(); + await u.page.waitForSelector('p[data-clerk-id]', { state: 'detached' }); + await u.page.waitForSelector('p[data-clerk-session]', { state: 'detached' }); + }); }); + +testAgainstRunningApps({ withEnv: [appConfigs.envs.withEmailCodes_persist_client] })( + 'sign out with persistClient smoke test @generic', + ({ app }) => { + test.describe.configure({ mode: 'serial' }); + + let fakeUser: FakeUser; + + test.beforeAll(async () => { + const u = createTestUtils({ app }); + fakeUser = u.services.users.createFakeUser(); + await u.services.users.createBapiUser(fakeUser); + }); + + test.afterAll(async () => { + await fakeUser.deleteIfExists(); + await app.teardown(); + }); + + test('sign out persisting client', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + await u.po.signIn.goTo(); + await u.po.signIn.setIdentifier(fakeUser.email); + await u.po.signIn.continue(); + await u.po.signIn.setPassword(fakeUser.password); + await u.po.signIn.continue(); + await u.po.expect.toBeSignedIn(); + await u.page.goToAppHome(); + const client_id_element = await u.page.waitForSelector('p[data-clerk-id]', { state: 'attached' }); + const client_id = await client_id_element.innerHTML(); + + await u.page.evaluate(async () => { + await window.Clerk.signOut(); + }); + + await u.po.expect.toBeSignedOut(); + await u.page.waitForSelector('p[data-clerk-session]', { state: 'detached' }); + + const client_id_after_sign_out = await u.page.locator('p[data-clerk-id]').innerHTML(); + expect(client_id).toEqual(client_id_after_sign_out); + }); + }, +); diff --git a/package.json b/package.json index feff8ce65e3..5d86e1126b7 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,7 @@ "test:integration:deployment:nextjs": "npx playwright test --config integration/playwright.deployments.config.ts", "test:integration:elements": "E2E_APP_ID=elements.* npm run test:integration:base -- --grep @elements", "test:integration:express": "E2E_APP_ID=express.* npm run test:integration:base -- --grep @express", - "test:integration:generic": "E2E_APP_ID=react.vite.*,next.appRouter.withEmailCodes npm run test:integration:base -- --grep @generic", + "test:integration:generic": "E2E_APP_ID=react.vite.*,next.appRouter.withEmailCodes* npm run test:integration:base -- --grep @generic", "test:integration:nextjs": "E2E_APP_ID=next.appRouter.* npm run test:integration:base -- --grep @nextjs", "test:integration:astro": "E2E_APP_ID=astro.* npm run test:integration:base -- --grep @astro", "test:integration:expo-web": "E2E_APP_ID=expo.expo-web npm run test:integration:base -- --grep @expo-web", diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index 84c3281b184..111d8690d9c 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -333,7 +333,12 @@ export class Clerk implements ClerkInterface { const cb = typeof callbackOrOptions === 'function' ? callbackOrOptions : defaultCb; if (!opts.sessionId || this.client.activeSessions.length === 1) { - await this.client.destroy(); + if (this.#options.experimental?.persistClient) { + await this.client.removeSessions(); + } else { + await this.client.destroy(); + } + return this.setActive({ session: null, beforeEmit: ignoreEventValue(cb), diff --git a/packages/clerk-js/src/core/resources/Client.ts b/packages/clerk-js/src/core/resources/Client.ts index e00a30bac74..28f0ccc8cc4 100644 --- a/packages/clerk-js/src/core/resources/Client.ts +++ b/packages/clerk-js/src/core/resources/Client.ts @@ -56,6 +56,7 @@ export class Client extends BaseResource implements ClientResource { // TODO: Make it restful by introducing a DELETE /client/:id endpoint return this._baseDelete({ path: '/client' }).then(() => { SessionTokenCache.clear(); + this.id = ''; this.sessions = []; this.signUp = new SignUp(null); this.signIn = new SignIn(null); @@ -65,6 +66,12 @@ export class Client extends BaseResource implements ClientResource { }); } + removeSessions(): Promise { + return this._baseDelete({ + path: this.path() + '/sessions', + }) as unknown as Promise; + } + clearCache(): void { return this.sessions.forEach(s => s.clearCache()); } diff --git a/packages/types/src/clerk.ts b/packages/types/src/clerk.ts index 3bf7c2d03ac..a5bb23b5c22 100644 --- a/packages/types/src/clerk.ts +++ b/packages/types/src/clerk.ts @@ -650,6 +650,16 @@ export type ClerkOptions = ClerkOptionsNavigation & }; sdkMetadata?: SDKMetadata; + + /** + * Enable experimental flags to gain access to new features. These flags are not guaranteed to be stable and may change drastically in between patch or minor versions. + */ + experimental?: Autocomplete< + { + persistClient: boolean; + }, + Record + >; }; export interface NavigateOptions { diff --git a/packages/types/src/client.ts b/packages/types/src/client.ts index f4f85e4c398..4326520dd0a 100644 --- a/packages/types/src/client.ts +++ b/packages/types/src/client.ts @@ -11,6 +11,7 @@ export interface ClientResource extends ClerkResource { isNew: () => boolean; create: () => Promise; destroy: () => Promise; + removeSessions: () => Promise; clearCache: () => void; lastActiveSessionId: string | null; createdAt: Date | null;