diff --git a/.changeset/brave-pears-add.md b/.changeset/brave-pears-add.md new file mode 100644 index 00000000000..74870af7af9 --- /dev/null +++ b/.changeset/brave-pears-add.md @@ -0,0 +1,7 @@ +--- +'@clerk/clerk-js': minor +'@clerk/types': minor +'@clerk/clerk-react': minor +--- + +Navigate to tasks on after sign-in/sign-up diff --git a/integration/.keys.json.sample b/integration/.keys.json.sample index caa70922c39..1cb3b47dc50 100644 --- a/integration/.keys.json.sample +++ b/integration/.keys.json.sample @@ -46,5 +46,9 @@ "with-waitlist-mode": { "pk": "", "sk": "" + }, + "with-session-tasks": { + "pk": "", + "sk": "" } } diff --git a/integration/presets/envs.ts b/integration/presets/envs.ts index 4fbf4d2bb22..8254244547f 100644 --- a/integration/presets/envs.ts +++ b/integration/presets/envs.ts @@ -137,6 +137,13 @@ const withSignInOrUpwithRestrictedModeFlow = withEmailCodes .setEnvVariable('public', 'CLERK_PUBLISHABLE_KEY', instanceKeys.get('with-restricted-mode').pk) .setEnvVariable('public', 'CLERK_SIGN_UP_URL', undefined); +const withSessionTasks = base + .clone() + .setId('withSessionTasks') + .setEnvVariable('private', 'CLERK_SECRET_KEY', instanceKeys.get('with-session-tasks').sk) + .setEnvVariable('public', 'CLERK_PUBLISHABLE_KEY', instanceKeys.get('with-session-tasks').pk) + .setEnvVariable('private', 'CLERK_ENCRYPTION_KEY', constants.E2E_CLERK_ENCRYPTION_KEY || 'a-key'); + export const envs = { base, withKeyless, @@ -157,4 +164,5 @@ export const envs = { withSignInOrUpFlow, withSignInOrUpEmailLinksFlow, withSignInOrUpwithRestrictedModeFlow, + withSessionTasks, } as const; diff --git a/integration/presets/longRunningApps.ts b/integration/presets/longRunningApps.ts index d5573f015e0..f595591a3c3 100644 --- a/integration/presets/longRunningApps.ts +++ b/integration/presets/longRunningApps.ts @@ -37,6 +37,11 @@ export const createLongRunningApps = () => { config: next.appRouter, env: envs.withSignInOrUpEmailLinksFlow, }, + { + id: 'next.appRouter.withSessionTasks', + config: next.appRouter, + env: envs.withSessionTasks, + }, { 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/templates/next-app-router/src/app/layout.tsx b/integration/templates/next-app-router/src/app/layout.tsx index 2e56184f39d..e598334db29 100644 --- a/integration/templates/next-app-router/src/app/layout.tsx +++ b/integration/templates/next-app-router/src/app/layout.tsx @@ -24,6 +24,8 @@ export default function RootLayout({ children }: { children: React.ReactNode }) persistClient: process.env.NEXT_PUBLIC_EXPERIMENTAL_PERSIST_CLIENT ? process.env.NEXT_PUBLIC_EXPERIMENTAL_PERSIST_CLIENT === 'true' : undefined, + // `experimental.withSessionTasks` will be removed soon in favor of checking via environment response + withSessionTasks: true, }} > <html lang='en'> diff --git a/integration/tests/session-tasks-sign-in.test.ts b/integration/tests/session-tasks-sign-in.test.ts new file mode 100644 index 00000000000..eebc35002c1 --- /dev/null +++ b/integration/tests/session-tasks-sign-in.test.ts @@ -0,0 +1,38 @@ +import { expect, test } from '@playwright/test'; + +import { appConfigs } from '../presets'; +import type { FakeUser } from '../testUtils'; +import { createTestUtils, testAgainstRunningApps } from '../testUtils'; + +testAgainstRunningApps({ withEnv: [appConfigs.envs.withSessionTasks] })( + 'session tasks after sign-in flow @nextjs', + ({ 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('navigate to task on after sign-in', 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 expect(u.page.getByRole('button', { name: /create organization/i })).toBeVisible(); + expect(page.url()).toContain('add-organization'); + }); + }, +); diff --git a/integration/tests/session-tasks-sign-up.test.ts b/integration/tests/session-tasks-sign-up.test.ts new file mode 100644 index 00000000000..ad5003eb2f3 --- /dev/null +++ b/integration/tests/session-tasks-sign-up.test.ts @@ -0,0 +1,34 @@ +import { expect, test } from '@playwright/test'; + +import { appConfigs } from '../presets'; +import { createTestUtils, testAgainstRunningApps } from '../testUtils'; + +testAgainstRunningApps({ withEnv: [appConfigs.envs.withSessionTasks] })( + 'session tasks after sign-up flow @nextjs', + ({ app }) => { + test.describe.configure({ mode: 'serial' }); + + test.afterAll(async () => { + await app.teardown(); + }); + + test('navigate to task on after sign-up', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + const fakeUser = u.services.users.createFakeUser({ + fictionalEmail: true, + withPhoneNumber: true, + withUsername: true, + }); + await u.po.signUp.goTo(); + await u.po.signUp.signUpWithEmailAndPassword({ + email: fakeUser.email, + password: fakeUser.password, + }); + + await expect(u.page.getByRole('button', { name: /create organization/i })).toBeVisible(); + expect(page.url()).toContain('add-organization'); + + await fakeUser.deleteIfExists(); + }); + }, +); diff --git a/packages/clerk-js/bundlewatch.config.json b/packages/clerk-js/bundlewatch.config.json index d9133f5333a..d5015e80235 100644 --- a/packages/clerk-js/bundlewatch.config.json +++ b/packages/clerk-js/bundlewatch.config.json @@ -1,7 +1,7 @@ { "files": [ - { "path": "./dist/clerk.js", "maxSize": "570kB" }, - { "path": "./dist/clerk.browser.js", "maxSize": "76kB" }, + { "path": "./dist/clerk.js", "maxSize": "572kB" }, + { "path": "./dist/clerk.browser.js", "maxSize": "78kB" }, { "path": "./dist/clerk.headless.js", "maxSize": "50KB" }, { "path": "./dist/ui-common*.js", "maxSize": "92KB" }, { "path": "./dist/vendors*.js", "maxSize": "26.5KB" }, @@ -12,7 +12,7 @@ { "path": "./dist/organizationswitcher*.js", "maxSize": "5KB" }, { "path": "./dist/organizationlist*.js", "maxSize": "5.5KB" }, { "path": "./dist/signin*.js", "maxSize": "12.4KB" }, - { "path": "./dist/signup*.js", "maxSize": "6.4KB" }, + { "path": "./dist/signup*.js", "maxSize": "6.5KB" }, { "path": "./dist/userbutton*.js", "maxSize": "5KB" }, { "path": "./dist/userprofile*.js", "maxSize": "15KB" }, { "path": "./dist/userverification*.js", "maxSize": "5KB" }, diff --git a/packages/clerk-js/src/core/__tests__/clerk.test.ts b/packages/clerk-js/src/core/__tests__/clerk.test.ts index 48f0a85a291..ea573685bd0 100644 --- a/packages/clerk-js/src/core/__tests__/clerk.test.ts +++ b/packages/clerk-js/src/core/__tests__/clerk.test.ts @@ -155,163 +155,302 @@ describe('Clerk singleton', () => { }); describe('.setActive', () => { - describe.each(['active', 'pending'] satisfies Array<SignedInSessionResource['status']>)( - 'when session has %s status', - status => { - const mockSession = { - id: '1', - remove: jest.fn(), - status, - user: {}, - touch: jest.fn(() => Promise.resolve()), - getToken: jest.fn(), - lastActiveToken: { getRawString: () => 'mocked-token' }, - }; - let eventBusSpy; + describe('with `active` session status', () => { + const mockSession = { + id: '1', + remove: jest.fn(), + status: 'active', + user: {}, + touch: jest.fn(() => Promise.resolve()), + getToken: jest.fn(), + lastActiveToken: { getRawString: () => 'mocked-token' }, + }; + let eventBusSpy; - beforeEach(() => { - eventBusSpy = jest.spyOn(eventBus, 'dispatch'); - }); + beforeEach(() => { + eventBusSpy = jest.spyOn(eventBus, 'dispatch'); + }); - afterEach(() => { - mockSession.remove.mockReset(); - mockSession.touch.mockReset(); + afterEach(() => { + mockSession.remove.mockReset(); + mockSession.touch.mockReset(); - eventBusSpy?.mockRestore(); - // cleanup global window pollution - (window as any).__unstable__onBeforeSetActive = null; - (window as any).__unstable__onAfterSetActive = null; + eventBusSpy?.mockRestore(); + // cleanup global window pollution + (window as any).__unstable__onBeforeSetActive = null; + (window as any).__unstable__onAfterSetActive = null; + }); + + it('does not call session touch on signOut', async () => { + mockSession.touch.mockReturnValueOnce(Promise.resolve()); + mockClientFetch.mockReturnValue(Promise.resolve({ signedInSessions: [mockSession] })); + + const sut = new Clerk(productionPublishableKey); + await sut.load(); + await sut.setActive({ session: null }); + await waitFor(() => { + expect(mockSession.touch).not.toHaveBeenCalled(); + expect(eventBusSpy).toHaveBeenCalledWith('token:update', { token: null }); }); + }); - it('does not call session touch on signOut', async () => { - mockSession.touch.mockReturnValueOnce(Promise.resolve()); - mockClientFetch.mockReturnValue(Promise.resolve({ signedInSessions: [mockSession] })); + it('calls session.touch by default', async () => { + mockSession.touch.mockReturnValue(Promise.resolve()); + mockClientFetch.mockReturnValue(Promise.resolve({ signedInSessions: [mockSession] })); - const sut = new Clerk(productionPublishableKey); - await sut.load(); - await sut.setActive({ session: null }); - await waitFor(() => { - expect(mockSession.touch).not.toHaveBeenCalled(); - expect(eventBusSpy).toHaveBeenCalledWith('token:update', { token: null }); - }); + const sut = new Clerk(productionPublishableKey); + await sut.load(); + await sut.setActive({ session: mockSession as any as ActiveSessionResource }); + expect(mockSession.touch).toHaveBeenCalled(); + }); + + it('does not call session.touch if Clerk was initialised with touchSession set to false', async () => { + mockSession.touch.mockReturnValueOnce(Promise.resolve()); + mockClientFetch.mockReturnValue(Promise.resolve({ signedInSessions: [mockSession] })); + mockSession.getToken.mockResolvedValue('mocked-token'); + + const sut = new Clerk(productionPublishableKey); + await sut.load({ touchSession: false }); + await sut.setActive({ session: mockSession as any as ActiveSessionResource }); + await waitFor(() => { + expect(mockSession.touch).not.toHaveBeenCalled(); + expect(mockSession.getToken).toHaveBeenCalled(); }); + }); - it('calls session.touch by default', async () => { - mockSession.touch.mockReturnValue(Promise.resolve()); - mockClientFetch.mockReturnValue(Promise.resolve({ signedInSessions: [mockSession] })); + it('calls __unstable__onBeforeSetActive before session.touch', async () => { + mockSession.touch.mockReturnValueOnce(Promise.resolve()); + mockClientFetch.mockReturnValue(Promise.resolve({ signedInSessions: [mockSession] })); - const sut = new Clerk(productionPublishableKey); - await sut.load(); - await sut.setActive({ session: mockSession as any as ActiveSessionResource }); + (window as any).__unstable__onBeforeSetActive = () => { + expect(mockSession.touch).not.toHaveBeenCalled(); + }; + + const sut = new Clerk(productionPublishableKey); + await sut.load(); + await sut.setActive({ session: mockSession as any as ActiveSessionResource }); + expect(mockSession.touch).toHaveBeenCalled(); + }); + + it('sets __session and __client_uat cookie before calling __unstable__onBeforeSetActive', async () => { + mockSession.touch.mockReturnValueOnce(Promise.resolve()); + mockClientFetch.mockReturnValue(Promise.resolve({ signedInSessions: [mockSession] })); + + (window as any).__unstable__onBeforeSetActive = () => { + expect(eventBusSpy).toHaveBeenCalledWith('token:update', { token: mockSession.lastActiveToken }); + }; + + const sut = new Clerk(productionPublishableKey); + await sut.load(); + await sut.setActive({ session: mockSession as any as ActiveSessionResource }); + }); + + it('calls __unstable__onAfterSetActive after beforeEmit and session.touch', async () => { + const beforeEmitMock = jest.fn(); + mockSession.touch.mockReturnValueOnce(Promise.resolve()); + mockClientFetch.mockReturnValue(Promise.resolve({ signedInSessions: [mockSession] })); + + (window as any).__unstable__onAfterSetActive = () => { expect(mockSession.touch).toHaveBeenCalled(); - }); + expect(beforeEmitMock).toHaveBeenCalled(); + }; - it('does not call session.touch if Clerk was initialised with touchSession set to false', async () => { - mockSession.touch.mockReturnValueOnce(Promise.resolve()); - mockClientFetch.mockReturnValue(Promise.resolve({ signedInSessions: [mockSession] })); - mockSession.getToken.mockResolvedValue('mocked-token'); + const sut = new Clerk(productionPublishableKey); + await sut.load(); + await sut.setActive({ session: mockSession as any as ActiveSessionResource, beforeEmit: beforeEmitMock }); + }); - const sut = new Clerk(productionPublishableKey); - await sut.load({ touchSession: false }); - await sut.setActive({ session: mockSession as any as ActiveSessionResource }); - await waitFor(() => { - expect(mockSession.touch).not.toHaveBeenCalled(); - expect(mockSession.getToken).toHaveBeenCalled(); - }); + // TODO: @dimkl include set transitive state + it('calls session.touch -> set cookie -> before emit with touched session on session switch', async () => { + const mockSession2 = { + id: '2', + remove: jest.fn(), + status: 'active', + user: {}, + touch: jest.fn(), + getToken: jest.fn(), + }; + mockClientFetch.mockReturnValue( + Promise.resolve({ + signedInSessions: [mockSession, mockSession2], + }), + ); + + const sut = new Clerk(productionPublishableKey); + await sut.load(); + + const executionOrder: string[] = []; + mockSession2.touch.mockImplementationOnce(() => { + sut.session = mockSession2 as any; + executionOrder.push('session.touch'); + return Promise.resolve(); + }); + mockSession2.getToken.mockImplementation(() => { + executionOrder.push('set cookie'); + return 'mocked-token-2'; + }); + const beforeEmitMock = jest.fn().mockImplementationOnce(() => { + executionOrder.push('before emit'); + return Promise.resolve(); }); - it('calls __unstable__onBeforeSetActive before session.touch', async () => { - mockSession.touch.mockReturnValueOnce(Promise.resolve()); - mockClientFetch.mockReturnValue(Promise.resolve({ signedInSessions: [mockSession] })); + await sut.setActive({ session: mockSession2 as any as ActiveSessionResource, beforeEmit: beforeEmitMock }); - (window as any).__unstable__onBeforeSetActive = () => { - expect(mockSession.touch).not.toHaveBeenCalled(); - }; + await waitFor(() => { + expect(executionOrder).toEqual(['session.touch', 'set cookie', 'before emit']); + expect(mockSession2.touch).toHaveBeenCalled(); + expect(mockSession2.getToken).toHaveBeenCalled(); + expect(beforeEmitMock).toHaveBeenCalledWith(mockSession2); + expect(sut.session).toMatchObject(mockSession2); + }); + }); - const sut = new Clerk(productionPublishableKey); - await sut.load(); - await sut.setActive({ session: mockSession as any as ActiveSessionResource }); - expect(mockSession.touch).toHaveBeenCalled(); + // TODO: @dimkl include set transitive state + it('calls with lastActiveOrganizationId session.touch -> set cookie -> before emit -> set accessors with touched session on organization switch', async () => { + mockClientFetch.mockReturnValue(Promise.resolve({ signedInSessions: [mockSession] })); + const sut = new Clerk(productionPublishableKey); + await sut.load(); + + const executionOrder: string[] = []; + mockSession.touch.mockImplementationOnce(() => { + sut.session = mockSession as any; + executionOrder.push('session.touch'); + return Promise.resolve(); + }); + mockSession.getToken.mockImplementation(() => { + executionOrder.push('set cookie'); + return 'mocked-token'; }); - it('sets __session and __client_uat cookie before calling __unstable__onBeforeSetActive', async () => { - mockSession.touch.mockReturnValueOnce(Promise.resolve()); - mockClientFetch.mockReturnValue(Promise.resolve({ signedInSessions: [mockSession] })); + const beforeEmitMock = jest.fn().mockImplementationOnce(() => { + executionOrder.push('before emit'); + return Promise.resolve(); + }); - (window as any).__unstable__onBeforeSetActive = () => { - expect(eventBusSpy).toHaveBeenCalledWith('token:update', { token: mockSession.lastActiveToken }); - }; + await sut.setActive({ organization: { id: 'org_id' } as Organization, beforeEmit: beforeEmitMock }); - const sut = new Clerk(productionPublishableKey); - await sut.load(); - await sut.setActive({ session: mockSession as any as ActiveSessionResource }); + await waitFor(() => { + expect(executionOrder).toEqual(['session.touch', 'set cookie', 'before emit']); + expect(mockSession.touch).toHaveBeenCalled(); + expect(mockSession.getToken).toHaveBeenCalled(); + expect((mockSession as any as ActiveSessionResource)?.lastActiveOrganizationId).toEqual('org_id'); + expect(beforeEmitMock).toHaveBeenCalledWith(mockSession); + expect(sut.session).toMatchObject(mockSession); }); + }); - it('calls __unstable__onAfterSetActive after beforeEmit and session.touch', async () => { - const beforeEmitMock = jest.fn(); - mockSession.touch.mockReturnValueOnce(Promise.resolve()); - mockClientFetch.mockReturnValue(Promise.resolve({ signedInSessions: [mockSession] })); + it('sets active organization by slug', async () => { + const mockSession2 = { + id: '1', + status, + user: { + organizationMemberships: [ + { + id: 'orgmem_id', + organization: { + id: 'org_id', + slug: 'some-org-slug', + }, + }, + ], + }, + touch: jest.fn(), + getToken: jest.fn(), + }; + mockClientFetch.mockReturnValue(Promise.resolve({ signedInSessions: [mockSession2] })); + const sut = new Clerk(productionPublishableKey); + await sut.load(); - (window as any).__unstable__onAfterSetActive = () => { - expect(mockSession.touch).toHaveBeenCalled(); - expect(beforeEmitMock).toHaveBeenCalled(); - }; + mockSession2.touch.mockImplementationOnce(() => { + sut.session = mockSession2 as any; + return Promise.resolve(); + }); + mockSession2.getToken.mockImplementation(() => 'mocked-token'); - const sut = new Clerk(productionPublishableKey); - await sut.load(); - await sut.setActive({ session: mockSession as any as ActiveSessionResource, beforeEmit: beforeEmitMock }); + await sut.setActive({ organization: 'some-org-slug' }); + + await waitFor(() => { + expect(mockSession2.touch).toHaveBeenCalled(); + expect(mockSession2.getToken).toHaveBeenCalled(); + expect((mockSession2 as any as ActiveSessionResource)?.lastActiveOrganizationId).toEqual('org_id'); + expect(sut.session).toMatchObject(mockSession2); }); + }); - // TODO: @dimkl include set transitive state - it('calls session.touch -> set cookie -> before emit with touched session on session switch', async () => { - const mockSession2 = { - id: '2', - remove: jest.fn(), - status: 'active', - user: {}, - touch: jest.fn(), - getToken: jest.fn(), - }; - mockClientFetch.mockReturnValue( - Promise.resolve({ - signedInSessions: [mockSession, mockSession2], - }), - ); + it('redirects the user to the /v1/client/touch endpoint if the cookie_expires_at is less than 8 days away', async () => { + mockSession.touch.mockReturnValue(Promise.resolve()); + mockClientFetch.mockReturnValue( + Promise.resolve({ + signedInSessions: [mockSession], + cookieExpiresAt: new Date(Date.now() + 2 * 24 * 60 * 60 * 1000), // 2 days from now + isEligibleForTouch: () => true, + buildTouchUrl: () => + `https://clerk.example.com/v1/client/touch?redirect_url=${mockWindowLocation.href}/redirect-url-path`, + }), + ); - const sut = new Clerk(productionPublishableKey); - await sut.load(); + const sut = new Clerk(productionPublishableKey); + sut.navigate = jest.fn(); + await sut.load(); + await sut.setActive({ + session: mockSession as any as ActiveSessionResource, + redirectUrl: '/redirect-url-path', + }); + const redirectUrl = new URL((sut.navigate as jest.Mock).mock.calls[0]); + expect(redirectUrl.pathname).toEqual('/v1/client/touch'); + expect(redirectUrl.searchParams.get('redirect_url')).toEqual(`${mockWindowLocation.href}/redirect-url-path`); + }); - const executionOrder: string[] = []; - mockSession2.touch.mockImplementationOnce(() => { - sut.session = mockSession2 as any; - executionOrder.push('session.touch'); - return Promise.resolve(); - }); - mockSession2.getToken.mockImplementation(() => { - executionOrder.push('set cookie'); - return 'mocked-token-2'; - }); - const beforeEmitMock = jest.fn().mockImplementationOnce(() => { - executionOrder.push('before emit'); - return Promise.resolve(); - }); + it('does not redirect the user to the /v1/client/touch endpoint if the cookie_expires_at is more than 8 days away', async () => { + mockSession.touch.mockReturnValue(Promise.resolve()); + mockClientFetch.mockReturnValue( + Promise.resolve({ + signedInSessions: [mockSession], + cookieExpiresAt: new Date(Date.now() + 10 * 24 * 60 * 60 * 1000), // 10 days from now + isEligibleForTouch: () => false, + buildTouchUrl: () => + `https://clerk.example.com/v1/client/touch?redirect_url=${mockWindowLocation.href}/redirect-url-path`, + }), + ); - await sut.setActive({ session: mockSession2 as any as ActiveSessionResource, beforeEmit: beforeEmitMock }); + const sut = new Clerk(productionPublishableKey); + sut.navigate = jest.fn(); + await sut.load(); + await sut.setActive({ + session: mockSession as any as ActiveSessionResource, + redirectUrl: '/redirect-url-path', + }); + expect(sut.navigate).toHaveBeenCalledWith('/redirect-url-path'); + }); - await waitFor(() => { - expect(executionOrder).toEqual(['session.touch', 'set cookie', 'before emit']); - expect(mockSession2.touch).toHaveBeenCalled(); - expect(mockSession2.getToken).toHaveBeenCalled(); - expect(beforeEmitMock).toHaveBeenCalledWith(mockSession2); - expect(sut.session).toMatchObject(mockSession2); - }); + it('does not redirect the user to the /v1/client/touch endpoint if the cookie_expires_at is not set', async () => { + mockSession.touch.mockReturnValue(Promise.resolve()); + mockClientFetch.mockReturnValue( + Promise.resolve({ + signedInSessions: [mockSession], + cookieExpiresAt: null, + isEligibleForTouch: () => false, + buildTouchUrl: () => + `https://clerk.example.com/v1/client/touch?redirect_url=${mockWindowLocation.href}/redirect-url-path`, + }), + ); + + const sut = new Clerk(productionPublishableKey); + sut.navigate = jest.fn(); + await sut.load(); + await sut.setActive({ + session: mockSession as any as ActiveSessionResource, + redirectUrl: '/redirect-url-path', }); + expect(sut.navigate).toHaveBeenCalledWith('/redirect-url-path'); + }); - // TODO: @dimkl include set transitive state - it('calls with lastActiveOrganizationId session.touch -> set cookie -> before emit -> set accessors with touched session on organization switch', async () => { + mockNativeRuntime(() => { + it('calls session.touch in a non-standard browser', async () => { mockClientFetch.mockReturnValue(Promise.resolve({ signedInSessions: [mockSession] })); + const sut = new Clerk(productionPublishableKey); - await sut.load(); + await sut.load({ standardBrowser: false }); const executionOrder: string[] = []; mockSession.touch.mockImplementationOnce(() => { @@ -319,11 +458,6 @@ describe('Clerk singleton', () => { executionOrder.push('session.touch'); return Promise.resolve(); }); - mockSession.getToken.mockImplementation(() => { - executionOrder.push('set cookie'); - return 'mocked-token'; - }); - const beforeEmitMock = jest.fn().mockImplementationOnce(() => { executionOrder.push('before emit'); return Promise.resolve(); @@ -331,152 +465,98 @@ describe('Clerk singleton', () => { await sut.setActive({ organization: { id: 'org_id' } as Organization, beforeEmit: beforeEmitMock }); - await waitFor(() => { - expect(executionOrder).toEqual(['session.touch', 'set cookie', 'before emit']); - expect(mockSession.touch).toHaveBeenCalled(); - expect(mockSession.getToken).toHaveBeenCalled(); - expect((mockSession as any as ActiveSessionResource)?.lastActiveOrganizationId).toEqual('org_id'); - expect(beforeEmitMock).toHaveBeenCalledWith(mockSession); - expect(sut.session).toMatchObject(mockSession); - }); + expect(executionOrder).toEqual(['session.touch', 'before emit']); + expect(mockSession.touch).toHaveBeenCalled(); + expect((mockSession as any as ActiveSessionResource)?.lastActiveOrganizationId).toEqual('org_id'); + expect(mockSession.getToken).toHaveBeenCalled(); + expect(beforeEmitMock).toHaveBeenCalledWith(mockSession); + expect(sut.session).toMatchObject(mockSession); }); + }); + }); - it('sets active organization by slug', async () => { - const mockSession2 = { - id: '1', - status, - user: { - organizationMemberships: [ - { - id: 'orgmem_id', - organization: { - id: 'org_id', - slug: 'some-org-slug', - }, - }, - ], - }, - touch: jest.fn(), - getToken: jest.fn(), - }; - mockClientFetch.mockReturnValue(Promise.resolve({ signedInSessions: [mockSession2] })); - const sut = new Clerk(productionPublishableKey); - await sut.load(); + describe('with `pending` session status', () => { + const mockSession = { + id: '1', + remove: jest.fn(), + status: 'pending', + user: {}, + touch: jest.fn(() => Promise.resolve()), + getToken: jest.fn(), + lastActiveToken: { getRawString: () => 'mocked-token' }, + tasks: [{ key: 'org' }], + currentTask: { key: 'org', __internal_getUrl: () => 'https://foocorp.com/add-organization' }, + }; + let eventBusSpy; - mockSession2.touch.mockImplementationOnce(() => { - sut.session = mockSession2 as any; - return Promise.resolve(); - }); - mockSession2.getToken.mockImplementation(() => 'mocked-token'); + beforeEach(() => { + eventBusSpy = jest.spyOn(eventBus, 'dispatch'); + }); - await sut.setActive({ organization: 'some-org-slug' }); + afterEach(() => { + mockSession.remove.mockReset(); + mockSession.touch.mockReset(); - await waitFor(() => { - expect(mockSession2.touch).toHaveBeenCalled(); - expect(mockSession2.getToken).toHaveBeenCalled(); - expect((mockSession2 as any as ActiveSessionResource)?.lastActiveOrganizationId).toEqual('org_id'); - expect(sut.session).toMatchObject(mockSession2); - }); - }); + eventBusSpy?.mockRestore(); + // cleanup global window pollution + (window as any).__unstable__onBeforeSetActive = null; + (window as any).__unstable__onAfterSetActive = null; + }); - it('redirects the user to the /v1/client/touch endpoint if the cookie_expires_at is less than 8 days away', async () => { - mockSession.touch.mockReturnValue(Promise.resolve()); - mockClientFetch.mockReturnValue( - Promise.resolve({ - signedInSessions: [mockSession], - cookieExpiresAt: new Date(Date.now() + 2 * 24 * 60 * 60 * 1000), // 2 days from now - isEligibleForTouch: () => true, - buildTouchUrl: () => - `https://clerk.example.com/v1/client/touch?redirect_url=${mockWindowLocation.href}/redirect-url-path`, - }), - ); + it('calls session.touch by default', async () => { + mockSession.touch.mockReturnValue(Promise.resolve()); + mockClientFetch.mockReturnValue(Promise.resolve({ signedInSessions: [mockSession] })); - const sut = new Clerk(productionPublishableKey); - sut.navigate = jest.fn(); - await sut.load(); - await sut.setActive({ - session: mockSession as any as ActiveSessionResource, - redirectUrl: '/redirect-url-path', - }); - const redirectUrl = new URL((sut.navigate as jest.Mock).mock.calls[0]); - expect(redirectUrl.pathname).toEqual('/v1/client/touch'); - expect(redirectUrl.searchParams.get('redirect_url')).toEqual(`${mockWindowLocation.href}/redirect-url-path`); - }); + const sut = new Clerk(productionPublishableKey); + await sut.load(); + await sut.setActive({ session: mockSession as any as ActiveSessionResource }); + expect(mockSession.touch).toHaveBeenCalled(); + }); - it('does not redirect the user to the /v1/client/touch endpoint if the cookie_expires_at is more than 8 days away', async () => { - mockSession.touch.mockReturnValue(Promise.resolve()); - mockClientFetch.mockReturnValue( - Promise.resolve({ - signedInSessions: [mockSession], - cookieExpiresAt: new Date(Date.now() + 10 * 24 * 60 * 60 * 1000), // 10 days from now - isEligibleForTouch: () => false, - buildTouchUrl: () => - `https://clerk.example.com/v1/client/touch?redirect_url=${mockWindowLocation.href}/redirect-url-path`, - }), - ); + it('does not call session.touch if Clerk was initialised with touchSession set to false', async () => { + mockSession.touch.mockReturnValueOnce(Promise.resolve()); + mockClientFetch.mockReturnValue(Promise.resolve({ signedInSessions: [mockSession] })); + mockSession.getToken.mockResolvedValue('mocked-token'); - const sut = new Clerk(productionPublishableKey); - sut.navigate = jest.fn(); - await sut.load(); - await sut.setActive({ - session: mockSession as any as ActiveSessionResource, - redirectUrl: '/redirect-url-path', - }); - expect(sut.navigate).toHaveBeenCalledWith('/redirect-url-path'); + const sut = new Clerk(productionPublishableKey); + await sut.load({ touchSession: false }); + await sut.setActive({ session: mockSession as any as ActiveSessionResource }); + await waitFor(() => { + expect(mockSession.touch).not.toHaveBeenCalled(); + expect(mockSession.getToken).toHaveBeenCalled(); }); + }); - it('does not redirect the user to the /v1/client/touch endpoint if the cookie_expires_at is not set', async () => { - mockSession.touch.mockReturnValue(Promise.resolve()); - mockClientFetch.mockReturnValue( - Promise.resolve({ - signedInSessions: [mockSession], - cookieExpiresAt: null, - isEligibleForTouch: () => false, - buildTouchUrl: () => - `https://clerk.example.com/v1/client/touch?redirect_url=${mockWindowLocation.href}/redirect-url-path`, - }), - ); + it('navigates to task and set accessors with touched session', async () => { + mockClientFetch.mockReturnValue(Promise.resolve({ sessions: [mockSession], signedInSessions: [mockSession] })); + const sut = new Clerk(productionPublishableKey); + await sut.load(); - const sut = new Clerk(productionPublishableKey); - sut.navigate = jest.fn(); - await sut.load(); - await sut.setActive({ - session: mockSession as any as ActiveSessionResource, - redirectUrl: '/redirect-url-path', - }); - expect(sut.navigate).toHaveBeenCalledWith('/redirect-url-path'); + const executionOrder: string[] = []; + mockSession.touch.mockImplementationOnce(() => { + sut.session = mockSession as any; + executionOrder.push('session.touch'); + return Promise.resolve(); + }); + mockSession.getToken.mockImplementation(() => { + executionOrder.push('set cookie'); + return 'mocked-token'; + }); + sut.navigate = jest.fn().mockImplementationOnce(() => { + executionOrder.push('navigate'); + return Promise.resolve(); }); - mockNativeRuntime(() => { - it('calls session.touch in a non-standard browser', async () => { - mockClientFetch.mockReturnValue(Promise.resolve({ signedInSessions: [mockSession] })); - - const sut = new Clerk(productionPublishableKey); - await sut.load({ standardBrowser: false }); - - const executionOrder: string[] = []; - mockSession.touch.mockImplementationOnce(() => { - sut.session = mockSession as any; - executionOrder.push('session.touch'); - return Promise.resolve(); - }); - const beforeEmitMock = jest.fn().mockImplementationOnce(() => { - executionOrder.push('before emit'); - return Promise.resolve(); - }); - - await sut.setActive({ organization: { id: 'org_id' } as Organization, beforeEmit: beforeEmitMock }); - - expect(executionOrder).toEqual(['session.touch', 'before emit']); - expect(mockSession.touch).toHaveBeenCalled(); - expect((mockSession as any as ActiveSessionResource)?.lastActiveOrganizationId).toEqual('org_id'); - expect(mockSession.getToken).toHaveBeenCalled(); - expect(beforeEmitMock).toHaveBeenCalledWith(mockSession); - expect(sut.session).toMatchObject(mockSession); - }); + await sut.setActive({ session: mockSession.id }); + + await waitFor(() => { + expect(executionOrder).toEqual(['session.touch', 'set cookie', 'navigate']); + expect(mockSession.touch).toHaveBeenCalled(); + expect(mockSession.getToken).toHaveBeenCalled(); + expect(sut.session).toMatchObject(mockSession); }); - }, - ); + }); + }); }); describe('.load()', () => { diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index 454792854d8..32f0fb7ece7 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -131,6 +131,7 @@ import { Organization, Waitlist, } from './resources/internal'; +import { navigateToTask } from './sessionTasks'; import { warnings } from './warnings'; type SetActiveHook = (intent?: 'sign-out') => void | Promise<void>; @@ -199,6 +200,15 @@ export class Clerk implements ClerkInterface { #options: ClerkOptions = {}; #pageLifecycle: ReturnType<typeof createPageLifecycle> | null = null; #touchThrottledUntil = 0; + #componentNavigationContext: { + navigate: ( + to: string, + options?: { + searchParams?: URLSearchParams; + }, + ) => Promise<unknown>; + basePath: string; + } | null = null; public __internal_getCachedResources: | (() => Promise<{ client: ClientJSONSnapshot | null; environment: EnvironmentJSONSnapshot | null }>) @@ -954,6 +964,11 @@ export class Clerk implements ClerkInterface { session = (this.client.sessions.find(x => x.id === session) as SignedInSessionResource) || null; } + if (session?.status === 'pending') { + await this.#handlePendingSession(session); + return; + } + let newSession = session === undefined ? this.session : session; // At this point, the `session` variable should contain either an `SignedInSessionResource` @@ -1043,6 +1058,38 @@ export class Clerk implements ClerkInterface { await onAfterSetActive(); }; + #handlePendingSession = async (session: SignedInSessionResource) => { + if (!this.environment) { + return; + } + + // Handles multi-session scenario when switching from `active` + // to `pending` + if (inActiveBrowserTab() || !this.#options.standardBrowser) { + await this.#touchCurrentSession(session); + session = this.#getSessionFromClient(session.id) ?? session; + } + + // Syncs __session and __client_uat, in case the `pending` session + // has expired, it needs to trigger a sign-out + const token = await session.getToken(); + if (!token) { + eventBus.dispatch(events.TokenUpdate, { token: null }); + } + + if (session.currentTask) { + await navigateToTask(session.currentTask, { + globalNavigate: this.navigate, + componentNavigationContext: this.#componentNavigationContext, + options: this.#options, + environment: this.environment, + }); + } + + this.#setAccessors(session); + this.#emit(); + }; + public addListener = (listener: ListenerCallback): UnsubscribeCallback => { listener = memoizeListenerCallback(listener); this.#listeners.push(listener); @@ -1070,6 +1117,20 @@ export class Clerk implements ClerkInterface { return unsubscribe; }; + public __internal_setComponentNavigationContext = (context: { + navigate: ( + to: string, + options?: { + searchParams?: URLSearchParams; + }, + ) => Promise<unknown>; + basePath: string; + }) => { + this.#componentNavigationContext = context; + + return () => (this.#componentNavigationContext = null); + }; + public navigate = async (to: string | undefined, options?: NavigateOptions): Promise<unknown> => { if (!to || !inBrowser()) { return; diff --git a/packages/clerk-js/src/core/resources/Session.ts b/packages/clerk-js/src/core/resources/Session.ts index d3972d75327..1c9aa745b69 100644 --- a/packages/clerk-js/src/core/resources/Session.ts +++ b/packages/clerk-js/src/core/resources/Session.ts @@ -363,4 +363,9 @@ export class Session extends BaseResource implements SessionResource { return token.getRawString() || null; }); } + + get currentTask() { + const [task] = this.tasks ?? []; + return task; + } } diff --git a/packages/clerk-js/src/core/sessionTasks.ts b/packages/clerk-js/src/core/sessionTasks.ts new file mode 100644 index 00000000000..29d7d9e0481 --- /dev/null +++ b/packages/clerk-js/src/core/sessionTasks.ts @@ -0,0 +1,53 @@ +import type { ClerkOptions, EnvironmentResource, SessionTask } from '@clerk/types'; + +import { buildURL } from '../utils'; + +export const SESSION_TASK_ROUTE_BY_KEY: Record<SessionTask['key'], string> = { + org: 'add-organization', +} as const; + +interface NavigateToTaskOptions { + componentNavigationContext: { + navigate: ( + to: string, + options?: { + searchParams?: URLSearchParams; + }, + ) => Promise<unknown>; + basePath: string; + } | null; + globalNavigate: (to: string) => Promise<unknown>; + options: ClerkOptions; + environment: EnvironmentResource; +} + +/** + * Handles navigation to the tasks URL based on the application context such + * as internal component routing or custom flows. + * @internal + */ +export function navigateToTask( + task: SessionTask, + { componentNavigationContext, globalNavigate, options, environment }: NavigateToTaskOptions, +) { + const taskRoute = `/${SESSION_TASK_ROUTE_BY_KEY[task.key]}`; + + if (componentNavigationContext) { + return componentNavigationContext.navigate(`/${componentNavigationContext.basePath + taskRoute}`); + } + + const signInUrl = options['signInUrl'] || environment.displayConfig.signInUrl; + const signUpUrl = options['signUpUrl'] || environment.displayConfig.signUpUrl; + const isReferrerSignUpUrl = window.location.href.startsWith(signUpUrl); + + const sessionTaskUrl = buildURL( + // TODO - Accept custom URL option for custom flows in order to eject out of `signInUrl/signUpUrl` + { + base: isReferrerSignUpUrl ? signUpUrl : signInUrl, + hashPath: taskRoute, + }, + { stringify: true }, + ); + + return globalNavigate(sessionTaskUrl); +} diff --git a/packages/clerk-js/src/core/warnings.ts b/packages/clerk-js/src/core/warnings.ts index 1fd500a1e91..2513a4d00cc 100644 --- a/packages/clerk-js/src/core/warnings.ts +++ b/packages/clerk-js/src/core/warnings.ts @@ -16,8 +16,12 @@ const warnings = { 'The <SignUp/> and <SignIn/> components cannot render when a user is already signed in, unless the application allows multiple sessions. Since a user is signed in and this application only allows a single session, Clerk is redirecting to the Home URL instead.', cannotRenderSignUpComponentWhenSessionExists: 'The <SignUp/> component cannot render when a user is already signed in, unless the application allows multiple sessions. Since a user is signed in and this application only allows a single session, Clerk is redirecting to the value set in `afterSignUp` URL instead.', + cannotRenderSignUpComponentWhenTaskExists: + 'The <SignUp/> component cannot render when a user has a pending task, unless the application allows multiple sessions. Since a user is signed in and this application only allows a single session, Clerk is redirecting to the task instead.', cannotRenderSignInComponentWhenSessionExists: 'The <SignIn/> component cannot render when a user is already signed in, unless the application allows multiple sessions. Since a user is signed in and this application only allows a single session, Clerk is redirecting to the `afterSignIn` URL instead.', + cannotRenderSignInComponentWhenTaskExists: + 'The <SignIn/> component cannot render when a user has a pending task, unless the application allows multiple sessions. Since a user is signed in and this application only allows a single session, Clerk is redirecting to the task instead.', cannotRenderComponentWhenUserDoesNotExist: '<UserProfile/> cannot render unless a user is signed in. Since no user is signed in, this is no-op.', cannotRenderComponentWhenOrgDoesNotExist: `<OrganizationProfile/> cannot render unless an organization is active. Since no organization is currently active, this is no-op.`, diff --git a/packages/clerk-js/src/ui/common/redirects.ts b/packages/clerk-js/src/ui/common/redirects.ts index 655e5fcefce..6b785c9d694 100644 --- a/packages/clerk-js/src/ui/common/redirects.ts +++ b/packages/clerk-js/src/ui/common/redirects.ts @@ -1,3 +1,6 @@ +import type { SessionTask } from '@clerk/types'; + +import { SESSION_TASK_ROUTE_BY_KEY } from '../../core/sessionTasks'; import { buildURL } from '../../utils/url'; import type { SignInContextType, SignUpContextType, UserProfileContextType } from './../contexts'; @@ -25,6 +28,28 @@ export function buildVerificationRedirectUrl({ }); } +export function buildSessionTaskRedirectUrl({ + routing, + path, + baseUrl, + task, +}: Pick<SignInContextType | SignUpContextType, 'routing' | 'path'> & { + baseUrl: string; + task?: SessionTask; +}) { + if (!task) { + return null; + } + + return buildRedirectUrl({ + routing, + baseUrl, + path, + endpoint: `/${SESSION_TASK_ROUTE_BY_KEY[task.key]}`, + authQueryString: null, + }); +} + export function buildSSOCallbackURL( ctx: Partial<SignInContextType | SignUpContextType>, baseUrl: string | undefined = '', diff --git a/packages/clerk-js/src/ui/common/withRedirect.tsx b/packages/clerk-js/src/ui/common/withRedirect.tsx index 43ec9b95172..81336a63ec2 100644 --- a/packages/clerk-js/src/ui/common/withRedirect.tsx +++ b/packages/clerk-js/src/ui/common/withRedirect.tsx @@ -61,8 +61,10 @@ export const withRedirectToAfterSignIn = <P extends AvailableComponentProps>(Com return withRedirect( Component, sessionExistsAndSingleSessionModeEnabled, - ({ clerk }) => signInCtx.afterSignInUrl || clerk.buildAfterSignInUrl(), - warnings.cannotRenderSignInComponentWhenSessionExists, + ({ clerk }) => signInCtx.sessionTaskUrl || signInCtx.afterSignInUrl || clerk.buildAfterSignInUrl(), + signInCtx.sessionTaskUrl + ? warnings.cannotRenderSignInComponentWhenTaskExists + : warnings.cannotRenderSignInComponentWhenSessionExists, )(props); }; @@ -80,8 +82,10 @@ export const withRedirectToAfterSignUp = <P extends AvailableComponentProps>(Com return withRedirect( Component, sessionExistsAndSingleSessionModeEnabled, - ({ clerk }) => signUpCtx.afterSignUpUrl || clerk.buildAfterSignUpUrl(), - warnings.cannotRenderSignUpComponentWhenSessionExists, + ({ clerk }) => signUpCtx.sessionTaskUrl || signUpCtx.afterSignUpUrl || clerk.buildAfterSignUpUrl(), + signUpCtx.sessionTaskUrl + ? warnings.cannotRenderSignUpComponentWhenTaskExists + : warnings.cannotRenderSignUpComponentWhenSessionExists, )(props); }; @@ -89,11 +93,3 @@ export const withRedirectToAfterSignUp = <P extends AvailableComponentProps>(Com return HOC; }; - -export const withRedirectToHomeSingleSessionGuard = <P extends AvailableComponentProps>(Component: ComponentType<P>) => - withRedirect( - Component, - sessionExistsAndSingleSessionModeEnabled, - ({ environment }) => environment.displayConfig.homeUrl, - warnings.cannotRenderComponentWhenSessionExists, - ); diff --git a/packages/clerk-js/src/ui/components/SessionTask/SessionTask.tsx b/packages/clerk-js/src/ui/components/SessionTask/SessionTask.tsx new file mode 100644 index 00000000000..b869d62265e --- /dev/null +++ b/packages/clerk-js/src/ui/components/SessionTask/SessionTask.tsx @@ -0,0 +1,28 @@ +import { useClerk } from '@clerk/shared/react/index'; +import { eventComponentMounted } from '@clerk/shared/telemetry'; +import type { SessionTask } from '@clerk/types'; + +import { OrganizationListContext } from '../../contexts'; +import { OrganizationList } from '../OrganizationList'; + +const ContentRegistry: Record<SessionTask['key'], React.ComponentType> = { + org: () => ( + // TODO - Hide personal workspace within organization list context based on environment + <OrganizationListContext.Provider value={{ componentName: 'OrganizationList', hidePersonal: true }}> + <OrganizationList /> + </OrganizationListContext.Provider> + ), +}; + +/** + * @internal + */ +export function SessionTask({ task }: { task: SessionTask['key'] }): React.ReactNode { + const clerk = useClerk(); + + clerk.telemetry?.record(eventComponentMounted('SessionTask', { task })); + + const Content = ContentRegistry[task]; + + return <Content />; +} diff --git a/packages/clerk-js/src/ui/components/SessionTask/index.ts b/packages/clerk-js/src/ui/components/SessionTask/index.ts new file mode 100644 index 00000000000..ddae613a6e2 --- /dev/null +++ b/packages/clerk-js/src/ui/components/SessionTask/index.ts @@ -0,0 +1 @@ +export * from './SessionTask'; diff --git a/packages/clerk-js/src/ui/components/SignIn/SignIn.tsx b/packages/clerk-js/src/ui/components/SignIn/SignIn.tsx index c30b60ed5a0..2a5895836f0 100644 --- a/packages/clerk-js/src/ui/components/SignIn/SignIn.tsx +++ b/packages/clerk-js/src/ui/components/SignIn/SignIn.tsx @@ -2,6 +2,7 @@ import { useClerk } from '@clerk/shared/react'; import type { SignInModalProps, SignInProps } from '@clerk/types'; import React from 'react'; +import { SESSION_TASK_ROUTE_BY_KEY } from '../../../core/sessionTasks'; import { normalizeRoutingOptions } from '../../../utils/normalizeRoutingOptions'; import { SignInEmailLinkFlowComplete, SignUpEmailLinkFlowComplete } from '../../common/EmailLinkCompleteFlowCard'; import type { SignUpContextType } from '../../contexts'; @@ -14,7 +15,8 @@ import { } from '../../contexts'; import { Flow } from '../../customizables'; import { useFetch } from '../../hooks'; -import { Route, Switch, VIRTUAL_ROUTER_BASE_PATH } from '../../router'; +import { preloadSessionTask, SessionTask } from '../../lazyModules/components'; +import { Route, Switch, useRouter, VIRTUAL_ROUTER_BASE_PATH } from '../../router'; import { LazySignUpContinue, LazySignUpSSOCallback, @@ -128,6 +130,11 @@ function SignInRoutes(): JSX.Element { > <LazySignUpVerifyPhone /> </Route> + {signInContext.withSessionTasks && ( + <Route path={SESSION_TASK_ROUTE_BY_KEY['org']}> + <SessionTask task='org' /> + </Route> + )} <Route index> <LazySignUpContinue /> </Route> @@ -137,7 +144,11 @@ function SignInRoutes(): JSX.Element { </Route> </Route> )} - + {signInContext.withSessionTasks && ( + <Route path={SESSION_TASK_ROUTE_BY_KEY['org']}> + <SessionTask task='org' /> + </Route> + )} <Route index> <SignInStart /> </Route> @@ -152,7 +163,13 @@ function SignInRoutes(): JSX.Element { const usePreloadSignUp = (enabled = false) => useFetch(enabled ? preloadSignUp : undefined, 'preloadComponent', { staleTime: Infinity }); +const usePreloadSessionTask = (enabled = false) => + useFetch(enabled ? preloadSessionTask : undefined, 'preloadComponent', { staleTime: Infinity }); + function SignInRoot() { + const { __internal_setComponentNavigationContext } = useClerk(); + const { navigate, basePath } = useRouter(); + const signInContext = useSignInContext(); const normalizedSignUpContext = { componentName: 'SignUp', @@ -170,6 +187,13 @@ function SignInRoot() { */ usePreloadSignUp(signInContext.isCombinedFlow); + // `experimental.withSessionTasks` will be removed soon in favor of checking via environment response + usePreloadSessionTask(signInContext.withSessionTasks); + + React.useEffect(() => { + return __internal_setComponentNavigationContext?.({ basePath, navigate }); + }, [basePath, navigate]); + return ( <SignUpContext.Provider value={normalizedSignUpContext}> <SignInRoutes /> diff --git a/packages/clerk-js/src/ui/components/SignUp/SignUp.tsx b/packages/clerk-js/src/ui/components/SignUp/SignUp.tsx index c96abac836b..9e42f7efac8 100644 --- a/packages/clerk-js/src/ui/components/SignUp/SignUp.tsx +++ b/packages/clerk-js/src/ui/components/SignUp/SignUp.tsx @@ -2,16 +2,22 @@ import { useClerk } from '@clerk/shared/react'; import type { SignUpModalProps, SignUpProps } from '@clerk/types'; import React from 'react'; +import { SESSION_TASK_ROUTE_BY_KEY } from '../../../core/sessionTasks'; import { SignUpEmailLinkFlowComplete } from '../../common/EmailLinkCompleteFlowCard'; import { SignUpContext, useSignUpContext, withCoreSessionSwitchGuard } from '../../contexts'; import { Flow } from '../../customizables'; -import { Route, Switch, VIRTUAL_ROUTER_BASE_PATH } from '../../router'; +import { useFetch } from '../../hooks'; +import { preloadSessionTask, SessionTask } from '../../lazyModules/components'; +import { Route, Switch, useRouter, VIRTUAL_ROUTER_BASE_PATH } from '../../router'; import { SignUpContinue } from './SignUpContinue'; import { SignUpSSOCallback } from './SignUpSSOCallback'; import { SignUpStart } from './SignUpStart'; import { SignUpVerifyEmail } from './SignUpVerifyEmail'; import { SignUpVerifyPhone } from './SignUpVerifyPhone'; +const usePreloadSessionTask = (enabled = false) => + useFetch(enabled ? preloadSessionTask : undefined, 'preloadComponent', { staleTime: Infinity }); + function RedirectToSignUp() { const clerk = useClerk(); React.useEffect(() => { @@ -21,8 +27,17 @@ function RedirectToSignUp() { } function SignUpRoutes(): JSX.Element { + const { __internal_setComponentNavigationContext } = useClerk(); + const { navigate, basePath } = useRouter(); const signUpContext = useSignUpContext(); + // `experimental.withSessionTasks` will be removed soon in favor of checking via environment response + usePreloadSessionTask(signUpContext.withSessionTasks); + + React.useEffect(() => { + return __internal_setComponentNavigationContext?.({ basePath, navigate }); + }, [basePath, navigate]); + return ( <Flow.Root flow='signUp'> <Switch> @@ -74,6 +89,11 @@ function SignUpRoutes(): JSX.Element { <SignUpContinue /> </Route> </Route> + {signUpContext.withSessionTasks && ( + <Route path={SESSION_TASK_ROUTE_BY_KEY['org']}> + <SessionTask task='org' /> + </Route> + )} <Route index> <SignUpStart /> </Route> diff --git a/packages/clerk-js/src/ui/contexts/components/SignIn.ts b/packages/clerk-js/src/ui/contexts/components/SignIn.ts index 564f755992c..d709a0d14b4 100644 --- a/packages/clerk-js/src/ui/contexts/components/SignIn.ts +++ b/packages/clerk-js/src/ui/contexts/components/SignIn.ts @@ -5,7 +5,12 @@ import { createContext, useContext, useMemo } from 'react'; import { SIGN_IN_INITIAL_VALUE_KEYS } from '../../../core/constants'; import { buildURL } from '../../../utils'; import { RedirectUrls } from '../../../utils/redirectUrls'; -import { buildRedirectUrl, MAGIC_LINK_VERIFY_PATH_ROUTE, SSO_CALLBACK_PATH_ROUTE } from '../../common/redirects'; +import { + buildRedirectUrl, + buildSessionTaskRedirectUrl, + MAGIC_LINK_VERIFY_PATH_ROUTE, + SSO_CALLBACK_PATH_ROUTE, +} from '../../common/redirects'; import { useEnvironment, useOptions } from '../../contexts'; import type { ParsedQueryString } from '../../router'; import { useRouter } from '../../router'; @@ -21,11 +26,13 @@ export type SignInContextType = SignInCtx & { authQueryString: string | null; afterSignUpUrl: string; afterSignInUrl: string; + sessionTaskUrl: string | null; transferable: boolean; waitlistUrl: string; emailLinkRedirectUrl: string; ssoCallbackUrl: string; isCombinedFlow: boolean; + withSessionTasks: boolean; }; export const SignInContext = createContext<SignInCtx | null>(null); @@ -112,6 +119,13 @@ export const useSignInContext = (): SignInContextType => { const signUpContinueUrl = buildURL({ base: signUpUrl, hashPath: '/continue' }, { stringify: true }); + const sessionTaskUrl = buildSessionTaskRedirectUrl({ + task: clerk.session?.currentTask, + path: ctx.path, + routing: ctx.routing, + baseUrl: signInUrl, + }); + return { ...(ctx as SignInCtx), transferable: ctx.transferable ?? true, @@ -123,11 +137,13 @@ export const useSignInContext = (): SignInContextType => { afterSignUpUrl, emailLinkRedirectUrl, ssoCallbackUrl, + sessionTaskUrl, navigateAfterSignIn, signUpContinueUrl, queryParams, initialValues: { ...ctx.initialValues, ...initialValuesFromQueryParams }, authQueryString, isCombinedFlow, + withSessionTasks: !!options.experimental?.withSessionTasks, }; }; diff --git a/packages/clerk-js/src/ui/contexts/components/SignUp.ts b/packages/clerk-js/src/ui/contexts/components/SignUp.ts index 32b4e4794c0..59ce5f4ab09 100644 --- a/packages/clerk-js/src/ui/contexts/components/SignUp.ts +++ b/packages/clerk-js/src/ui/contexts/components/SignUp.ts @@ -5,7 +5,12 @@ import { createContext, useContext, useMemo } from 'react'; import { SIGN_UP_INITIAL_VALUE_KEYS } from '../../../core/constants'; import { buildURL } from '../../../utils'; import { RedirectUrls } from '../../../utils/redirectUrls'; -import { buildRedirectUrl, MAGIC_LINK_VERIFY_PATH_ROUTE, SSO_CALLBACK_PATH_ROUTE } from '../../common/redirects'; +import { + buildRedirectUrl, + buildSessionTaskRedirectUrl, + MAGIC_LINK_VERIFY_PATH_ROUTE, + SSO_CALLBACK_PATH_ROUTE, +} from '../../common/redirects'; import { useEnvironment, useOptions } from '../../contexts'; import type { ParsedQueryString } from '../../router'; import { useRouter } from '../../router'; @@ -22,9 +27,11 @@ export type SignUpContextType = SignUpCtx & { afterSignUpUrl: string; afterSignInUrl: string; waitlistUrl: string; + sessionTaskUrl: string | null; isCombinedFlow: boolean; emailLinkRedirectUrl: string; ssoCallbackUrl: string; + withSessionTasks: boolean; }; export const SignUpContext = createContext<SignUpCtx | null>(null); @@ -107,6 +114,13 @@ export const useSignUpContext = (): SignUpContextType => { // TODO: Avoid building this url again to remove duplicate code. Get it from window.Clerk instead. const secondFactorUrl = buildURL({ base: signInUrl, hashPath: '/factor-two' }, { stringify: true }); + const sessionTaskUrl = buildSessionTaskRedirectUrl({ + task: clerk.session?.currentTask, + path: ctx.path, + routing: ctx.routing, + baseUrl: signUpUrl, + }); + return { ...ctx, componentName, @@ -118,10 +132,12 @@ export const useSignUpContext = (): SignUpContextType => { afterSignInUrl, emailLinkRedirectUrl, ssoCallbackUrl, + sessionTaskUrl, navigateAfterSignUp, queryParams, initialValues: { ...ctx.initialValues, ...initialValuesFromQueryParams }, authQueryString, isCombinedFlow, + withSessionTasks: !!options.experimental?.withSessionTasks, }; }; diff --git a/packages/clerk-js/src/ui/lazyModules/components.ts b/packages/clerk-js/src/ui/lazyModules/components.ts index 51788d64910..6182576c716 100644 --- a/packages/clerk-js/src/ui/lazyModules/components.ts +++ b/packages/clerk-js/src/ui/lazyModules/components.ts @@ -19,6 +19,7 @@ const componentImportPaths = { KeylessPrompt: () => import(/* webpackChunkName: "keylessPrompt" */ '../components/KeylessPrompt'), PricingTable: () => import(/* webpackChunkName: "pricingTable" */ '../components/PricingTable'), Checkout: () => import(/* webpackChunkName: "checkout" */ '../components/Checkout'), + SessionTask: () => import(/* webpackChunkName: "sessionTask" */ '../components/SessionTask'), } as const; export const SignIn = lazy(() => componentImportPaths.SignIn().then(module => ({ default: module.SignIn }))); @@ -94,6 +95,11 @@ export const PricingTable = lazy(() => componentImportPaths.PricingTable().then(module => ({ default: module.__experimental_PricingTable })), ); +export const preloadSessionTask = () => import(/* webpackChunkName: "sessionTask" */ '../components/SessionTask'); +export const SessionTask = lazy(() => + componentImportPaths.SessionTask().then(module => ({ default: module.SessionTask })), +); + export const preloadComponent = async (component: unknown) => { return componentImportPaths[component as keyof typeof componentImportPaths]?.(); }; diff --git a/packages/react/src/isomorphicClerk.ts b/packages/react/src/isomorphicClerk.ts index 455b9a72aa8..c2b85093152 100644 --- a/packages/react/src/isomorphicClerk.ts +++ b/packages/react/src/isomorphicClerk.ts @@ -93,6 +93,7 @@ type IsomorphicLoadedClerk = Without< | '__internal_getCachedResources' | '__internal_reloadInitialResources' | '__experimental_commerce' + | '__internal_setComponentNavigationContext' > & { client: ClientResource | undefined; __experimental_commerce: __experimental_CommerceNamespace | undefined; diff --git a/packages/types/src/clerk.ts b/packages/types/src/clerk.ts index 29834688505..e1366319454 100644 --- a/packages/types/src/clerk.ts +++ b/packages/types/src/clerk.ts @@ -426,6 +426,21 @@ export interface Clerk { */ __internal_addNavigationListener: (callback: () => void) => UnsubscribeCallback; + /** + * Registers the internal navigation context from UI components in order to + * be triggered from `Clerk` methods + * @internal + */ + __internal_setComponentNavigationContext: (context: { + navigate: ( + to: string, + options?: { + searchParams?: URLSearchParams; + }, + ) => Promise<unknown>; + basePath: string; + }) => () => void; + /** * Set the active session and organization explicitly. * @@ -803,6 +818,8 @@ export type ClerkOptions = ClerkOptionsNavigation & */ rethrowOfflineNetworkErrors: boolean; commerce: boolean; + // `experimental.withSessionTasks` will be removed soon in favor of checking via environment response + withSessionTasks: boolean; }, Record<string, any> >; diff --git a/packages/types/src/session.ts b/packages/types/src/session.ts index 6f40a118250..bd48545b462 100644 --- a/packages/types/src/session.ts +++ b/packages/types/src/session.ts @@ -124,6 +124,7 @@ export interface SessionResource extends ClerkResource { lastActiveAt: Date; actor: ActJWTClaim | null; tasks: Array<SessionTask> | null; + currentTask?: SessionTask; /** * The user associated with the session. */ @@ -173,6 +174,7 @@ export interface ActiveSessionResource extends SessionResource { export interface PendingSessionResource extends SessionResource { status: 'pending'; user: UserResource; + currentTask: SessionTask; } /** @@ -224,7 +226,7 @@ export interface PublicUserData { } export interface SessionTask { - key: 'orgs'; + key: 'org'; } export type GetTokenOptions = {