diff --git a/.changeset/rich-donuts-agree.md b/.changeset/rich-donuts-agree.md new file mode 100644 index 00000000000..e00f2d31dec --- /dev/null +++ b/.changeset/rich-donuts-agree.md @@ -0,0 +1,23 @@ +--- +'@clerk/clerk-js': minor +'@clerk/types': minor +--- + +Add `navigate` parameter to `clerk.setActive()` for custom navigation before the session and/or organization is set. + +It's useful for handling pending session tasks for after-auth flows: + +```typescript +await clerk.setActive({ + session, + navigate: async ({ session }) => { + const currentTask = session.currentTask; + if (currentTask) { + await router.push(`/onboarding/${currentTask.key}`) + return; + } + + await router.push('/dashboard') + } +}); +``` diff --git a/.changeset/warm-rocks-flow.md b/.changeset/warm-rocks-flow.md new file mode 100644 index 00000000000..890e514aef9 --- /dev/null +++ b/.changeset/warm-rocks-flow.md @@ -0,0 +1,10 @@ +--- +'@clerk/tanstack-react-start': minor +'@clerk/react-router': minor +'@clerk/nextjs': minor +'@clerk/clerk-react': minor +'@clerk/remix': minor +'@clerk/vue': minor +--- + +Rename `RedirectToTask` control component to `RedirectToTasks` diff --git a/integration/tests/session-tasks-sign-in.test.ts b/integration/tests/session-tasks-sign-in.test.ts index 7502fdee7be..f91a3ec3f85 100644 --- a/integration/tests/session-tasks-sign-in.test.ts +++ b/integration/tests/session-tasks-sign-in.test.ts @@ -83,7 +83,7 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withSessionTasks] })( await u.po.signIn.enterTestOtpCode(); // Resolves task - await u.po.signIn.waitForMounted(); + await u.po.signUp.waitForMounted(); const fakeOrganization = Object.assign(u.services.organizations.createFakeOrganization(), { slug: u.services.organizations.createFakeOrganization().slug + '-with-sign-in-sso', }); diff --git a/integration/tests/session-tasks-sign-up.test.ts b/integration/tests/session-tasks-sign-up.test.ts index d43ea2c5def..ca262f281e5 100644 --- a/integration/tests/session-tasks-sign-up.test.ts +++ b/integration/tests/session-tasks-sign-up.test.ts @@ -1,19 +1,28 @@ +import { createClerkClient } from '@clerk/backend'; import { expect, test } from '@playwright/test'; import { appConfigs } from '../presets'; +import { instanceKeys } from '../presets/envs'; import type { FakeUser } from '../testUtils'; import { createTestUtils, testAgainstRunningApps } from '../testUtils'; +import { createUserService } from '../testUtils/usersService'; testAgainstRunningApps({ withEnv: [appConfigs.envs.withSessionTasks] })( 'session tasks after sign-up flow @nextjs', ({ app }) => { test.describe.configure({ mode: 'serial' }); - let fakeUser: FakeUser; + let regularFakeUser: FakeUser; + let fakeUserForOAuth: FakeUser; test.beforeEach(() => { const u = createTestUtils({ app }); - fakeUser = u.services.users.createFakeUser({ + regularFakeUser = u.services.users.createFakeUser({ + fictionalEmail: true, + withPhoneNumber: true, + withUsername: true, + }); + fakeUserForOAuth = u.services.users.createFakeUser({ fictionalEmail: true, withPhoneNumber: true, withUsername: true, @@ -23,7 +32,16 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withSessionTasks] })( test.afterAll(async () => { const u = createTestUtils({ app }); await u.services.organizations.deleteAll(); - await fakeUser.deleteIfExists(); + await regularFakeUser.deleteIfExists(); + + // Delete user from OAuth provider instance + const client = createClerkClient({ + secretKey: instanceKeys.get('oauth-provider').sk, + publishableKey: instanceKeys.get('oauth-provider').pk, + }); + const users = createUserService(client); + await users.deleteIfExists({ email: fakeUserForOAuth.email }); + await app.teardown(); }); @@ -38,8 +56,8 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withSessionTasks] })( const u = createTestUtils({ app, page, context }); await u.po.signUp.goTo(); await u.po.signUp.signUpWithEmailAndPassword({ - email: fakeUser.email, - password: fakeUser.password, + email: regularFakeUser.email, + password: regularFakeUser.password, }); await u.po.expect.toBeSignedIn(); @@ -68,12 +86,12 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withSessionTasks] })( await u.po.signIn.getGoToSignUp().click(); await u.po.signUp.waitForMounted(); - await u.po.signUp.setEmailAddress(fakeUser.email); + await u.po.signUp.setEmailAddress(fakeUserForOAuth.email); await u.po.signUp.continue(); await u.po.signUp.enterTestOtpCode(); // Resolves task - await u.po.signIn.waitForMounted(); + await u.po.signUp.waitForMounted(); const fakeOrganization = Object.assign(u.services.organizations.createFakeOrganization(), { slug: u.services.organizations.createFakeOrganization().slug + '-with-sign-in-sso', }); diff --git a/packages/clerk-js/bundlewatch.config.json b/packages/clerk-js/bundlewatch.config.json index 00856735535..4fd266e054a 100644 --- a/packages/clerk-js/bundlewatch.config.json +++ b/packages/clerk-js/bundlewatch.config.json @@ -1,9 +1,9 @@ { "files": [ - { "path": "./dist/clerk.js", "maxSize": "622.25KB" }, - { "path": "./dist/clerk.browser.js", "maxSize": "76KB" }, + { "path": "./dist/clerk.js", "maxSize": "625KB" }, + { "path": "./dist/clerk.browser.js", "maxSize": "78KB" }, { "path": "./dist/clerk.legacy.browser.js", "maxSize": "117KB" }, - { "path": "./dist/clerk.headless*.js", "maxSize": "58KB" }, + { "path": "./dist/clerk.headless*.js", "maxSize": "61KB" }, { "path": "./dist/ui-common*.js", "maxSize": "113KB" }, { "path": "./dist/ui-common*.legacy.*.js", "maxSize": "118KB" }, { "path": "./dist/vendors*.js", "maxSize": "40.2KB" }, diff --git a/packages/clerk-js/src/core/__tests__/clerk.test.ts b/packages/clerk-js/src/core/__tests__/clerk.test.ts index 8109c5e5faa..8dee5fba0a6 100644 --- a/packages/clerk-js/src/core/__tests__/clerk.test.ts +++ b/packages/clerk-js/src/core/__tests__/clerk.test.ts @@ -444,6 +444,18 @@ describe('Clerk singleton', () => { expect(sut.navigate).toHaveBeenCalledWith('/redirect-url-path'); }); + it('calls `navigate`', async () => { + mockSession.touch.mockReturnValue(Promise.resolve()); + mockClientFetch.mockReturnValue(Promise.resolve({ signedInSessions: [mockSession] })); + const navigate = jest.fn(); + + const sut = new Clerk(productionPublishableKey); + await sut.load(); + await sut.setActive({ session: mockSession as any as PendingSessionResource, navigate }); + expect(mockSession.touch).toHaveBeenCalled(); + expect(navigate).toHaveBeenCalled(); + }); + mockNativeRuntime(() => { it('calls session.touch in a non-standard browser', async () => { mockClientFetch.mockReturnValue(Promise.resolve({ signedInSessions: [mockSession] })); @@ -484,7 +496,7 @@ describe('Clerk singleton', () => { getToken: jest.fn(), lastActiveToken: { getRawString: () => 'mocked-token' }, tasks: [{ key: 'choose-organization' }], - currentTask: { key: 'choose-organization', __internal_getUrl: () => 'https://sut/tasks/choose-organization' }, + currentTask: { key: 'choose-organization' }, reload: jest.fn(() => Promise.resolve({ id: '1', @@ -493,7 +505,6 @@ describe('Clerk singleton', () => { tasks: [{ key: 'choose-organization' }], currentTask: { key: 'choose-organization', - __internal_getUrl: () => 'https://sut/tasks/choose-organization', }, }), ), @@ -518,10 +529,64 @@ describe('Clerk singleton', () => { mockSession.touch.mockReturnValue(Promise.resolve()); mockClientFetch.mockReturnValue(Promise.resolve({ signedInSessions: [mockSession] })); + const sut = new Clerk(productionPublishableKey); + await sut.load(); + await sut.setActive({ session: mockSession as any as PendingSessionResource }); + expect(mockSession.touch).toHaveBeenCalled(); + }); + + it('does not call __unstable__onBeforeSetActive before session.touch', async () => { + mockSession.touch.mockReturnValueOnce(Promise.resolve()); + mockClientFetch.mockReturnValue(Promise.resolve({ signedInSessions: [mockSession] })); + + const onBeforeSetActive = jest.fn(); + (window as any).__unstable__onBeforeSetActive = onBeforeSetActive; + + const sut = new Clerk(productionPublishableKey); + await sut.load(); + await sut.setActive({ session: mockSession as any as ActiveSessionResource }); + expect(onBeforeSetActive).not.toHaveBeenCalled(); + }); + + it('does not call __unstable__onAfterSetActive after session.touch', async () => { + mockSession.touch.mockReturnValueOnce(Promise.resolve()); + mockClientFetch.mockReturnValue(Promise.resolve({ signedInSessions: [mockSession] })); + + const onAfterSetActive = jest.fn(); + (window as any).__unstable__onAfterSetActive = onAfterSetActive; + const sut = new Clerk(productionPublishableKey); await sut.load(); await sut.setActive({ session: mockSession as any as ActiveSessionResource }); + expect(onAfterSetActive).not.toHaveBeenCalled(); + }); + + it('navigate to `taskUrl` option', async () => { + mockSession.touch.mockReturnValue(Promise.resolve()); + mockClientFetch.mockReturnValue(Promise.resolve({ signedInSessions: [mockSession] })); + + const sut = new Clerk(productionPublishableKey); + sut.navigate = jest.fn(); + await sut.load({ + taskUrls: { + 'choose-organization': '/choose-organization', + }, + }); + await sut.setActive({ session: mockSession as any as PendingSessionResource }); expect(mockSession.touch).toHaveBeenCalled(); + expect(sut.navigate).toHaveBeenCalledWith('/choose-organization'); + }); + + it('calls `navigate`', async () => { + mockSession.touch.mockReturnValue(Promise.resolve()); + mockClientFetch.mockReturnValue(Promise.resolve({ signedInSessions: [mockSession] })); + const navigate = jest.fn(); + + const sut = new Clerk(productionPublishableKey); + await sut.load(); + await sut.setActive({ session: mockSession as any as PendingSessionResource, navigate }); + expect(mockSession.touch).toHaveBeenCalled(); + expect(navigate).toHaveBeenCalled(); }); }); @@ -907,7 +972,7 @@ describe('Clerk singleton', () => { mockEnvironmentFetch.mockReset(); }); - describe('with after-auth flows', () => { + describe('with pending session', () => { beforeEach(() => { mockClientFetch.mockReset(); mockEnvironmentFetch.mockReturnValue( @@ -924,13 +989,13 @@ describe('Clerk singleton', () => { ); }); - it('redirects to pending task', async () => { + it('navigates to task', async () => { const mockSession = { id: '1', status: 'pending', user: {}, tasks: [{ key: 'choose-organization' }], - currentTask: { key: 'choose-organization', __internal_getUrl: () => 'https://sut/tasks/choose-organization' }, + currentTask: { key: 'choose-organization' }, lastActiveToken: { getRawString: () => 'mocked-token' }, }; @@ -954,7 +1019,6 @@ describe('Clerk singleton', () => { }), ); - const mockSetActive = jest.fn(); const mockSignUpCreate = jest .fn() .mockReturnValue(Promise.resolve({ status: 'complete', createdSessionId: '123' })); @@ -965,58 +1029,11 @@ describe('Clerk singleton', () => { fail('we should always have a client'); } sut.client.signUp.create = mockSignUpCreate; - sut.setActive = mockSetActive; await sut.handleRedirectCallback(); await waitFor(() => { - expect(mockNavigate.mock.calls[0][0]).toBe('/sign-in#/tasks/choose-organization'); - }); - }); - - it('redirects to after sign-in URL when task has been resolved', async () => { - const mockSession = { - id: '1', - status: 'active', - user: {}, - lastActiveToken: { getRawString: () => 'mocked-token' }, - }; - - const mockResource = { - ...mockSession, - remove: jest.fn(), - touch: jest.fn(() => Promise.resolve()), - getToken: jest.fn(), - reload: jest.fn(() => Promise.resolve(mockSession)), - }; - - mockResource.touch.mockReturnValueOnce(Promise.resolve()); - mockClientFetch.mockReturnValue( - Promise.resolve({ - signedInSessions: [mockResource], - signIn: new SignIn(null), - signUp: new SignUp(null), - isEligibleForTouch: () => false, - }), - ); - - const mockSetActive = jest.fn(); - const mockSignUpCreate = jest - .fn() - .mockReturnValue(Promise.resolve({ status: 'complete', createdSessionId: '123' })); - - const sut = new Clerk(productionPublishableKey); - await sut.load(mockedLoadOptions); - if (!sut.client) { - fail('we should always have a client'); - } - sut.client.signUp.create = mockSignUpCreate; - sut.setActive = mockSetActive; - - await sut.handleRedirectCallback(); - - await waitFor(() => { - expect(mockNavigate.mock.calls[0][0]).toBe('/'); + expect(mockNavigate.mock.calls[0][0]).toBe('/sign-up#/tasks/choose-organization'); }); }); }); @@ -1486,7 +1503,7 @@ describe('Clerk singleton', () => { mockClientFetch.mockReturnValue( Promise.resolve({ sessions: [mockSession], - signedInSessions: [], + signedInSessions: [mockSession], signIn: new SignIn(null), signUp: new SignUp({ status: 'missing_requirements', @@ -1526,7 +1543,7 @@ describe('Clerk singleton', () => { const mockSession = { id: sessionId, remove: jest.fn(), - status, + status: 'active', user: {}, touch: jest.fn(() => Promise.resolve()), getToken: jest.fn(), @@ -1547,7 +1564,7 @@ describe('Clerk singleton', () => { mockClientFetch.mockReturnValue( Promise.resolve({ sessions: [mockSession], - signedInSessions: [], + signedInSessions: [mockSession], signIn: new SignIn(null), signUp: new SignUp({ status: 'missing_requirements', @@ -2432,118 +2449,6 @@ describe('Clerk singleton', () => { }); }); - describe('navigateToTask', () => { - describe('with `pending` session status', () => { - const mockSession = { - id: '1', - status: 'pending', - user: {}, - tasks: [{ key: 'choose-organization' }], - currentTask: { key: 'choose-organization', __internal_getUrl: () => 'https://sut/tasks/choose-organization' }, - lastActiveToken: { getRawString: () => 'mocked-token' }, - }; - - const mockResource = { - ...mockSession, - remove: jest.fn(), - touch: jest.fn(() => Promise.resolve()), - getToken: jest.fn(), - reload: jest.fn(() => Promise.resolve(mockSession)), - }; - - beforeEach(() => { - mockResource.touch.mockReturnValueOnce(Promise.resolve()); - mockClientFetch.mockReturnValue( - Promise.resolve({ - signedInSessions: [mockResource], - isEligibleForTouch: () => false, - }), - ); - }); - - afterEach(() => { - mockResource.remove.mockReset(); - mockResource.touch.mockReset(); - }); - - it('navigates to next task with default internal routing for AIOs', async () => { - const sut = new Clerk(productionPublishableKey); - await sut.load(mockedLoadOptions); - - await sut.setActive({ session: mockResource as any as PendingSessionResource }); - await sut.__internal_navigateToTaskIfAvailable(); - - expect(mockNavigate.mock.calls[0][0]).toBe('/sign-in#/tasks/choose-organization'); - }); - - it('navigates to next task with custom routing from clerk options', async () => { - const sut = new Clerk(productionPublishableKey); - await sut.load({ - ...mockedLoadOptions, - taskUrls: { - 'choose-organization': '/onboarding/choose-organization', - }, - }); - - await sut.setActive({ session: mockResource as any as PendingSessionResource }); - await sut.__internal_navigateToTaskIfAvailable(); - - expect(mockNavigate.mock.calls[0][0]).toBe('/onboarding/choose-organization'); - }); - }); - - 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' }, - reload: jest.fn(() => - Promise.resolve({ - id: '1', - remove: jest.fn(), - status: 'active', - user: {}, - touch: jest.fn(() => Promise.resolve()), - getToken: jest.fn(), - lastActiveToken: { getRawString: () => 'mocked-token' }, - }), - ), - }; - - afterEach(() => { - mockSession.remove.mockReset(); - mockSession.touch.mockReset(); - (window as any).__unstable__onBeforeSetActive = null; - (window as any).__unstable__onAfterSetActive = null; - }); - - it('navigates to redirect url on completion', async () => { - mockSession.touch.mockReturnValue(Promise.resolve()); - mockClientFetch.mockReturnValue( - Promise.resolve({ - signedInSessions: [mockSession], - isEligibleForTouch: () => false, - }), - ); - - const sut = new Clerk(productionPublishableKey); - await sut.load(mockedLoadOptions); - await sut.setActive({ session: mockSession as any as ActiveSessionResource }); - - const redirectUrlComplete = '/welcome-to-app'; - await sut.__internal_navigateToTaskIfAvailable({ redirectUrlComplete }); - - console.log(mockNavigate.mock.calls); - - expect(mockNavigate.mock.calls[0][0]).toBe('/welcome-to-app'); - }); - }); - }); - describe('updateClient', () => { afterEach(() => { // cleanup global window pollution diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index 539b50e961e..5679e7ef1cc 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -18,8 +18,6 @@ import type { __experimental_CheckoutInstance, __experimental_CheckoutOptions, __internal_CheckoutProps, - __internal_ComponentNavigationContext, - __internal_NavigateToTaskIfAvailableParams, __internal_OAuthConsentProps, __internal_PlanDetailsProps, __internal_SubscriptionDetailsProps, @@ -55,7 +53,6 @@ import type { OrganizationProfileProps, OrganizationResource, OrganizationSwitcherProps, - PendingSessionResource, PricingTableProps, PublicKeyCredentialCreationOptionsWithoutExtensions, PublicKeyCredentialRequestOptionsWithoutExtensions, @@ -64,6 +61,7 @@ import type { RedirectOptions, Resources, SDKMetadata, + SessionResource, SetActiveParams, SignedInSessionResource, SignInProps, @@ -111,12 +109,12 @@ import { isError, isOrganizationId, isRedirectForFAPIInitiatedFlow, + isSignedInAndSingleSessionModeEnabled, noOrganizationExists, noUserExists, processCssLayerNameExtraction, removeClerkQueryParam, requiresUserInput, - sessionExistsAndSingleSessionModeEnabled, stripOrigin, windowNavigate, } from '../utils'; @@ -153,7 +151,7 @@ import { Organization, Waitlist, } from './resources/internal'; -import { navigateToTask } from './sessionTasks'; +import { getTaskEndpoint, navigateIfTaskExists, warnMissingPendingTaskHandlers } from './sessionTasks'; import { State } from './state'; import { warnings } from './warnings'; @@ -233,7 +231,6 @@ export class Clerk implements ClerkInterface { #options: ClerkOptions = {}; #pageLifecycle: ReturnType | null = null; #touchThrottledUntil = 0; - #componentNavigationContext: __internal_ComponentNavigationContext | null = null; #publicEventBus = createClerkEventBus(); public __internal_getCachedResources: @@ -554,7 +551,7 @@ export class Clerk implements ClerkInterface { public openSignIn = (props?: SignInProps): void => { this.assertComponentsReady(this.#componentControls); - if (sessionExistsAndSingleSessionModeEnabled(this, this.environment)) { + if (isSignedInAndSingleSessionModeEnabled(this, this.environment)) { if (this.#instanceType === 'development') { throw new ClerkRuntimeError(warnings.cannotOpenSignInOrSignUp, { code: CANNOT_RENDER_SINGLE_SESSION_ENABLED_ERROR_CODE, @@ -688,7 +685,7 @@ export class Clerk implements ClerkInterface { public openSignUp = (props?: SignUpProps): void => { this.assertComponentsReady(this.#componentControls); - if (sessionExistsAndSingleSessionModeEnabled(this, this.environment)) { + if (isSignedInAndSingleSessionModeEnabled(this, this.environment)) { if (this.#instanceType === 'development') { throw new ClerkRuntimeError(warnings.cannotOpenSignInOrSignUp, { code: CANNOT_RENDER_SINGLE_SESSION_ENABLED_ERROR_CODE, @@ -1201,8 +1198,11 @@ export class Clerk implements ClerkInterface { /** * `setActive` can be used to set the active session and/or organization. */ - public setActive = async ({ session, organization, beforeEmit, redirectUrl }: SetActiveParams): Promise => { + public setActive = async (params: SetActiveParams): Promise => { + const { organization, beforeEmit, redirectUrl, navigate: setActiveNavigate } = params; + let { session } = params; this.__internal_setActiveInProgress = true; + try { if (!this.client) { throw new Error('setActive is being called before the client is loaded. Wait for init.'); @@ -1214,6 +1214,10 @@ export class Clerk implements ClerkInterface { ); } + if (typeof session === 'string') { + session = (this.client.sessions.find(x => x.id === session) as SignedInSessionResource) || null; + } + const onBeforeSetActive: SetActiveHook = typeof window !== 'undefined' && typeof window.__unstable__onBeforeSetActive === 'function' ? window.__unstable__onBeforeSetActive @@ -1224,11 +1228,10 @@ export class Clerk implements ClerkInterface { ? window.__unstable__onAfterSetActive : noop; - if (typeof session === 'string') { - session = (this.client.sessions.find(x => x.id === session) as SignedInSessionResource) || null; - } - let newSession = session === undefined ? this.session : session; + if (newSession?.status === 'pending') { + warnMissingPendingTaskHandlers({ ...this.#options, ...params }); + } // At this point, the `session` variable should contain either an `SignedInSessionResource` // ,`null` or `undefined`. @@ -1259,16 +1262,14 @@ export class Clerk implements ClerkInterface { } } - if (newSession?.status === 'pending') { - await this.#handlePendingSession(newSession); - return; + // Do not revalidate server cache for pending sessions to avoid unmount of `SignIn/SignUp` AIOs when navigating to task + if (newSession?.status !== 'pending') { + /** + * Hint to each framework, that the user will be signed out when `{session: null}` is provided. + */ + await onBeforeSetActive(newSession === null ? 'sign-out' : undefined); } - /** - * Hint to each framework, that the user will be signed out when `{session: null}` is provided. - */ - await onBeforeSetActive(newSession === null ? 'sign-out' : undefined); - //1. setLastActiveSession to passed user session (add a param). // Note that this will also update the session's active organization // id. @@ -1301,17 +1302,37 @@ export class Clerk implements ClerkInterface { }); } - if (redirectUrl && !beforeEmit) { + const taskUrl = + newSession?.status === 'pending' && + newSession?.currentTask && + this.#options.taskUrls?.[newSession?.currentTask.key]; + + if (!beforeEmit && (redirectUrl || taskUrl || setActiveNavigate)) { await tracker.track(async () => { if (!this.client) { // Typescript is not happy because since thinks this.client might have changed to undefined because the function is asynchronous. return; } - this.#setTransitiveState(); - if (this.client.isEligibleForTouch()) { - const absoluteRedirectUrl = new URL(redirectUrl, window.location.href); - await this.navigate(this.buildUrlWithAuth(this.client.buildTouchUrl({ redirectUrl: absoluteRedirectUrl }))); - } else { + + if (newSession?.status !== 'pending') { + this.#setTransitiveState(); + } + + if (taskUrl) { + const taskUrlWithRedirect = redirectUrl + ? buildURL({ base: taskUrl, hashSearchParams: { redirectUrl } }, { stringify: true }) + : taskUrl; + await this.navigate(taskUrlWithRedirect); + } else if (setActiveNavigate && newSession) { + await setActiveNavigate({ session: newSession }); + } else if (redirectUrl) { + if (this.client.isEligibleForTouch()) { + const absoluteRedirectUrl = new URL(redirectUrl, window.location.href); + const redirectUrlWithAuth = this.buildUrlWithAuth( + this.client.buildTouchUrl({ redirectUrl: absoluteRedirectUrl }), + ); + await this.navigate(redirectUrlWithAuth); + } await this.navigate(redirectUrl); } }); @@ -1324,125 +1345,15 @@ export class Clerk implements ClerkInterface { this.#setAccessors(newSession); this.#emit(); - await onAfterSetActive(); - } finally { - this.__internal_setActiveInProgress = false; - } - }; - #handlePendingSession = async (session: PendingSessionResource) => { - /** - * Do not revalidate server cache when `setActive` is called with a pending - * session within components, to avoid flash of content and unmount during - * internal navigation - */ - const shouldInvalidateCache = !this.#componentNavigationContext; - - const onBeforeSetActive: SetActiveHook = - shouldInvalidateCache && - typeof window !== 'undefined' && - typeof window.__unstable__onBeforeSetActive === 'function' - ? window.__unstable__onBeforeSetActive - : noop; - - const onAfterSetActive: SetActiveHook = - shouldInvalidateCache && - typeof window !== 'undefined' && - typeof window.__unstable__onAfterSetActive === 'function' - ? window.__unstable__onAfterSetActive - : noop; - - await onBeforeSetActive(); - - if (!this.environment) { - return; - } - - let newSession: SignedInSessionResource | null = session; - - // Handles multi-session scenario when switching between `pending` sessions - // and satisfying task requirements such as organization selection - if (inActiveBrowserTab() || !this.#options.standardBrowser) { - await this.#touchCurrentSession(session); - newSession = 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.emit(events.TokenUpdate, { token: null }); - } - - // Only triggers navigation for internal AIO components routing or custom URLs - const shouldNavigateOnSetActive = this.#componentNavigationContext; - if (newSession?.currentTask && shouldNavigateOnSetActive) { - await navigateToTask(session.currentTask.key, { - options: this.#options, - environment: this.environment, - globalNavigate: this.navigate, - componentNavigationContext: this.#componentNavigationContext, - }); - } - - this.#setAccessors(session); - this.#emit(); - - await onAfterSetActive(); - }; - - public __internal_navigateToTaskIfAvailable = async ({ - redirectUrlComplete, - }: __internal_NavigateToTaskIfAvailableParams = {}): Promise => { - const onBeforeSetActive: SetActiveHook = - typeof window !== 'undefined' && typeof window.__unstable__onBeforeSetActive === 'function' - ? window.__unstable__onBeforeSetActive - : noop; - - const onAfterSetActive: SetActiveHook = - typeof window !== 'undefined' && typeof window.__unstable__onAfterSetActive === 'function' - ? window.__unstable__onAfterSetActive - : noop; - - const session = this.session; - if (!session || !this.environment) { - return; - } - - if (session.status === 'pending') { - await navigateToTask(session.currentTask.key, { - options: this.#options, - environment: this.environment, - globalNavigate: this.navigate, - componentNavigationContext: this.#componentNavigationContext, - }); - return; - } - - await onBeforeSetActive(); - - if (redirectUrlComplete) { - const tracker = createBeforeUnloadTracker(this.#options.standardBrowser); - - await tracker.track(async () => { - if (!this.client) { - return; - } - - if (this.client.isEligibleForTouch()) { - const absoluteRedirectUrl = new URL(redirectUrlComplete, window.location.href); - await this.navigate(this.buildUrlWithAuth(this.client.buildTouchUrl({ redirectUrl: absoluteRedirectUrl }))); - } else { - await this.navigate(redirectUrlComplete); - } - }); - - if (tracker.isUnloading()) { - return; + // Do not revalidate server cache for pending sessions to avoid unmount of `SignIn/SignUp` AIOs when navigating to task + // newSession can be mutated by the time we get here (org change session touch) + if (newSession?.status !== 'pending') { + await onAfterSetActive(); } + } finally { + this.__internal_setActiveInProgress = false; } - - await onAfterSetActive(); }; public addListener = (listener: ListenerCallback): UnsubscribeCallback => { @@ -1479,12 +1390,6 @@ export class Clerk implements ClerkInterface { return unsubscribe; }; - public __internal_setComponentNavigationContext = (context: __internal_ComponentNavigationContext) => { - this.#componentNavigationContext = context; - - return () => (this.#componentNavigationContext = null); - }; - public navigate = async (to: string | undefined, options?: NavigateOptions): Promise => { if (!to || !inBrowser()) { return; @@ -1646,6 +1551,28 @@ export class Clerk implements ClerkInterface { return this.buildUrlWithAuth(this.environment.displayConfig.organizationProfileUrl); } + public buildTasksUrl(): string { + const currentTask = this.session?.currentTask; + if (!currentTask) { + return ''; + } + + const customTaskUrl = this.#options.taskUrls?.[currentTask.key]; + if (customTaskUrl) { + return customTaskUrl; + } + + return buildURL( + { + base: this.buildSignInUrl(), + hashPath: getTaskEndpoint(currentTask), + }, + { + stringify: true, + }, + ); + } + #redirectToSatellite = async (): Promise => { if (!inBrowser()) { return; @@ -1736,6 +1663,13 @@ export class Clerk implements ClerkInterface { return; }; + public redirectToTasks = async (): Promise => { + if (inBrowser()) { + return this.navigate(this.buildTasksUrl()); + } + return; + }; + public handleEmailLinkVerification = async ( params: HandleEmailLinkVerificationParams, customNavigate?: (to: string) => Promise, @@ -1914,12 +1848,36 @@ export class Clerk implements ClerkInterface { }); }; + const signInUrl = params.signInUrl || displayConfig.signInUrl; + const signUpUrl = params.signUpUrl || displayConfig.signUpUrl; + + const setActiveNavigate = async ({ + session, + baseUrl, + redirectUrl, + }: { + session: SessionResource; + baseUrl: string; + redirectUrl: string; + }) => { + if (!session.currentTask) { + await this.navigate(redirectUrl); + return; + } + + await navigateIfTaskExists(session, { + baseUrl, + navigate: this.navigate, + }); + }; + if (si.status === 'complete') { - await this.setActive({ + return this.setActive({ session: si.sessionId, - redirectUrl: redirectUrls.getAfterSignInUrl(), + navigate: async ({ session }) => { + await setActiveNavigate({ session, baseUrl: signInUrl, redirectUrl: redirectUrls.getAfterSignInUrl() }); + }, }); - return this.__internal_navigateToTaskIfAvailable(); } const userExistsButNeedsToSignIn = @@ -1929,11 +1887,12 @@ export class Clerk implements ClerkInterface { const res = await signIn.create({ transfer: true }); switch (res.status) { case 'complete': - await this.setActive({ + return this.setActive({ session: res.createdSessionId, - redirectUrl: redirectUrls.getAfterSignInUrl(), + navigate: async ({ session }) => { + await setActiveNavigate({ session, baseUrl: signUpUrl, redirectUrl: redirectUrls.getAfterSignInUrl() }); + }, }); - return this.__internal_navigateToTaskIfAvailable(); case 'needs_first_factor': return navigateToFactorOne(); case 'needs_second_factor': @@ -1979,11 +1938,12 @@ export class Clerk implements ClerkInterface { const res = await signUp.create({ transfer: true }); switch (res.status) { case 'complete': - await this.setActive({ + return this.setActive({ session: res.createdSessionId, - redirectUrl: redirectUrls.getAfterSignUpUrl(), + navigate: async ({ session }) => { + await setActiveNavigate({ session, baseUrl: signUpUrl, redirectUrl: redirectUrls.getAfterSignUpUrl() }); + }, }); - return this.__internal_navigateToTaskIfAvailable(); case 'missing_requirements': return navigateToNextStepSignUp({ missingFields: res.missingFields }); default: @@ -1992,11 +1952,12 @@ export class Clerk implements ClerkInterface { } if (su.status === 'complete') { - await this.setActive({ + return this.setActive({ session: su.sessionId, - redirectUrl: redirectUrls.getAfterSignUpUrl(), + navigate: async ({ session }) => { + await setActiveNavigate({ session, baseUrl: signUpUrl, redirectUrl: redirectUrls.getAfterSignUpUrl() }); + }, }); - return this.__internal_navigateToTaskIfAvailable(); } if (si.status === 'needs_second_factor') { @@ -2019,7 +1980,13 @@ export class Clerk implements ClerkInterface { if (sessionId) { return this.setActive({ session: sessionId, - redirectUrl: redirectUrls.getAfterSignInUrl(), + navigate: async ({ session }) => { + await setActiveNavigate({ + session, + baseUrl: suUserAlreadySignedIn ? signUpUrl : signInUrl, + redirectUrl: redirectUrls.getAfterSignInUrl(), + }); + }, }); } } @@ -2032,8 +1999,9 @@ export class Clerk implements ClerkInterface { return navigateToNextStepSignUp({ missingFields: signUp.missingFields }); } - if (this.__internal_hasAfterAuthFlows) { - return this.__internal_navigateToTaskIfAvailable({ redirectUrlComplete: redirectUrls.getAfterSignInUrl() }); + if (this.session?.currentTask) { + await this.redirectToTasks(); + return; } return navigateToSignIn(); @@ -2204,6 +2172,18 @@ export class Clerk implements ClerkInterface { } } + const setActiveNavigate = async ({ session, redirectUrl }: { session: SessionResource; redirectUrl: string }) => { + if (!session.currentTask) { + await this.navigate(redirectUrl); + return; + } + + await navigateIfTaskExists(session, { + baseUrl: displayConfig.signInUrl, + navigate: this.navigate, + }); + }; + switch (signInOrSignUp.status) { case 'needs_second_factor': await navigateToFactorTwo(); @@ -2212,7 +2192,9 @@ export class Clerk implements ClerkInterface { if (signInOrSignUp.createdSessionId) { await this.setActive({ session: signInOrSignUp.createdSessionId, - redirectUrl, + navigate: async ({ session }) => { + await setActiveNavigate({ session, redirectUrl: redirectUrl ?? this.buildAfterSignInUrl() }); + }, }); } break; diff --git a/packages/clerk-js/src/core/resources/SignIn.ts b/packages/clerk-js/src/core/resources/SignIn.ts index 3c7623a66c5..9cc58a4dafe 100644 --- a/packages/clerk-js/src/core/resources/SignIn.ts +++ b/packages/clerk-js/src/core/resources/SignIn.ts @@ -43,7 +43,6 @@ import type { } from '@clerk/types'; import { - buildURL, generateSignatureWithCoinbaseWallet, generateSignatureWithMetamask, generateSignatureWithOKXWallet, @@ -250,25 +249,16 @@ export class SignIn extends BaseResource implements SignInResource { params: AuthenticateWithRedirectParams, navigateCallback: (url: URL | string) => void, ): Promise => { - const { strategy, redirectUrl, redirectUrlComplete, identifier, oidcPrompt, continueSignIn } = params || {}; + const { strategy, redirectUrlComplete, identifier, oidcPrompt, continueSignIn } = params || {}; + const actionCompleteRedirectUrl = redirectUrlComplete; - const redirectUrlWithAuthToken = SignIn.clerk.buildUrlWithAuth(redirectUrl); - - // When after-auth is enabled, redirect to SSO callback route. - // This ensures organization selection tasks are displayed after sign-in, - // rather than redirecting to potentially unprotected pages while the session is pending. - const actionCompleteRedirectUrl = SignIn.clerk.__internal_hasAfterAuthFlows - ? buildURL({ - base: redirectUrlWithAuthToken, - search: `?redirect_url=${redirectUrlComplete}`, - }).toString() - : redirectUrlComplete; + const redirectUrl = SignIn.clerk.buildUrlWithAuth(params.redirectUrl); if (!this.id || !continueSignIn) { await this.create({ strategy, identifier, - redirectUrl: redirectUrlWithAuthToken, + redirectUrl, actionCompleteRedirectUrl, }); } @@ -276,7 +266,7 @@ export class SignIn extends BaseResource implements SignInResource { if (strategy === 'saml' || strategy === 'enterprise_sso') { await this.prepareFirstFactor({ strategy, - redirectUrl: SignIn.clerk.buildUrlWithAuth(redirectUrl), + redirectUrl, actionCompleteRedirectUrl, oidcPrompt, }); diff --git a/packages/clerk-js/src/core/resources/SignUp.ts b/packages/clerk-js/src/core/resources/SignUp.ts index ed16d882875..079b6c34d67 100644 --- a/packages/clerk-js/src/core/resources/SignUp.ts +++ b/packages/clerk-js/src/core/resources/SignUp.ts @@ -290,18 +290,11 @@ export class SignUp extends BaseResource implements SignUpResource { const redirectUrlWithAuthToken = SignUp.clerk.buildUrlWithAuth(redirectUrl); - // When force after-auth is enabled, redirect to SSO callback route. - // This ensures organization selection tasks are displayed after sign-up, - // rather than redirecting to potentially unprotected pages while the session is pending. - const actionCompleteRedirectUrl = SignUp.clerk.__internal_hasAfterAuthFlows - ? redirectUrlWithAuthToken - : redirectUrlComplete; - const authenticateFn = () => { const authParams = { strategy, redirectUrl: redirectUrlWithAuthToken, - actionCompleteRedirectUrl, + actionCompleteRedirectUrl: redirectUrlComplete, unsafeMetadata, emailAddress, legalAccepted, diff --git a/packages/clerk-js/src/core/sessionTasks.ts b/packages/clerk-js/src/core/sessionTasks.ts index e785617063a..0ecfdce3fd7 100644 --- a/packages/clerk-js/src/core/sessionTasks.ts +++ b/packages/clerk-js/src/core/sessionTasks.ts @@ -1,51 +1,69 @@ -import type { - __internal_ComponentNavigationContext, - ClerkOptions, - EnvironmentResource, - SessionTask, -} from '@clerk/types'; +import { logger } from '@clerk/shared/logger'; +import type { ClerkOptions, SessionResource, SessionTask, SetActiveParams } from '@clerk/types'; -import { buildURL } from '../utils'; +import { buildURL, forwardClerkQueryParams } from '../utils'; +/** + * @internal + */ export const INTERNAL_SESSION_TASK_ROUTE_BY_KEY: Record = { 'choose-organization': 'choose-organization', } as const; -interface NavigateToTaskOptions { - componentNavigationContext: __internal_ComponentNavigationContext | null; - globalNavigate: (to: string) => Promise; - options: ClerkOptions; - environment: EnvironmentResource; +/** + * @internal + */ +export const getTaskEndpoint = (task: SessionTask) => `/tasks/${INTERNAL_SESSION_TASK_ROUTE_BY_KEY[task.key]}`; + +/** + * @internal + */ +export function buildTaskUrl(task: SessionTask, opts: Pick[0], 'base'>) { + const params = forwardClerkQueryParams(); + + return buildURL( + { + base: opts.base, + hashPath: getTaskEndpoint(task), + searchParams: params, + }, + { stringify: true }, + ); } /** - * Handles navigation to the tasks URL based on the application context such - * as internal component routing or custom flows. * @internal */ -export function navigateToTask( - routeKey: keyof typeof INTERNAL_SESSION_TASK_ROUTE_BY_KEY, - { componentNavigationContext, globalNavigate, options, environment }: NavigateToTaskOptions, +export function navigateIfTaskExists( + session: SessionResource, + { + navigate, + baseUrl, + }: { + navigate: (to: string) => Promise; + baseUrl: string; + }, ) { - const customTaskUrl = options?.taskUrls?.[routeKey]; - const internalTaskRoute = `/tasks/${INTERNAL_SESSION_TASK_ROUTE_BY_KEY[routeKey]}`; + const currentTask = session.currentTask; + if (!currentTask) { + return; + } + + return navigate(buildTaskUrl(currentTask, { base: baseUrl })); +} + +export function warnMissingPendingTaskHandlers(options: Record) { + const taskOptions = ['taskUrls', 'navigate'] as Array< + keyof (Pick & Pick) + >; - if (componentNavigationContext && !customTaskUrl) { - return componentNavigationContext.navigate(componentNavigationContext.indexPath + internalTaskRoute); + const hasAtLeastOneOption = Object.keys(options).some(option => taskOptions.includes(option as any)); + if (hasAtLeastOneOption) { + return; } - const signInUrl = options['signInUrl'] || environment.displayConfig.signInUrl; - const signUpUrl = options['signUpUrl'] || environment.displayConfig.signUpUrl; - const isReferrerSignUpUrl = window.location.href.startsWith(signUpUrl); - - return globalNavigate( - customTaskUrl ?? - buildURL( - { - base: isReferrerSignUpUrl ? signUpUrl : signInUrl, - hashPath: internalTaskRoute, - }, - { stringify: true }, - ), + // TODO - Link to after-auth docs once it gets released + logger.warnOnce( + `Clerk: Session has pending tasks but no handling is configured. To handle pending tasks, provide either "taskUrls" for navigation to custom URLs or "navigate" for programmatic navigation. Without these options, users may get stuck on incomplete flows.`, ); } diff --git a/packages/clerk-js/src/ui/common/redirects.ts b/packages/clerk-js/src/ui/common/redirects.ts index 23e805815e0..aff7a444fce 100644 --- a/packages/clerk-js/src/ui/common/redirects.ts +++ b/packages/clerk-js/src/ui/common/redirects.ts @@ -1,6 +1,3 @@ -import type { ClerkOptions, SessionTask } from '@clerk/types'; - -import { INTERNAL_SESSION_TASK_ROUTE_BY_KEY } from '../../core/sessionTasks'; import { buildURL } from '../../utils/url'; import type { SignInContextType, SignUpContextType, UserProfileContextType } from './../contexts'; @@ -28,33 +25,6 @@ export function buildVerificationRedirectUrl({ }); } -export function buildSessionTaskRedirectUrl({ - routing, - path, - baseUrl, - task, - taskUrls, -}: Pick & { - baseUrl: string; - task?: SessionTask; - taskUrls?: ClerkOptions['taskUrls']; -}) { - if (!task) { - return null; - } - - return ( - taskUrls?.[task.key] ?? - buildRedirectUrl({ - routing, - baseUrl, - path, - endpoint: `/tasks/${INTERNAL_SESSION_TASK_ROUTE_BY_KEY[task.key]}`, - authQueryString: null, - }) - ); -} - export function buildSSOCallbackURL( ctx: Partial, baseUrl: string | undefined = '', diff --git a/packages/clerk-js/src/ui/common/withRedirect.tsx b/packages/clerk-js/src/ui/common/withRedirect.tsx index 81336a63ec2..fc98311d958 100644 --- a/packages/clerk-js/src/ui/common/withRedirect.tsx +++ b/packages/clerk-js/src/ui/common/withRedirect.tsx @@ -6,7 +6,7 @@ import React from 'react'; import { warnings } from '../../core/warnings'; import type { ComponentGuard } from '../../utils'; -import { sessionExistsAndSingleSessionModeEnabled } from '../../utils'; +import { isSignedInAndSingleSessionModeEnabled } from '../../utils'; import { useEnvironment, useOptions, useSignInContext, useSignUpContext } from '../contexts'; import { useRouter } from '../router'; import type { AvailableComponentProps } from '../types'; @@ -60,11 +60,9 @@ export const withRedirectToAfterSignIn =

