diff --git a/.changeset/whole-knives-attend.md b/.changeset/whole-knives-attend.md new file mode 100644 index 00000000000..232c5603fc6 --- /dev/null +++ b/.changeset/whole-knives-attend.md @@ -0,0 +1,14 @@ +--- +'@clerk/clerk-js': patch +'@clerk/types': patch +--- + +Add `taskUrls` option to customize task flow URLs: + +```tsx + +``` diff --git a/.typedoc/__tests__/__snapshots__/file-structure.test.ts.snap b/.typedoc/__tests__/__snapshots__/file-structure.test.ts.snap index f24a109e99b..a36c2dbcd77 100644 --- a/.typedoc/__tests__/__snapshots__/file-structure.test.ts.snap +++ b/.typedoc/__tests__/__snapshots__/file-structure.test.ts.snap @@ -106,6 +106,7 @@ exports[`Typedoc output > should have a deliberate file structure 1`] = ` "types/server-get-token.mdx", "types/session-resource.mdx", "types/session-status-claim.mdx", + "types/session-task.mdx", "types/session-verification-level.mdx", "types/session-verification-types.mdx", "types/set-active-params.mdx", diff --git a/packages/clerk-js/src/core/__tests__/clerk.test.ts b/packages/clerk-js/src/core/__tests__/clerk.test.ts index 1be58ac9236..035da4a48cb 100644 --- a/packages/clerk-js/src/core/__tests__/clerk.test.ts +++ b/packages/clerk-js/src/core/__tests__/clerk.test.ts @@ -2331,7 +2331,7 @@ describe('Clerk singleton', () => { }); }); - describe('nextTask', () => { + describe('navigateToTask', () => { describe('with `pending` session status', () => { const mockSession = { id: '1', @@ -2350,7 +2350,7 @@ describe('Clerk singleton', () => { reload: jest.fn(() => Promise.resolve(mockSession)), }; - beforeAll(() => { + beforeEach(() => { mockResource.touch.mockReturnValueOnce(Promise.resolve()); mockClientFetch.mockReturnValue(Promise.resolve({ signedInSessions: [mockResource] })); }); @@ -2360,7 +2360,7 @@ describe('Clerk singleton', () => { mockResource.touch.mockReset(); }); - it('navigates to next task', async () => { + it('navigates to next task with default internal routing for AIOs', async () => { const sut = new Clerk(productionPublishableKey); await sut.load(mockedLoadOptions); @@ -2369,6 +2369,21 @@ describe('Clerk singleton', () => { expect(mockNavigate.mock.calls[0][0]).toBe('/sign-in#/tasks/add-organization'); }); + + it('navigates to next task with custom routing from clerk options', async () => { + const sut = new Clerk(productionPublishableKey); + await sut.load({ + ...mockedLoadOptions, + taskUrls: { + org: '/onboarding/select-organization', + }, + }); + + await sut.setActive({ session: mockResource as any as PendingSessionResource }); + await sut.__internal_navigateToTaskIfAvailable(); + + expect(mockNavigate.mock.calls[0][0]).toBe('/onboarding/select-organization'); + }); }); describe('with `active` session status', () => { diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index 2439952460e..6389fb02bc7 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -1317,7 +1317,7 @@ export class Clerk implements ClerkInterface { eventBus.emit(events.TokenUpdate, { token: null }); } - // Only triggers navigation for internal AIO components routing + // Only triggers navigation for internal AIO components routing or custom URLs const shouldNavigateOnSetActive = this.#componentNavigationContext; if (newSession?.currentTask && shouldNavigateOnSetActive) { await navigateToTask(session.currentTask.key, { diff --git a/packages/clerk-js/src/core/sessionTasks.ts b/packages/clerk-js/src/core/sessionTasks.ts index 1971d68e870..da44e2a5756 100644 --- a/packages/clerk-js/src/core/sessionTasks.ts +++ b/packages/clerk-js/src/core/sessionTasks.ts @@ -7,7 +7,7 @@ import type { import { buildURL } from '../utils'; -export const SESSION_TASK_ROUTE_BY_KEY: Record = { +export const INTERNAL_SESSION_TASK_ROUTE_BY_KEY: Record = { org: 'add-organization', } as const; @@ -24,10 +24,10 @@ interface NavigateToTaskOptions { * @internal */ export function navigateToTask( - routeKey: keyof typeof SESSION_TASK_ROUTE_BY_KEY, + routeKey: keyof typeof INTERNAL_SESSION_TASK_ROUTE_BY_KEY, { componentNavigationContext, globalNavigate, options, environment }: NavigateToTaskOptions, ) { - const taskRoute = `/tasks/${SESSION_TASK_ROUTE_BY_KEY[routeKey]}`; + const taskRoute = `/tasks/${INTERNAL_SESSION_TASK_ROUTE_BY_KEY[routeKey]}`; if (componentNavigationContext) { return componentNavigationContext.navigate(componentNavigationContext.indexPath + taskRoute); @@ -38,7 +38,6 @@ export function navigateToTask( const isReferrerSignUpUrl = window.location.href.startsWith(signUpUrl); const sessionTaskUrl = buildURL( - // TODO - Accept custom URL option for custom flows in order to eject out of `signInUrl/signUpUrl` { base: isReferrerSignUpUrl ? signUpUrl : signInUrl, hashPath: taskRoute, @@ -46,5 +45,5 @@ export function navigateToTask( { stringify: true }, ); - return globalNavigate(sessionTaskUrl); + return globalNavigate(options.taskUrls?.[routeKey] ?? sessionTaskUrl); } diff --git a/packages/clerk-js/src/ui/common/redirects.ts b/packages/clerk-js/src/ui/common/redirects.ts index 59636a9bb64..23e805815e0 100644 --- a/packages/clerk-js/src/ui/common/redirects.ts +++ b/packages/clerk-js/src/ui/common/redirects.ts @@ -1,6 +1,6 @@ -import type { SessionTask } from '@clerk/types'; +import type { ClerkOptions, SessionTask } from '@clerk/types'; -import { SESSION_TASK_ROUTE_BY_KEY } from '../../core/sessionTasks'; +import { INTERNAL_SESSION_TASK_ROUTE_BY_KEY } from '../../core/sessionTasks'; import { buildURL } from '../../utils/url'; import type { SignInContextType, SignUpContextType, UserProfileContextType } from './../contexts'; @@ -33,21 +33,26 @@ export function buildSessionTaskRedirectUrl({ path, baseUrl, task, + taskUrls, }: Pick & { baseUrl: string; task?: SessionTask; + taskUrls?: ClerkOptions['taskUrls']; }) { if (!task) { return null; } - return buildRedirectUrl({ - routing, - baseUrl, - path, - endpoint: `/tasks/${SESSION_TASK_ROUTE_BY_KEY[task.key]}`, - authQueryString: null, - }); + return ( + taskUrls?.[task.key] ?? + buildRedirectUrl({ + routing, + baseUrl, + path, + endpoint: `/tasks/${INTERNAL_SESSION_TASK_ROUTE_BY_KEY[task.key]}`, + authQueryString: null, + }) + ); } export function buildSSOCallbackURL( diff --git a/packages/clerk-js/src/ui/components/SessionTasks/index.tsx b/packages/clerk-js/src/ui/components/SessionTasks/index.tsx index 23627b0bfe5..20f4229594d 100644 --- a/packages/clerk-js/src/ui/components/SessionTasks/index.tsx +++ b/packages/clerk-js/src/ui/components/SessionTasks/index.tsx @@ -6,7 +6,7 @@ import { Card } from '@/ui/elements/Card'; import { withCardStateProvider } from '@/ui/elements/contexts'; import { LoadingCardContainer } from '@/ui/elements/LoadingCard'; -import { SESSION_TASK_ROUTE_BY_KEY } from '../../../core/sessionTasks'; +import { INTERNAL_SESSION_TASK_ROUTE_BY_KEY } from '../../../core/sessionTasks'; import { SignInContext, SignUpContext } from '../../../ui/contexts'; import { SessionTasksContext, useSessionTasksContext } from '../../contexts/components/SessionTasks'; import { Route, Switch, useRouter } from '../../router'; @@ -38,7 +38,7 @@ const SessionTasksStart = () => { function SessionTaskRoutes(): JSX.Element { return ( - + diff --git a/packages/clerk-js/src/ui/contexts/components/SignIn.ts b/packages/clerk-js/src/ui/contexts/components/SignIn.ts index 67f6442bb3a..75aa4b4b1b5 100644 --- a/packages/clerk-js/src/ui/contexts/components/SignIn.ts +++ b/packages/clerk-js/src/ui/contexts/components/SignIn.ts @@ -126,6 +126,7 @@ export const useSignInContext = (): SignInContextType => { path: ctx.path, routing: ctx.routing, baseUrl: signInUrl, + taskUrls: clerk.__internal_getOption('taskUrls'), }); return { diff --git a/packages/clerk-js/src/ui/contexts/components/SignUp.ts b/packages/clerk-js/src/ui/contexts/components/SignUp.ts index 7632e782f2d..f91efbea7b1 100644 --- a/packages/clerk-js/src/ui/contexts/components/SignUp.ts +++ b/packages/clerk-js/src/ui/contexts/components/SignUp.ts @@ -121,6 +121,7 @@ export const useSignUpContext = (): SignUpContextType => { path: ctx.path, routing: ctx.routing, baseUrl: signUpUrl, + taskUrls: clerk.__internal_getOption('taskUrls'), }); return { diff --git a/packages/types/src/clerk.ts b/packages/types/src/clerk.ts index 543f45d40a0..3abdb10a664 100644 --- a/packages/types/src/clerk.ts +++ b/packages/types/src/clerk.ts @@ -48,7 +48,7 @@ import type { SignUpFallbackRedirectUrl, SignUpForceRedirectUrl, } from './redirects'; -import type { PendingSessionOptions, SignedInSessionResource } from './session'; +import type { PendingSessionOptions, SessionTask, SignedInSessionResource } from './session'; import type { SessionVerificationLevel } from './sessionVerification'; import type { SignInResource } from './signIn'; import type { SignUpResource } from './signUp'; @@ -1050,6 +1050,14 @@ export type ClerkOptions = PendingSessionOptions & * @internal */ __internal_keyless_dismissPrompt?: (() => Promise) | null; + + /** + * Customize the URL paths users are redirected to after sign-in or sign-up when specific + * session tasks need to be completed. + * + * @default undefined - Uses Clerk's default task flow URLs + */ + taskUrls?: Record; }; export interface NavigateOptions { diff --git a/packages/types/src/session.ts b/packages/types/src/session.ts index a6adc59c33e..84ebb5eaf1d 100644 --- a/packages/types/src/session.ts +++ b/packages/types/src/session.ts @@ -326,7 +326,14 @@ export interface PublicUserData { userId?: string; } +/** + * Represents a required action that a user must complete + * before their session becomes fully active + */ export interface SessionTask { + /** + * The unique identifier for the type of task that needs to be completed + */ key: 'org'; }