(Com const signInCtx = useSignInContext(); return withRedirect( Component, - sessionExistsAndSingleSessionModeEnabled, - ({ clerk }) => signInCtx.sessionTaskUrl || signInCtx.afterSignInUrl || clerk.buildAfterSignInUrl(), - signInCtx.sessionTaskUrl - ? warnings.cannotRenderSignInComponentWhenTaskExists - : warnings.cannotRenderSignInComponentWhenSessionExists, + isSignedInAndSingleSessionModeEnabled, + ({ clerk }) => signInCtx.afterSignInUrl || clerk.buildAfterSignInUrl(), + warnings.cannotRenderSignInComponentWhenSessionExists, )(props); }; @@ -81,11 +79,9 @@ export const withRedirectToAfterSignUp =

(Com const signUpCtx = useSignUpContext(); return withRedirect( Component, - sessionExistsAndSingleSessionModeEnabled, - ({ clerk }) => signUpCtx.sessionTaskUrl || signUpCtx.afterSignUpUrl || clerk.buildAfterSignUpUrl(), - signUpCtx.sessionTaskUrl - ? warnings.cannotRenderSignUpComponentWhenTaskExists - : warnings.cannotRenderSignUpComponentWhenSessionExists, + isSignedInAndSingleSessionModeEnabled, + ({ clerk }) => signUpCtx.afterSignUpUrl || clerk.buildAfterSignUpUrl(), + warnings.cannotRenderSignUpComponentWhenSessionExists, )(props); }; @@ -93,3 +89,47 @@ export const withRedirectToAfterSignUp =

(Com return HOC; }; + +export const withRedirectToSignInTask =

(Component: ComponentType

) => { + const displayName = Component.displayName || Component.name || 'Component'; + Component.displayName = displayName; + + const HOC = (props: P) => { + const signInCtx = useSignInContext(); + + return withRedirect( + Component, + (clerk, environment) => + !!environment?.authConfig.singleSessionMode && !!(clerk.session?.currentTask && signInCtx?.taskUrl), + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + () => signInCtx.taskUrl!, + undefined, + )(props); + }; + + HOC.displayName = `withRedirectToSignInTask(${displayName})`; + + return HOC; +}; + +export const withRedirectToSignUpTask =

(Component: ComponentType

) => { + const displayName = Component.displayName || Component.name || 'Component'; + Component.displayName = displayName; + + const HOC = (props: P) => { + const signUpCtx = useSignUpContext(); + + return withRedirect( + Component, + (clerk, environment) => + !!environment?.authConfig.singleSessionMode && !!(clerk.session?.currentTask && signUpCtx?.taskUrl), + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + () => signUpCtx.taskUrl!, + undefined, + )(props); + }; + + HOC.displayName = `withRedirectToSignUpTask(${displayName})`; + + return HOC; +}; diff --git a/packages/clerk-js/src/ui/components/SessionTasks/index.tsx b/packages/clerk-js/src/ui/components/SessionTasks/index.tsx index 25a19c16bfc..3baba7afd2e 100644 --- a/packages/clerk-js/src/ui/components/SessionTasks/index.tsx +++ b/packages/clerk-js/src/ui/components/SessionTasks/index.tsx @@ -1,5 +1,6 @@ import { useClerk } from '@clerk/shared/react'; import { eventComponentMounted } from '@clerk/shared/telemetry'; +import type { SessionResource } from '@clerk/types'; import { useContext, useEffect, useRef } from 'react'; import { Card } from '@/ui/elements/Card'; @@ -24,7 +25,10 @@ const SessionTasksStart = () => { useEffect(() => { // Simulates additional latency to avoid a abrupt UI transition when navigating to the next task const timeoutId = setTimeout(() => { - void clerk.__internal_navigateToTaskIfAvailable({ redirectUrlComplete }); + const currentTaskKey = clerk.session?.currentTask?.key; + if (!currentTaskKey) return; + + void navigate(`./${INTERNAL_SESSION_TASK_ROUTE_BY_KEY[currentTaskKey]}`); }, 500); return () => clearTimeout(timeoutId); }, [navigate, clerk, redirectUrlComplete]); @@ -39,7 +43,7 @@ const SessionTasksStart = () => { ); }; -function SessionTaskRoutes(): JSX.Element { +function SessionTasksRoutes(): JSX.Element { const ctx = useSessionTasksContext(); return ( @@ -61,7 +65,7 @@ function SessionTaskRoutes(): JSX.Element { /** * @internal */ -export const SessionTask = withCardStateProvider(() => { +export const SessionTasks = withCardStateProvider(() => { const clerk = useClerk(); const { navigate } = useRouter(); const signInContext = useContext(SignInContext); @@ -102,9 +106,18 @@ export const SessionTask = withCardStateProvider(() => { ); } + const navigateOnSetActive = async ({ session }: { session: SessionResource }) => { + const currentTask = session.currentTask; + if (!currentTask) { + return navigate(redirectUrlComplete); + } + + return navigate(`./${INTERNAL_SESSION_TASK_ROUTE_BY_KEY[currentTask.key]}`); + }; + return ( - - + + ); }); diff --git a/packages/clerk-js/src/ui/components/SessionTasks/tasks/TaskChooseOrganization/ChooseOrganizationScreen.tsx b/packages/clerk-js/src/ui/components/SessionTasks/tasks/TaskChooseOrganization/ChooseOrganizationScreen.tsx index 0893c68fecc..841e2677dee 100644 --- a/packages/clerk-js/src/ui/components/SessionTasks/tasks/TaskChooseOrganization/ChooseOrganizationScreen.tsx +++ b/packages/clerk-js/src/ui/components/SessionTasks/tasks/TaskChooseOrganization/ChooseOrganizationScreen.tsx @@ -15,7 +15,7 @@ import { sharedMainIdentifierSx, } from '@/ui/common/organizations/OrganizationPreview'; import { organizationListParams, populateCacheUpdateItem } from '@/ui/components/OrganizationSwitcher/utils'; -import { useSessionTasksContext } from '@/ui/contexts/components/SessionTasks'; +import { useTaskChooseOrganizationContext } from '@/ui/contexts/components/SessionTasks'; import { Col, descriptors, localizationKeys, Text } from '@/ui/customizables'; import { Action, Actions } from '@/ui/elements/Actions'; import { useCardState, withCardStateProvider } from '@/ui/elements/contexts'; @@ -97,8 +97,7 @@ export const ChooseOrganizationScreen = withCardStateProvider( const MembershipPreview = withCardStateProvider((props: { organization: OrganizationResource }) => { const card = useCardState(); - const { redirectUrlComplete } = useSessionTasksContext(); - const { __internal_navigateToTaskIfAvailable } = useClerk(); + const { redirectUrlComplete } = useTaskChooseOrganizationContext(); const { isLoaded, setActive } = useOrganizationList(); if (!isLoaded) { @@ -109,9 +108,8 @@ const MembershipPreview = withCardStateProvider((props: { organization: Organiza return card.runAsync(async () => { await setActive({ organization, + redirectUrl: redirectUrlComplete, }); - - await __internal_navigateToTaskIfAvailable({ redirectUrlComplete }); }); }; diff --git a/packages/clerk-js/src/ui/components/SessionTasks/tasks/TaskChooseOrganization/CreateOrganizationScreen.tsx b/packages/clerk-js/src/ui/components/SessionTasks/tasks/TaskChooseOrganization/CreateOrganizationScreen.tsx index 7e52289446d..77b908b593a 100644 --- a/packages/clerk-js/src/ui/components/SessionTasks/tasks/TaskChooseOrganization/CreateOrganizationScreen.tsx +++ b/packages/clerk-js/src/ui/components/SessionTasks/tasks/TaskChooseOrganization/CreateOrganizationScreen.tsx @@ -1,6 +1,6 @@ -import { useClerk, useOrganizationList } from '@clerk/shared/react'; +import { useOrganizationList } from '@clerk/shared/react'; -import { useSessionTasksContext } from '@/ui/contexts/components/SessionTasks'; +import { useTaskChooseOrganizationContext } from '@/ui/contexts/components/SessionTasks'; import { localizationKeys } from '@/ui/customizables'; import { useCardState, withCardStateProvider } from '@/ui/elements/contexts'; import { Form } from '@/ui/elements/Form'; @@ -19,8 +19,7 @@ type CreateOrganizationScreenProps = { export const CreateOrganizationScreen = withCardStateProvider((props: CreateOrganizationScreenProps) => { const card = useCardState(); - const { __internal_navigateToTaskIfAvailable } = useClerk(); - const { redirectUrlComplete } = useSessionTasksContext(); + const { redirectUrlComplete } = useTaskChooseOrganizationContext(); const { createOrganization, isLoaded, setActive } = useOrganizationList({ userMemberships: organizationListParams.userMemberships, }); @@ -46,9 +45,10 @@ export const CreateOrganizationScreen = withCardStateProvider((props: CreateOrga try { const organization = await createOrganization({ name: nameField.value, slug: slugField.value }); - await setActive({ organization }); - - await __internal_navigateToTaskIfAvailable({ redirectUrlComplete }); + await setActive({ + organization, + redirectUrl: redirectUrlComplete, + }); } catch (err) { handleError(err, [nameField, slugField], card.setError); } diff --git a/packages/clerk-js/src/ui/components/SessionTasks/tasks/withTaskGuard.ts b/packages/clerk-js/src/ui/components/SessionTasks/tasks/withTaskGuard.ts index 2fb26a8c3b5..d5702f19760 100644 --- a/packages/clerk-js/src/ui/components/SessionTasks/tasks/withTaskGuard.ts +++ b/packages/clerk-js/src/ui/components/SessionTasks/tasks/withTaskGuard.ts @@ -2,7 +2,7 @@ import type { ComponentType } from 'react'; import { warnings } from '@/core/warnings'; import { withRedirect } from '@/ui/common'; -import { useSessionTasksContext } from '@/ui/contexts/components/SessionTasks'; +import { useTaskChooseOrganizationContext } from '@/ui/contexts/components/SessionTasks'; import type { AvailableComponentProps } from '@/ui/types'; export const withTaskGuard =

(Component: ComponentType

) => { @@ -10,7 +10,7 @@ export const withTaskGuard =

(Component: Comp Component.displayName = displayName; const HOC = (props: P) => { - const ctx = useSessionTasksContext(); + const ctx = useTaskChooseOrganizationContext(); return withRedirect( Component, clerk => !clerk.session?.currentTask, diff --git a/packages/clerk-js/src/ui/components/SignIn/SignInAccountSwitcher.tsx b/packages/clerk-js/src/ui/components/SignIn/SignInAccountSwitcher.tsx index ec8d84c5978..369f5a6a382 100644 --- a/packages/clerk-js/src/ui/components/SignIn/SignInAccountSwitcher.tsx +++ b/packages/clerk-js/src/ui/components/SignIn/SignInAccountSwitcher.tsx @@ -5,7 +5,7 @@ import { Header } from '@/ui/elements/Header'; import { PreviewButton } from '@/ui/elements/PreviewButton'; import { UserPreview } from '@/ui/elements/UserPreview'; -import { withRedirectToAfterSignIn } from '../../common'; +import { withRedirectToAfterSignIn, withRedirectToSignInTask } from '../../common'; import { useEnvironment, useSignInContext, useSignOutContext } from '../../contexts'; import { Col, descriptors, Flow, localizationKeys } from '../../customizables'; import { Add, SwitchArrowRight } from '../../icons'; @@ -125,4 +125,6 @@ const SignInAccountSwitcherInternal = () => { ); }; -export const SignInAccountSwitcher = withRedirectToAfterSignIn(withCardStateProvider(SignInAccountSwitcherInternal)); +export const SignInAccountSwitcher = withRedirectToSignInTask( + withRedirectToAfterSignIn(withCardStateProvider(SignInAccountSwitcherInternal)), +); diff --git a/packages/clerk-js/src/ui/components/SignIn/SignInFactorOne.tsx b/packages/clerk-js/src/ui/components/SignIn/SignInFactorOne.tsx index 716390610ec..3a0634b7477 100644 --- a/packages/clerk-js/src/ui/components/SignIn/SignInFactorOne.tsx +++ b/packages/clerk-js/src/ui/components/SignIn/SignInFactorOne.tsx @@ -6,7 +6,7 @@ import { useCardState, withCardStateProvider } from '@/ui/elements/contexts'; import { ErrorCard } from '@/ui/elements/ErrorCard'; import { LoadingCard } from '@/ui/elements/LoadingCard'; -import { withRedirectToAfterSignIn } from '../../common'; +import { withRedirectToAfterSignIn, withRedirectToSignInTask } from '../../common'; import { useCoreSignIn, useEnvironment } from '../../contexts'; import { useAlternativeStrategies } from '../../hooks/useAlternativeStrategies'; import { localizationKeys } from '../../localization'; @@ -251,4 +251,6 @@ function SignInFactorOneInternal(): JSX.Element { } } -export const SignInFactorOne = withRedirectToAfterSignIn(withCardStateProvider(SignInFactorOneInternal)); +export const SignInFactorOne = withRedirectToSignInTask( + withRedirectToAfterSignIn(withCardStateProvider(SignInFactorOneInternal)), +); diff --git a/packages/clerk-js/src/ui/components/SignIn/SignInFactorOneAlternativeChannelCodeForm.tsx b/packages/clerk-js/src/ui/components/SignIn/SignInFactorOneAlternativeChannelCodeForm.tsx index 35c2bf80871..58ebe4f71c4 100644 --- a/packages/clerk-js/src/ui/components/SignIn/SignInFactorOneAlternativeChannelCodeForm.tsx +++ b/packages/clerk-js/src/ui/components/SignIn/SignInFactorOneAlternativeChannelCodeForm.tsx @@ -34,7 +34,7 @@ export const SignInFactorOneAlternativeChannelCodeForm = (props: SignInFactorOne const signIn = useCoreSignIn(); const card = useCardState(); const { navigate } = useRouter(); - const { afterSignInUrl } = useSignInContext(); + const { afterSignInUrl, navigateOnSetActive } = useSignInContext(); const { setActive } = useClerk(); const supportEmail = useSupportEmail(); const clerk = useClerk(); @@ -65,7 +65,12 @@ export const SignInFactorOneAlternativeChannelCodeForm = (props: SignInFactorOne switch (res.status) { case 'complete': - return setActive({ session: res.createdSessionId, redirectUrl: afterSignInUrl }); + return setActive({ + session: res.createdSessionId, + navigate: async ({ session }) => { + await navigateOnSetActive({ session, redirectUrl: afterSignInUrl }); + }, + }); case 'needs_second_factor': return navigate('../factor-two'); case 'needs_new_password': diff --git a/packages/clerk-js/src/ui/components/SignIn/SignInFactorOneCodeForm.tsx b/packages/clerk-js/src/ui/components/SignIn/SignInFactorOneCodeForm.tsx index e2015e5d784..0a0a43162c5 100644 --- a/packages/clerk-js/src/ui/components/SignIn/SignInFactorOneCodeForm.tsx +++ b/packages/clerk-js/src/ui/components/SignIn/SignInFactorOneCodeForm.tsx @@ -35,7 +35,7 @@ export const SignInFactorOneCodeForm = (props: SignInFactorOneCodeFormProps) => const signIn = useCoreSignIn(); const card = useCardState(); const { navigate } = useRouter(); - const { afterSignInUrl } = useSignInContext(); + const { afterSignInUrl, navigateOnSetActive } = useSignInContext(); const { setActive } = useClerk(); const supportEmail = useSupportEmail(); const clerk = useClerk(); @@ -104,7 +104,12 @@ export const SignInFactorOneCodeForm = (props: SignInFactorOneCodeFormProps) => switch (res.status) { case 'complete': - return setActive({ session: res.createdSessionId, redirectUrl: afterSignInUrl }); + return setActive({ + session: res.createdSessionId, + navigate: async ({ session }) => { + await navigateOnSetActive({ session, redirectUrl: afterSignInUrl }); + }, + }); case 'needs_second_factor': return navigate('../factor-two'); case 'needs_new_password': diff --git a/packages/clerk-js/src/ui/components/SignIn/SignInFactorOnePasswordCard.tsx b/packages/clerk-js/src/ui/components/SignIn/SignInFactorOnePasswordCard.tsx index 0faac0ed1a4..f7e05371cee 100644 --- a/packages/clerk-js/src/ui/components/SignIn/SignInFactorOnePasswordCard.tsx +++ b/packages/clerk-js/src/ui/components/SignIn/SignInFactorOnePasswordCard.tsx @@ -55,7 +55,7 @@ export const SignInFactorOnePasswordCard = (props: SignInFactorOnePasswordProps) const card = useCardState(); const { setActive } = useClerk(); const signIn = useCoreSignIn(); - const { afterSignInUrl } = useSignInContext(); + const { afterSignInUrl, navigateOnSetActive } = useSignInContext(); const supportEmail = useSupportEmail(); const passwordControl = usePasswordControl(props); const { navigate } = useRouter(); @@ -74,7 +74,12 @@ export const SignInFactorOnePasswordCard = (props: SignInFactorOnePasswordProps) .then(res => { switch (res.status) { case 'complete': - return setActive({ session: res.createdSessionId, redirectUrl: afterSignInUrl }); + return setActive({ + session: res.createdSessionId, + navigate: async ({ session }) => { + await navigateOnSetActive({ session, redirectUrl: afterSignInUrl }); + }, + }); case 'needs_second_factor': return navigate('../factor-two'); default: diff --git a/packages/clerk-js/src/ui/components/SignIn/SignInFactorTwo.tsx b/packages/clerk-js/src/ui/components/SignIn/SignInFactorTwo.tsx index 51c04605558..522e687dcb6 100644 --- a/packages/clerk-js/src/ui/components/SignIn/SignInFactorTwo.tsx +++ b/packages/clerk-js/src/ui/components/SignIn/SignInFactorTwo.tsx @@ -4,7 +4,7 @@ import React from 'react'; import { withCardStateProvider } from '@/ui/elements/contexts'; import { LoadingCard } from '@/ui/elements/LoadingCard'; -import { withRedirectToAfterSignIn } from '../../common'; +import { withRedirectToAfterSignIn, withRedirectToSignInTask } from '../../common'; import { useCoreSignIn } from '../../contexts'; import { SignInFactorTwoAlternativeMethods } from './SignInFactorTwoAlternativeMethods'; import { SignInFactorTwoBackupCodeCard } from './SignInFactorTwoBackupCodeCard'; @@ -82,4 +82,6 @@ function SignInFactorTwoInternal(): JSX.Element { } } -export const SignInFactorTwo = withRedirectToAfterSignIn(withCardStateProvider(SignInFactorTwoInternal)); +export const SignInFactorTwo = withRedirectToSignInTask( + withRedirectToAfterSignIn(withCardStateProvider(SignInFactorTwoInternal)), +); diff --git a/packages/clerk-js/src/ui/components/SignIn/SignInFactorTwoBackupCodeCard.tsx b/packages/clerk-js/src/ui/components/SignIn/SignInFactorTwoBackupCodeCard.tsx index 82e465dc7a4..e198bf90d0b 100644 --- a/packages/clerk-js/src/ui/components/SignIn/SignInFactorTwoBackupCodeCard.tsx +++ b/packages/clerk-js/src/ui/components/SignIn/SignInFactorTwoBackupCodeCard.tsx @@ -24,7 +24,7 @@ type SignInFactorTwoBackupCodeCardProps = { export const SignInFactorTwoBackupCodeCard = (props: SignInFactorTwoBackupCodeCardProps) => { const { onShowAlternativeMethodsClicked } = props; const signIn = useCoreSignIn(); - const { afterSignInUrl } = useSignInContext(); + const { afterSignInUrl, navigateOnSetActive } = useSignInContext(); const { setActive } = useClerk(); const { navigate } = useRouter(); const supportEmail = useSupportEmail(); @@ -52,7 +52,12 @@ export const SignInFactorTwoBackupCodeCard = (props: SignInFactorTwoBackupCodeCa queryParams.set('createdSessionId', res.createdSessionId); return navigate(`../reset-password-success?${queryParams.toString()}`); } - return setActive({ session: res.createdSessionId, redirectUrl: afterSignInUrl }); + return setActive({ + session: res.createdSessionId, + navigate: async ({ session }) => { + await navigateOnSetActive({ session, redirectUrl: afterSignInUrl }); + }, + }); default: return console.error(clerkInvalidFAPIResponse(res.status, supportEmail)); } diff --git a/packages/clerk-js/src/ui/components/SignIn/SignInFactorTwoCodeForm.tsx b/packages/clerk-js/src/ui/components/SignIn/SignInFactorTwoCodeForm.tsx index d4b69d3b10c..4b06c83ed75 100644 --- a/packages/clerk-js/src/ui/components/SignIn/SignInFactorTwoCodeForm.tsx +++ b/packages/clerk-js/src/ui/components/SignIn/SignInFactorTwoCodeForm.tsx @@ -33,7 +33,7 @@ type SignInFactorTwoCodeFormProps = SignInFactorTwoCodeCard & { export const SignInFactorTwoCodeForm = (props: SignInFactorTwoCodeFormProps) => { const signIn = useCoreSignIn(); const card = useCardState(); - const { afterSignInUrl } = useSignInContext(); + const { afterSignInUrl, navigateOnSetActive } = useSignInContext(); const { setActive } = useClerk(); const { navigate } = useRouter(); const supportEmail = useSupportEmail(); @@ -79,7 +79,12 @@ export const SignInFactorTwoCodeForm = (props: SignInFactorTwoCodeFormProps) => queryParams.set('createdSessionId', res.createdSessionId); return navigate(`../reset-password-success?${queryParams.toString()}`); } - return setActive({ session: res.createdSessionId, redirectUrl: afterSignInUrl }); + return setActive({ + session: res.createdSessionId, + navigate: async ({ session }) => { + await navigateOnSetActive({ session, redirectUrl: afterSignInUrl }); + }, + }); default: return console.error(clerkInvalidFAPIResponse(res.status, supportEmail)); } diff --git a/packages/clerk-js/src/ui/components/SignIn/SignInSSOCallback.tsx b/packages/clerk-js/src/ui/components/SignIn/SignInSSOCallback.tsx index 07bfaed52d2..8e2d2de1961 100644 --- a/packages/clerk-js/src/ui/components/SignIn/SignInSSOCallback.tsx +++ b/packages/clerk-js/src/ui/components/SignIn/SignInSSOCallback.tsx @@ -1,3 +1,3 @@ -import { SSOCallback, withRedirectToAfterSignIn } from '../../common'; +import { SSOCallback, withRedirectToAfterSignIn, withRedirectToSignInTask } from '../../common'; -export const SignInSSOCallback = withRedirectToAfterSignIn(SSOCallback); +export const SignInSSOCallback = withRedirectToSignInTask(withRedirectToAfterSignIn(SSOCallback)); diff --git a/packages/clerk-js/src/ui/components/SignIn/SignInStart.tsx b/packages/clerk-js/src/ui/components/SignIn/SignInStart.tsx index b5f21ce1b7a..1748af215c6 100644 --- a/packages/clerk-js/src/ui/components/SignIn/SignInStart.tsx +++ b/packages/clerk-js/src/ui/components/SignIn/SignInStart.tsx @@ -30,6 +30,7 @@ import { getIdentifierControlDisplayValues, groupIdentifiers, withRedirectToAfterSignIn, + withRedirectToSignInTask, } from '../../common'; import { useCoreSignIn, useEnvironment, useSignInContext } from '../../contexts'; import { Col, descriptors, Flow, localizationKeys } from '../../customizables'; @@ -84,7 +85,7 @@ function SignInStartInternal(): JSX.Element { const signIn = useCoreSignIn(); const { navigate } = useRouter(); const ctx = useSignInContext(); - const { afterSignInUrl, signUpUrl, waitlistUrl, isCombinedFlow } = ctx; + const { afterSignInUrl, signUpUrl, waitlistUrl, isCombinedFlow, navigateOnSetActive } = ctx; const supportEmail = useSupportEmail(); const identifierAttributes = useMemo( () => groupIdentifiers(userSettings.enabledFirstFactorIdentifiers), @@ -235,7 +236,9 @@ function SignInStartInternal(): JSX.Element { removeClerkQueryParam('__clerk_ticket'); return clerk.setActive({ session: res.createdSessionId, - redirectUrl: afterSignInUrl, + navigate: async ({ session }) => { + await navigateOnSetActive({ session, redirectUrl: afterSignInUrl }); + }, }); default: { console.error(clerkInvalidFAPIResponse(res.status, supportEmail)); @@ -386,7 +389,9 @@ function SignInStartInternal(): JSX.Element { case 'complete': return clerk.setActive({ session: res.createdSessionId, - redirectUrl: afterSignInUrl, + navigate: async ({ session }) => { + await navigateOnSetActive({ session, redirectUrl: afterSignInUrl }); + }, }); default: { console.error(clerkInvalidFAPIResponse(res.status, supportEmail)); @@ -435,7 +440,12 @@ function SignInStartInternal(): JSX.Element { } else if (alreadySignedInError) { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const sid = alreadySignedInError.meta!.sessionId!; - await clerk.setActive({ session: sid, redirectUrl: afterSignInUrl }); + await clerk.setActive({ + session: sid, + navigate: async ({ session }) => { + await navigateOnSetActive({ session, redirectUrl: afterSignInUrl }); + }, + }); } else if (isCombinedFlow && accountDoesNotExistError) { const attribute = getSignUpAttributeFromIdentifier(identifierField); @@ -468,6 +478,7 @@ function SignInStartInternal(): JSX.Element { signUpMode: userSettings.signUp.mode, redirectUrl, redirectUrlComplete, + navigateOnSetActive, passwordEnabled: userSettings.attributes.password?.required ?? false, alternativePhoneCodeChannel: alternativePhoneCodeProvider?.channel || @@ -675,4 +686,6 @@ const InstantPasswordRow = ({ field }: { field?: FormControlState<'password'> }) ); }; -export const SignInStart = withRedirectToAfterSignIn(withCardStateProvider(SignInStartInternal)); +export const SignInStart = withRedirectToSignInTask( + withRedirectToAfterSignIn(withCardStateProvider(SignInStartInternal)), +); diff --git a/packages/clerk-js/src/ui/components/SignIn/__tests__/handleCombinedFlowTransfer.test.ts b/packages/clerk-js/src/ui/components/SignIn/__tests__/handleCombinedFlowTransfer.test.ts index dc7cfe6083d..817fb988ad0 100644 --- a/packages/clerk-js/src/ui/components/SignIn/__tests__/handleCombinedFlowTransfer.test.ts +++ b/packages/clerk-js/src/ui/components/SignIn/__tests__/handleCombinedFlowTransfer.test.ts @@ -40,6 +40,7 @@ describe('handleCombinedFlowTransfer', () => { clerk: mockClerk as unknown as LoadedClerk, afterSignUpUrl: 'https://test.com', passwordEnabled: false, + navigateOnSetActive: jest.fn(), }); expect(mockCompleteSignUpFlow).toHaveBeenCalled(); @@ -64,6 +65,7 @@ describe('handleCombinedFlowTransfer', () => { clerk: mockClerk as unknown as LoadedClerk, afterSignUpUrl: 'https://test.com', passwordEnabled: false, + navigateOnSetActive: jest.fn(), }); expect(mockNavigate).not.toHaveBeenCalled(); @@ -90,6 +92,7 @@ describe('handleCombinedFlowTransfer', () => { clerk: mockClerk as unknown as LoadedClerk, afterSignUpUrl: 'https://test.com', passwordEnabled: true, + navigateOnSetActive: jest.fn(), }); expect(mockNavigate).toHaveBeenCalled(); @@ -116,6 +119,7 @@ describe('handleCombinedFlowTransfer', () => { clerk: mockClerk as unknown as LoadedClerk, afterSignUpUrl: 'https://test.com', passwordEnabled: false, + navigateOnSetActive: jest.fn(), }); expect(mockNavigate).toHaveBeenCalled(); @@ -142,6 +146,7 @@ describe('handleCombinedFlowTransfer', () => { clerk: mockClerk as unknown as LoadedClerk, afterSignUpUrl: 'https://test.com', passwordEnabled: false, + navigateOnSetActive: jest.fn(), }); expect(mockNavigate).toHaveBeenCalled(); diff --git a/packages/clerk-js/src/ui/components/SignIn/handleCombinedFlowTransfer.ts b/packages/clerk-js/src/ui/components/SignIn/handleCombinedFlowTransfer.ts index cf4fad78daf..9021501868f 100644 --- a/packages/clerk-js/src/ui/components/SignIn/handleCombinedFlowTransfer.ts +++ b/packages/clerk-js/src/ui/components/SignIn/handleCombinedFlowTransfer.ts @@ -1,4 +1,11 @@ -import type { LoadedClerk, PhoneCodeChannel, PhoneCodeStrategy, SignUpModes, SignUpResource } from '@clerk/types'; +import type { + LoadedClerk, + PhoneCodeChannel, + PhoneCodeStrategy, + SessionResource, + SignUpModes, + SignUpResource, +} from '@clerk/types'; import { SIGN_UP_MODES } from '../../../core/constants'; import type { RouteContextValue } from '../../router/RouteContext'; @@ -17,6 +24,7 @@ type HandleCombinedFlowTransferProps = { redirectUrlComplete?: string; passwordEnabled: boolean; alternativePhoneCodeChannel?: PhoneCodeChannel | null; + navigateOnSetActive: (opts: { session: SessionResource; redirectUrl: string }) => Promise; }; /** @@ -35,6 +43,7 @@ export function handleCombinedFlowTransfer({ redirectUrl, redirectUrlComplete, passwordEnabled, + navigateOnSetActive, alternativePhoneCodeChannel, }: HandleCombinedFlowTransferProps): Promise | void { if (signUpMode === SIGN_UP_MODES.WAITLIST) { @@ -83,7 +92,13 @@ export function handleCombinedFlowTransfer({ signUp: res, verifyEmailPath: 'create/verify-email-address', verifyPhonePath: 'create/verify-phone-number', - handleComplete: () => clerk.setActive({ session: res.createdSessionId, redirectUrl: afterSignUpUrl }), + handleComplete: () => + clerk.setActive({ + session: res.createdSessionId, + navigate: async ({ session }) => { + await navigateOnSetActive({ session, redirectUrl: afterSignUpUrl }); + }, + }), navigate, redirectUrl, redirectUrlComplete, diff --git a/packages/clerk-js/src/ui/components/SignIn/index.tsx b/packages/clerk-js/src/ui/components/SignIn/index.tsx index ee894be5da9..cf451cf5ab3 100644 --- a/packages/clerk-js/src/ui/components/SignIn/index.tsx +++ b/packages/clerk-js/src/ui/components/SignIn/index.tsx @@ -14,7 +14,7 @@ import { Flow } from '@/ui/customizables'; import { useFetch } from '@/ui/hooks'; import { usePreloadTasks } from '@/ui/hooks/usePreloadTasks'; import { SessionTasks as LazySessionTasks } from '@/ui/lazyModules/components'; -import { Route, Switch, useRouter, VIRTUAL_ROUTER_BASE_PATH } from '@/ui/router'; +import { Route, Switch, VIRTUAL_ROUTER_BASE_PATH } from '@/ui/router'; import type { SignUpCtx } from '@/ui/types'; import { normalizeRoutingOptions } from '@/utils/normalizeRoutingOptions'; @@ -131,13 +131,13 @@ function SignInRoutes(): JSX.Element { > - - - + + + @@ -161,9 +161,6 @@ const usePreloadSignUp = (enabled = false) => useFetch(enabled ? preloadSignUp : undefined, 'preloadComponent', { staleTime: Infinity }); function SignInRoot() { - const { __internal_setComponentNavigationContext } = useClerk(); - const { navigate, indexPath } = useRouter(); - const signInContext = useSignInContext(); const normalizedSignUpContext = { componentName: 'SignUp', @@ -183,10 +180,6 @@ function SignInRoot() { usePreloadTasks(); - React.useEffect(() => { - return __internal_setComponentNavigationContext?.({ indexPath, navigate }); - }, [indexPath, navigate]); - return ( diff --git a/packages/clerk-js/src/ui/components/SignIn/shared.ts b/packages/clerk-js/src/ui/components/SignIn/shared.ts index 20560bd7ea4..f14c04c91b4 100644 --- a/packages/clerk-js/src/ui/components/SignIn/shared.ts +++ b/packages/clerk-js/src/ui/components/SignIn/shared.ts @@ -15,7 +15,7 @@ function useHandleAuthenticateWithPasskey(onSecondFactor: () => Promise // @ts-expect-error -- private method for the time being const { setActive, __internal_navigateWithError } = useClerk(); const supportEmail = useSupportEmail(); - const { afterSignInUrl } = useSignInContext(); + const { afterSignInUrl, navigateOnSetActive } = useSignInContext(); const { authenticateWithPasskey } = useCoreSignIn(); useEffect(() => { @@ -29,7 +29,12 @@ function useHandleAuthenticateWithPasskey(onSecondFactor: () => Promise const res = await authenticateWithPasskey(...args); switch (res.status) { case 'complete': - return setActive({ session: res.createdSessionId, redirectUrl: afterSignInUrl }); + return setActive({ + session: res.createdSessionId, + navigate: async ({ session }) => { + await navigateOnSetActive({ session, redirectUrl: afterSignInUrl }); + }, + }); case 'needs_second_factor': return onSecondFactor(); default: diff --git a/packages/clerk-js/src/ui/components/SignUp/SignUpContinue.tsx b/packages/clerk-js/src/ui/components/SignUp/SignUpContinue.tsx index 73d8196b2eb..9acbc23a2f2 100644 --- a/packages/clerk-js/src/ui/components/SignUp/SignUpContinue.tsx +++ b/packages/clerk-js/src/ui/components/SignUp/SignUpContinue.tsx @@ -38,6 +38,7 @@ function SignUpContinueInternal() { unsafeMetadata, initialValues = {}, isCombinedFlow: _isCombinedFlow, + navigateOnSetActive, } = useSignUpContext(); const signUp = useCoreSignUp(); const isWithinSignInContext = !!React.useContext(SignInContext); @@ -178,7 +179,13 @@ function SignUpContinueInternal() { signUp: res, verifyEmailPath: './verify-email-address', verifyPhonePath: './verify-phone-number', - handleComplete: () => clerk.setActive({ session: res.createdSessionId, redirectUrl: afterSignUpUrl }), + handleComplete: () => + clerk.setActive({ + session: res.createdSessionId, + navigate: async ({ session }) => { + await navigateOnSetActive({ session, redirectUrl: afterSignUpUrl }); + }, + }), navigate, oidcPrompt: ctx.oidcPrompt, }), diff --git a/packages/clerk-js/src/ui/components/SignUp/SignUpEmailLinkCard.tsx b/packages/clerk-js/src/ui/components/SignUp/SignUpEmailLinkCard.tsx index ce90d11ef59..db65dabf68b 100644 --- a/packages/clerk-js/src/ui/components/SignUp/SignUpEmailLinkCard.tsx +++ b/packages/clerk-js/src/ui/components/SignUp/SignUpEmailLinkCard.tsx @@ -17,7 +17,7 @@ export const SignUpEmailLinkCard = () => { const { t } = useLocalizations(); const signUp = useCoreSignUp(); const signUpContext = useSignUpContext(); - const { afterSignUpUrl } = signUpContext; + const { afterSignUpUrl, navigateOnSetActive } = signUpContext; const card = useCardState(); const { navigate } = useRouter(); const { setActive } = useClerk(); @@ -56,7 +56,13 @@ export const SignUpEmailLinkCard = () => { continuePath: '../continue', verifyEmailPath: '../verify-email-address', verifyPhonePath: '../verify-phone-number', - handleComplete: () => setActive({ session: su.createdSessionId, redirectUrl: afterSignUpUrl }), + handleComplete: () => + setActive({ + session: su.createdSessionId, + navigate: async ({ session }) => { + await navigateOnSetActive({ session, redirectUrl: afterSignUpUrl }); + }, + }), navigate, }); } diff --git a/packages/clerk-js/src/ui/components/SignUp/SignUpSSOCallback.tsx b/packages/clerk-js/src/ui/components/SignUp/SignUpSSOCallback.tsx index a72a27627fa..81d8f30b917 100644 --- a/packages/clerk-js/src/ui/components/SignUp/SignUpSSOCallback.tsx +++ b/packages/clerk-js/src/ui/components/SignUp/SignUpSSOCallback.tsx @@ -1,3 +1,3 @@ -import { SSOCallback, withRedirectToAfterSignUp } from '../../common'; +import { SSOCallback, withRedirectToAfterSignUp, withRedirectToSignUpTask } from '../../common'; -export const SignUpSSOCallback = withRedirectToAfterSignUp(SSOCallback); +export const SignUpSSOCallback = withRedirectToSignUpTask(withRedirectToAfterSignUp(SSOCallback)); diff --git a/packages/clerk-js/src/ui/components/SignUp/SignUpStart.tsx b/packages/clerk-js/src/ui/components/SignUp/SignUpStart.tsx index a71f2d8d007..a51353d97f1 100644 --- a/packages/clerk-js/src/ui/components/SignUp/SignUpStart.tsx +++ b/packages/clerk-js/src/ui/components/SignUp/SignUpStart.tsx @@ -16,7 +16,7 @@ import { createUsernameError } from '@/ui/utils/usernameUtils'; import { ERROR_CODES, SIGN_UP_MODES } from '../../../core/constants'; import { getClerkQueryParam, removeClerkQueryParam } from '../../../utils/getClerkQueryParam'; -import { withRedirectToAfterSignUp } from '../../common'; +import { withRedirectToAfterSignUp, withRedirectToSignUpTask } from '../../common'; import { SignInContext, useCoreSignUp, useEnvironment, useSignUpContext } from '../../contexts'; import { descriptors, Flex, Flow, localizationKeys, useAppearance, useLocalizations } from '../../customizables'; import { CaptchaElement } from '../../elements/CaptchaElement'; @@ -43,7 +43,7 @@ function SignUpStartInternal(): JSX.Element { const { setActive } = useClerk(); const ctx = useSignUpContext(); const isWithinSignInContext = !!React.useContext(SignInContext); - const { afterSignUpUrl, signInUrl, unsafeMetadata } = ctx; + const { afterSignUpUrl, signInUrl, unsafeMetadata, navigateOnSetActive } = ctx; const isCombinedFlow = !!(ctx.isCombinedFlow && !!isWithinSignInContext); const [activeCommIdentifierType, setActiveCommIdentifierType] = React.useState(() => getInitialActiveIdentifier(attributes, userSettings.signUp.progressive, { @@ -166,7 +166,12 @@ function SignUpStartInternal(): JSX.Element { handleComplete: () => { removeClerkQueryParam('__clerk_ticket'); removeClerkQueryParam('__clerk_invitation_token'); - return setActive({ session: signUp.createdSessionId, redirectUrl: afterSignUpUrl }); + return setActive({ + session: signUp.createdSessionId, + navigate: async ({ session }) => { + await navigateOnSetActive({ session, redirectUrl: afterSignUpUrl }); + }, + }); }, navigate, oidcPrompt, @@ -334,7 +339,13 @@ function SignUpStartInternal(): JSX.Element { signUp: res, verifyEmailPath: 'verify-email-address', verifyPhonePath: 'verify-phone-number', - handleComplete: () => setActive({ session: res.createdSessionId, redirectUrl: afterSignUpUrl }), + handleComplete: () => + setActive({ + session: res.createdSessionId, + navigate: async ({ session }) => { + await navigateOnSetActive({ session, redirectUrl: afterSignUpUrl }); + }, + }), navigate, redirectUrl, redirectUrlComplete, @@ -448,4 +459,6 @@ function SignUpStartInternal(): JSX.Element { ); } -export const SignUpStart = withRedirectToAfterSignUp(withCardStateProvider(SignUpStartInternal)); +export const SignUpStart = withRedirectToSignUpTask( + withRedirectToAfterSignUp(withCardStateProvider(SignUpStartInternal)), +); diff --git a/packages/clerk-js/src/ui/components/SignUp/SignUpVerificationCodeForm.tsx b/packages/clerk-js/src/ui/components/SignUp/SignUpVerificationCodeForm.tsx index 74697015302..40795940079 100644 --- a/packages/clerk-js/src/ui/components/SignUp/SignUpVerificationCodeForm.tsx +++ b/packages/clerk-js/src/ui/components/SignUp/SignUpVerificationCodeForm.tsx @@ -24,7 +24,7 @@ type SignInFactorOneCodeFormProps = { }; export const SignUpVerificationCodeForm = (props: SignInFactorOneCodeFormProps) => { - const { afterSignUpUrl } = useSignUpContext(); + const { afterSignUpUrl, navigateOnSetActive } = useSignUpContext(); const { setActive } = useClerk(); const { navigate } = useRouter(); @@ -43,7 +43,13 @@ export const SignUpVerificationCodeForm = (props: SignInFactorOneCodeFormProps) verifyEmailPath: '../verify-email-address', verifyPhonePath: '../verify-phone-number', continuePath: '../continue', - handleComplete: () => setActive({ session: res.createdSessionId, redirectUrl: afterSignUpUrl }), + handleComplete: () => + setActive({ + session: res.createdSessionId, + navigate: async ({ session }) => { + await navigateOnSetActive({ session, redirectUrl: afterSignUpUrl }); + }, + }), navigate, }); }) diff --git a/packages/clerk-js/src/ui/components/SignUp/index.tsx b/packages/clerk-js/src/ui/components/SignUp/index.tsx index 8668ee07dc9..6cfe5963605 100644 --- a/packages/clerk-js/src/ui/components/SignUp/index.tsx +++ b/packages/clerk-js/src/ui/components/SignUp/index.tsx @@ -2,12 +2,13 @@ import { useClerk } from '@clerk/shared/react'; import type { SignUpModalProps, SignUpProps } from '@clerk/types'; import React from 'react'; +import { usePreloadTasks } from '@/ui/hooks/usePreloadTasks'; + import { SessionTasks as LazySessionTasks } from '../../../ui/lazyModules/components'; import { SignUpEmailLinkFlowComplete } from '../../common/EmailLinkCompleteFlowCard'; import { SignUpContext, useSignUpContext, withCoreSessionSwitchGuard } from '../../contexts'; import { Flow } from '../../customizables'; -import { usePreloadTasks } from '../../hooks/usePreloadTasks'; -import { Route, Switch, useRouter, VIRTUAL_ROUTER_BASE_PATH } from '../../router'; +import { Route, Switch, VIRTUAL_ROUTER_BASE_PATH } from '../../router'; import { SignUpContinue } from './SignUpContinue'; import { SignUpSSOCallback } from './SignUpSSOCallback'; import { SignUpStart } from './SignUpStart'; @@ -25,14 +26,8 @@ function RedirectToSignUp() { function SignUpRoutes(): JSX.Element { usePreloadTasks(); - const { __internal_setComponentNavigationContext } = useClerk(); - const { navigate, indexPath } = useRouter(); const signUpContext = useSignUpContext(); - React.useEffect(() => { - return __internal_setComponentNavigationContext?.({ indexPath, navigate }); - }, [indexPath, navigate]); - return ( diff --git a/packages/clerk-js/src/ui/components/UserButton/useMultisessionActions.tsx b/packages/clerk-js/src/ui/components/UserButton/useMultisessionActions.tsx index 5a630c3a4da..c192b2e0684 100644 --- a/packages/clerk-js/src/ui/components/UserButton/useMultisessionActions.tsx +++ b/packages/clerk-js/src/ui/components/UserButton/useMultisessionActions.tsx @@ -1,6 +1,8 @@ import { useClerk } from '@clerk/shared/react'; import type { SignedInSessionResource, UserButtonProps, UserResource } from '@clerk/types'; +import { navigateIfTaskExists } from '@/core/sessionTasks'; +import { useEnvironment } from '@/ui/contexts'; import { useCardState } from '@/ui/elements/contexts'; import { sleep } from '@/ui/utils/sleep'; @@ -19,10 +21,11 @@ type UseMultisessionActionsParams = { } & Pick; export const useMultisessionActions = (opts: UseMultisessionActionsParams) => { - const { setActive, signOut, openUserProfile, __internal_navigateToTaskIfAvailable } = useClerk(); + const { setActive, signOut, openUserProfile } = useClerk(); const card = useCardState(); const { signedInSessions, otherSessions } = useMultipleSessions({ user: opts.user }); const { navigate } = useRouter(); + const { displayConfig } = useEnvironment(); const handleSignOutSessionClicked = (session: SignedInSessionResource) => () => { if (otherSessions.length === 0) { @@ -70,12 +73,23 @@ export const useMultisessionActions = (opts: UseMultisessionActionsParams) => { const handleSessionClicked = (session: SignedInSessionResource) => async () => { card.setLoading(); - return setActive({ session, redirectUrl: opts.afterSwitchSessionUrl }) - .then(() => __internal_navigateToTaskIfAvailable()) - .finally(() => { - card.setIdle(); - opts.actionCompleteCallback?.(); - }); + return setActive({ + session, + navigate: async ({ session }) => { + if (!session.currentTask && opts.afterSwitchSessionUrl) { + await navigate(opts.afterSwitchSessionUrl); + return; + } + + await navigateIfTaskExists(session, { + baseUrl: opts.signInUrl ?? displayConfig.signInUrl, + navigate, + }); + }, + }).finally(() => { + card.setIdle(); + opts.actionCompleteCallback?.(); + }); }; const handleAddAccountClicked = () => { diff --git a/packages/clerk-js/src/ui/contexts/ClerkUIComponentsContext.tsx b/packages/clerk-js/src/ui/contexts/ClerkUIComponentsContext.tsx index f2088ce71bb..ccde1a6e924 100644 --- a/packages/clerk-js/src/ui/contexts/ClerkUIComponentsContext.tsx +++ b/packages/clerk-js/src/ui/contexts/ClerkUIComponentsContext.tsx @@ -26,7 +26,7 @@ import { UserVerificationContext, WaitlistContext, } from './components'; -import { SessionTasksContext, TaskChooseOrganizationContext } from './components/SessionTasks'; +import { TaskChooseOrganizationContext } from './components/SessionTasks'; export function ComponentContextProvider({ componentName, @@ -115,9 +115,7 @@ export function ComponentContextProvider({ - - {children} - + {children} ); default: diff --git a/packages/clerk-js/src/ui/contexts/components/SignIn.ts b/packages/clerk-js/src/ui/contexts/components/SignIn.ts index 75aa4b4b1b5..35242867ea6 100644 --- a/packages/clerk-js/src/ui/contexts/components/SignIn.ts +++ b/packages/clerk-js/src/ui/contexts/components/SignIn.ts @@ -1,16 +1,14 @@ import { useClerk } from '@clerk/shared/react'; import { isAbsoluteUrl } from '@clerk/shared/url'; +import type { SessionResource } from '@clerk/types'; import { createContext, useContext, useMemo } from 'react'; +import { getTaskEndpoint } from '@/core/sessionTasks'; + import { SIGN_IN_INITIAL_VALUE_KEYS } from '../../../core/constants'; import { buildURL } from '../../../utils'; import { RedirectUrls } from '../../../utils/redirectUrls'; -import { - buildRedirectUrl, - buildSessionTaskRedirectUrl, - MAGIC_LINK_VERIFY_PATH_ROUTE, - SSO_CALLBACK_PATH_ROUTE, -} from '../../common/redirects'; +import { buildRedirectUrl, 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'; @@ -26,19 +24,20 @@ export type SignInContextType = Omit Promise; + taskUrl: string | null; }; export const SignInContext = createContext(null); export const useSignInContext = (): SignInContextType => { const context = useContext(SignInContext); - const { navigate } = useRouter(); + const { navigate, basePath } = useRouter(); const { displayConfig, userSettings } = useEnvironment(); const { queryParams, queryString } = useRouter(); const signUpMode = userSettings.signUp.mode; @@ -121,16 +120,33 @@ 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, - taskUrls: clerk.__internal_getOption('taskUrls'), - }); + const navigateOnSetActive = async ({ session, redirectUrl }: { session: SessionResource; redirectUrl: string }) => { + const currentTask = session.currentTask; + if (!currentTask) { + return navigate(redirectUrl); + } + + const taskEndpoint = getTaskEndpoint(currentTask); + const taskNavigationPath = isCombinedFlow ? '/create' + taskEndpoint : taskEndpoint; + + return navigate(`/${basePath + taskNavigationPath}`); + }; + + const taskUrl = clerk.session?.currentTask + ? (clerk.__internal_getOption('taskUrls')?.[clerk.session?.currentTask.key] ?? + buildRedirectUrl({ + routing: ctx.routing, + baseUrl: signInUrl, + authQueryString, + path: ctx.path, + endpoint: isCombinedFlow + ? '/create' + getTaskEndpoint(clerk.session?.currentTask) + : getTaskEndpoint(clerk.session?.currentTask), + })) + : null; return { - ...(ctx as SignInCtx), + ...ctx, transferable: ctx.transferable ?? true, oauthFlow: ctx.oauthFlow || 'auto', componentName, @@ -141,12 +157,13 @@ export const useSignInContext = (): SignInContextType => { afterSignUpUrl, emailLinkRedirectUrl, ssoCallbackUrl, - sessionTaskUrl, navigateAfterSignIn, signUpContinueUrl, queryParams, initialValues: { ...ctx.initialValues, ...initialValuesFromQueryParams }, authQueryString, isCombinedFlow, + navigateOnSetActive, + taskUrl, }; }; diff --git a/packages/clerk-js/src/ui/contexts/components/SignUp.ts b/packages/clerk-js/src/ui/contexts/components/SignUp.ts index f91efbea7b1..836ab65f3e8 100644 --- a/packages/clerk-js/src/ui/contexts/components/SignUp.ts +++ b/packages/clerk-js/src/ui/contexts/components/SignUp.ts @@ -1,16 +1,14 @@ import { useClerk } from '@clerk/shared/react'; import { isAbsoluteUrl } from '@clerk/shared/url'; +import type { SessionResource } from '@clerk/types'; import { createContext, useContext, useMemo } from 'react'; +import { getTaskEndpoint, INTERNAL_SESSION_TASK_ROUTE_BY_KEY } from '@/core/sessionTasks'; + import { SIGN_UP_INITIAL_VALUE_KEYS } from '../../../core/constants'; import { buildURL } from '../../../utils'; import { RedirectUrls } from '../../../utils/redirectUrls'; -import { - buildRedirectUrl, - buildSessionTaskRedirectUrl, - MAGIC_LINK_VERIFY_PATH_ROUTE, - SSO_CALLBACK_PATH_ROUTE, -} from '../../common/redirects'; +import { buildRedirectUrl, 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'; @@ -27,17 +25,18 @@ export type SignUpContextType = Omit Promise; + taskUrl: string | null; }; export const SignUpContext = createContext(null); export const useSignUpContext = (): SignUpContextType => { const context = useContext(SignUpContext); - const { navigate } = useRouter(); + const { navigate, basePath } = useRouter(); const { displayConfig, userSettings } = useEnvironment(); const { queryParams, queryString } = useRouter(); const signUpMode = userSettings.signUp.mode; @@ -116,13 +115,25 @@ 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, - taskUrls: clerk.__internal_getOption('taskUrls'), - }); + const navigateOnSetActive = async ({ session, redirectUrl }: { session: SessionResource; redirectUrl: string }) => { + const currentTask = session.currentTask; + if (!currentTask) { + return navigate(redirectUrl); + } + + return navigate(`/${basePath}/tasks/${INTERNAL_SESSION_TASK_ROUTE_BY_KEY[currentTask.key]}`); + }; + + const taskUrl = clerk.session?.currentTask + ? (clerk.__internal_getOption('taskUrls')?.[clerk.session?.currentTask.key] ?? + buildRedirectUrl({ + routing: ctx.routing, + baseUrl: signInUrl, + authQueryString, + path: ctx.path, + endpoint: getTaskEndpoint(clerk.session?.currentTask), + })) + : null; return { ...ctx, @@ -136,11 +147,12 @@ export const useSignUpContext = (): SignUpContextType => { afterSignInUrl, emailLinkRedirectUrl, ssoCallbackUrl, - sessionTaskUrl, navigateAfterSignUp, queryParams, initialValues: { ...ctx.initialValues, ...initialValuesFromQueryParams }, authQueryString, isCombinedFlow, + navigateOnSetActive, + taskUrl, }; }; diff --git a/packages/clerk-js/src/ui/lazyModules/components.ts b/packages/clerk-js/src/ui/lazyModules/components.ts index 70c65670e8b..c137093cc40 100644 --- a/packages/clerk-js/src/ui/lazyModules/components.ts +++ b/packages/clerk-js/src/ui/lazyModules/components.ts @@ -122,7 +122,7 @@ export const OAuthConsent = lazy(() => ); export const SessionTasks = lazy(() => - componentImportPaths.SessionTasks().then(module => ({ default: module.SessionTask })), + componentImportPaths.SessionTasks().then(module => ({ default: module.SessionTasks })), ); export const preloadComponent = async (component: unknown) => { diff --git a/packages/clerk-js/src/ui/types.ts b/packages/clerk-js/src/ui/types.ts index c9b9f04260c..ec0640fdc1f 100644 --- a/packages/clerk-js/src/ui/types.ts +++ b/packages/clerk-js/src/ui/types.ts @@ -12,6 +12,7 @@ import type { OrganizationProfileProps, OrganizationSwitcherProps, PricingTableProps, + SessionResource, SignInFallbackRedirectUrl, SignInForceRedirectUrl, SignInProps, @@ -136,6 +137,7 @@ export type CheckoutCtx = __internal_CheckoutProps & { export type SessionTasksCtx = { redirectUrlComplete: string; currentTaskContainer?: React.RefObject | null; + navigateOnSetActive: (opts: { session: SessionResource; redirectUrl: string }) => Promise; }; export type TaskChooseOrganizationCtx = TaskChooseOrganizationProps & { diff --git a/packages/clerk-js/src/utils/componentGuards.ts b/packages/clerk-js/src/utils/componentGuards.ts index baf23904b17..afdb2d476b0 100644 --- a/packages/clerk-js/src/utils/componentGuards.ts +++ b/packages/clerk-js/src/utils/componentGuards.ts @@ -6,8 +6,8 @@ export type ComponentGuard = ( options?: ClerkOptions, ) => boolean; -export const sessionExistsAndSingleSessionModeEnabled: ComponentGuard = (clerk, environment) => { - return !!(clerk.session && environment?.authConfig.singleSessionMode); +export const isSignedInAndSingleSessionModeEnabled: ComponentGuard = (clerk, environment) => { + return !!(clerk.isSignedIn && environment?.authConfig.singleSessionMode); }; export const noUserExists: ComponentGuard = clerk => { diff --git a/packages/nextjs/src/client-boundary/controlComponents.ts b/packages/nextjs/src/client-boundary/controlComponents.ts index fd5a2b586fe..5398de807fc 100644 --- a/packages/nextjs/src/client-boundary/controlComponents.ts +++ b/packages/nextjs/src/client-boundary/controlComponents.ts @@ -10,7 +10,7 @@ export { Protect, RedirectToSignIn, RedirectToSignUp, - RedirectToTask, + RedirectToTasks, RedirectToUserProfile, AuthenticateWithRedirectCallback, RedirectToCreateOrganization, diff --git a/packages/nextjs/src/index.ts b/packages/nextjs/src/index.ts index 6832c05396f..a8fa99f7311 100644 --- a/packages/nextjs/src/index.ts +++ b/packages/nextjs/src/index.ts @@ -12,7 +12,6 @@ export { RedirectToOrganizationProfile, RedirectToSignIn, RedirectToSignUp, - RedirectToTask, RedirectToUserProfile, } from './client-boundary/controlComponents'; diff --git a/packages/react-router/src/__tests__/__snapshots__/exports.test.ts.snap b/packages/react-router/src/__tests__/__snapshots__/exports.test.ts.snap index 1b5cbd2560e..440ab515d9f 100644 --- a/packages/react-router/src/__tests__/__snapshots__/exports.test.ts.snap +++ b/packages/react-router/src/__tests__/__snapshots__/exports.test.ts.snap @@ -20,7 +20,7 @@ exports[`root public exports > should not change unexpectedly 1`] = ` "RedirectToOrganizationProfile", "RedirectToSignIn", "RedirectToSignUp", - "RedirectToTask", + "RedirectToTasks", "RedirectToUserProfile", "SignIn", "SignInButton", diff --git a/packages/react/src/components/controlComponents.tsx b/packages/react/src/components/controlComponents.tsx index 1d1d463cded..6ac89e1becb 100644 --- a/packages/react/src/components/controlComponents.tsx +++ b/packages/react/src/components/controlComponents.tsx @@ -166,20 +166,13 @@ export const RedirectToSignUp = withClerk(({ clerk, ...props }: WithClerkProp { - const { session } = clerk; - +export const RedirectToTasks = withClerk(({ clerk }: WithClerkProp) => { React.useEffect(() => { - if (!session) { - void clerk.redirectToSignIn(); - return; - } - - void clerk.__internal_navigateToTaskIfAvailable(); + void clerk.redirectToTasks(); }, []); return null; -}, 'RedirectToTask'); +}, 'RedirectToTasks'); /** * @function diff --git a/packages/react/src/components/index.ts b/packages/react/src/components/index.ts index b39014d0e01..d5a6ca33492 100644 --- a/packages/react/src/components/index.ts +++ b/packages/react/src/components/index.ts @@ -25,7 +25,7 @@ export { RedirectToOrganizationProfile, RedirectToSignIn, RedirectToSignUp, - RedirectToTask, + RedirectToTasks, RedirectToUserProfile, SignedIn, SignedOut, diff --git a/packages/react/src/isomorphicClerk.ts b/packages/react/src/isomorphicClerk.ts index 0eba21ccd54..22f70b3aaa3 100644 --- a/packages/react/src/isomorphicClerk.ts +++ b/packages/react/src/isomorphicClerk.ts @@ -4,7 +4,6 @@ import { loadClerkJsScript } from '@clerk/shared/loadClerkJsScript'; import { handleValueOrFn } from '@clerk/shared/utils'; import type { __internal_CheckoutProps, - __internal_NavigateToTaskIfAvailableParams, __internal_OAuthConsentProps, __internal_PlanDetailsProps, __internal_SubscriptionDetailsProps, @@ -105,7 +104,6 @@ type IsomorphicLoadedClerk = Without< | '__internal_reloadInitialResources' | 'billing' | 'apiKeys' - | '__internal_setComponentNavigationContext' | '__internal_setActiveInProgress' | '__internal_hasAfterAuthFlows' > & { @@ -390,6 +388,15 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { } }; + buildTasksUrl = (): string | void => { + const callback = () => this.clerkjs?.buildTasksUrl() || ''; + if (this.clerkjs && this.loaded) { + return callback(); + } else { + this.premountMethodCalls.set('buildTasksUrl', callback); + } + }; + buildUrlWithAuth = (to: string): string | void => { const callback = () => this.clerkjs?.buildUrlWithAuth(to) || ''; if (this.clerkjs && this.loaded) { @@ -743,14 +750,6 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { } }; - __internal_navigateToTaskIfAvailable = async (params?: __internal_NavigateToTaskIfAvailableParams): Promise => { - if (this.clerkjs) { - return this.clerkjs.__internal_navigateToTaskIfAvailable(params); - } else { - return Promise.reject(); - } - }; - /** * `setActive` can be used to set the active session and/or organization. */ @@ -1277,6 +1276,16 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { } }; + redirectToTasks = async () => { + const callback = () => this.clerkjs?.redirectToTasks(); + if (this.clerkjs && this.loaded) { + return callback(); + } else { + this.premountMethodCalls.set('redirectToTasks', callback); + return; + } + }; + handleRedirectCallback = async (params: HandleOAuthCallbackParams): Promise => { const callback = () => this.clerkjs?.handleRedirectCallback(params); if (this.clerkjs && this.loaded) { diff --git a/packages/remix/src/__tests__/__snapshots__/exports.test.ts.snap b/packages/remix/src/__tests__/__snapshots__/exports.test.ts.snap index 31f9180538f..f985eae730e 100644 --- a/packages/remix/src/__tests__/__snapshots__/exports.test.ts.snap +++ b/packages/remix/src/__tests__/__snapshots__/exports.test.ts.snap @@ -21,7 +21,7 @@ exports[`root public exports > should not change unexpectedly 1`] = ` "RedirectToOrganizationProfile", "RedirectToSignIn", "RedirectToSignUp", - "RedirectToTask", + "RedirectToTasks", "RedirectToUserProfile", "SignIn", "SignInButton", diff --git a/packages/tanstack-react-start/src/__tests__/__snapshots__/exports.test.ts.snap b/packages/tanstack-react-start/src/__tests__/__snapshots__/exports.test.ts.snap index 201ef59fb7a..28973325dad 100644 --- a/packages/tanstack-react-start/src/__tests__/__snapshots__/exports.test.ts.snap +++ b/packages/tanstack-react-start/src/__tests__/__snapshots__/exports.test.ts.snap @@ -32,7 +32,7 @@ exports[`root public exports > should not change unexpectedly 1`] = ` "RedirectToOrganizationProfile", "RedirectToSignIn", "RedirectToSignUp", - "RedirectToTask", + "RedirectToTasks", "RedirectToUserProfile", "SignIn", "SignInButton", diff --git a/packages/types/src/clerk.ts b/packages/types/src/clerk.ts index 66f8171b449..a9105e5b64e 100644 --- a/packages/types/src/clerk.ts +++ b/packages/types/src/clerk.ts @@ -49,7 +49,7 @@ import type { SignUpFallbackRedirectUrl, SignUpForceRedirectUrl, } from './redirects'; -import type { SessionTask, SignedInSessionResource } from './session'; +import type { SessionResource, SessionTask, SignedInSessionResource } from './session'; import type { SessionVerificationLevel } from './sessionVerification'; import type { SignInResource } from './signIn'; import type { SignUpResource } from './signUp'; @@ -120,6 +120,7 @@ export type SDKMetadata = { export type ListenerCallback = (emission: Resources) => void; export type UnsubscribeCallback = () => void; export type BeforeEmitCallback = (session?: SignedInSessionResource | null) => void | Promise; +export type SetActiveNavigate = ({ session }: { session: SessionResource }) => Promise; export type SignOutCallback = () => void | Promise; @@ -625,13 +626,6 @@ 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: __internal_ComponentNavigationContext) => () => void; - /** * Set the active session and organization explicitly. * @@ -681,6 +675,11 @@ export interface Clerk { */ buildOrganizationProfileUrl(): string; + /** + * Returns the configured url where tasks are mounted. + */ + buildTasksUrl(): string; + /** * Returns the configured afterSignInUrl of the instance. */ @@ -768,6 +767,11 @@ export interface Clerk { */ redirectToWaitlist: () => void; + /** + * Redirects to the configured URL where tasks are mounted. + */ + redirectToTasks(): Promise; + /** * Completes a Google One Tap redirection flow started by * {@link Clerk.authenticateWithGoogleOneTap} @@ -839,12 +843,6 @@ export interface Clerk { joinWaitlist: (params: JoinWaitlistParams) => Promise; - /** - * Navigates to the current task or redirects to `redirectUrlComplete` once the session is `active`. - * @internal - */ - __internal_navigateToTaskIfAvailable: (params?: __internal_NavigateToTaskIfAvailableParams) => Promise; - /** * This is an optional function. * This function is used to load cached Client and Environment resources if Clerk fails to load them from the Frontend API. @@ -1203,9 +1201,32 @@ export type SetActiveParams = { beforeEmit?: BeforeEmitCallback; /** - * The full URL or path to redirect to just before the active session and/or organization is set. + * The full URL or path to redirect to just before the session and/or organization is set. */ redirectUrl?: string; + + /** + * A custom navigation function to be called just before the session and/or organization is set. + * + * When provided, it takes precedence over the `redirectUrl` parameter for navigation. + * + * @example + * ```typescript + * await clerk.setActive({ + * session, + * navigate: async ({ session }) => { + * const currentTask = session.currentTask; + * if (currentTask) { + * await router.push(`/onboarding/${currentTask.key}`) + * return + * } + * + * router.push('/dashboard'); + * } + * }); + * ``` + */ + navigate?: SetActiveNavigate; }; /** @@ -2159,14 +2180,6 @@ export interface AuthenticateWithGoogleOneTapParams { legalAccepted?: boolean; } -export interface __internal_NavigateToTaskIfAvailableParams { - /** - * Full URL or path to navigate to after successfully resolving all tasks - * @default undefined - */ - redirectUrlComplete?: string; -} - export interface LoadedClerk extends Clerk { client: ClientResource; } diff --git a/packages/vue/src/components/controlComponents.ts b/packages/vue/src/components/controlComponents.ts index bda13710b10..cb787b1d8cf 100644 --- a/packages/vue/src/components/controlComponents.ts +++ b/packages/vue/src/components/controlComponents.ts @@ -60,16 +60,9 @@ export const RedirectToSignUp = defineComponent((props: RedirectOptions) => { return () => null; }); -export const RedirectToTask = defineComponent((props: RedirectOptions) => { - const { sessionCtx } = useClerkContext(); - +export const RedirectToTasks = defineComponent(() => { useClerkLoaded(clerk => { - if (!sessionCtx.value) { - void clerk.redirectToSignIn(props); - return; - } - - void clerk.__internal_navigateToTaskIfAvailable(); + void clerk.redirectToTasks(); }); return () => null;