Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
6622e0a
Add `tasks` on `Session` resource
LauraBeatris Feb 14, 2025
7d2d43d
Add changeset
LauraBeatris Feb 14, 2025
d4f072e
Update redirect guard to explict check for `active`
LauraBeatris Feb 17, 2025
e307f3a
Display pending task routes
LauraBeatris Feb 17, 2025
0d0a717
Update handling for array of tasks
LauraBeatris Feb 18, 2025
f44a7ec
Fix session task key
LauraBeatris Feb 18, 2025
43beb76
Implement unit tests for Task component
LauraBeatris Feb 19, 2025
46fe2b8
Add unit tests for `useTaskRoute`
LauraBeatris Feb 19, 2025
9234c79
Add option for a custom tasks URL to cover custom flow
LauraBeatris Feb 20, 2025
d38dbbc
Refactor redirect guards for tasks
LauraBeatris Feb 20, 2025
1dc30d5
Does not trigger `redirectUrl` logic
LauraBeatris Feb 20, 2025
36e0c91
Trigger navigation on client-piggybacking
LauraBeatris Feb 21, 2025
f1e64f1
Set tasks URL on the sign-in/sign-up context
LauraBeatris Feb 24, 2025
9a5415d
Introduce separate redirect guards for tasks
LauraBeatris Feb 24, 2025
1233d3b
Add unit test for redirection to task
LauraBeatris Feb 24, 2025
e15d124
Introduce skeleton for integration tests
LauraBeatris Feb 25, 2025
b9f8870
Introduce new base skeleton for URL resolution
LauraBeatris Feb 25, 2025
22b4a4a
Add `with-session-tasks` to integration tests
LauraBeatris Feb 26, 2025
af7f79a
Implement integration tests
LauraBeatris Feb 27, 2025
8818499
Do not close modals on `Clerk.navigate` if the origin is not outisde …
LauraBeatris Feb 27, 2025
be6c67e
Add intermediary route for task resolution
LauraBeatris Mar 4, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/old-cherries-laugh.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
---
'@clerk/clerk-js': patch
'@clerk/types': patch
---

Navigate to session tasks on after sign-in/sign-up
4 changes: 4 additions & 0 deletions integration/.keys.json.sample
Original file line numberDiff line numberDiff line change
Expand Up@@ -46,5 +46,9 @@
"with-waitlist-mode": {
"pk": "",
"sk": ""
},
"with-session-tasks": {
"pk": "",
"sk": ""
}
}
8 changes: 8 additions & 0 deletions integration/presets/envs.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -42,6 +42,13 @@ const withEmailCodes = base
.setEnvVariable('public', 'CLERK_PUBLISHABLE_KEY', instanceKeys.get('with-email-codes').pk)
.setEnvVariable('private', 'CLERK_ENCRYPTION_KEY', constants.E2E_CLERK_ENCRYPTION_KEY || 'a-key');

const withSessionTasks = base
.clone()
.setId('withSessionTasks')
.setEnvVariable('private', 'CLERK_SECRET_KEY', instanceKeys.get('with-session-tasks').sk)
.setEnvVariable('public', 'CLERK_PUBLISHABLE_KEY', instanceKeys.get('with-session-tasks').pk)
.setEnvVariable('private', 'CLERK_ENCRYPTION_KEY', constants.E2E_CLERK_ENCRYPTION_KEY || 'a-key');

const withEmailCodes_destroy_client = withEmailCodes
.clone()
.setEnvVariable('public', 'EXPERIMENTAL_PERSIST_CLIENT', 'false');
Expand DownExpand Up@@ -157,4 +164,5 @@ export const envs = {
withSignInOrUpFlow,
withSignInOrUpEmailLinksFlow,
withSignInOrUpwithRestrictedModeFlow,
withSessionTasks,
} as const;
5 changes: 5 additions & 0 deletions integration/presets/longRunningApps.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -37,6 +37,11 @@ export const createLongRunningApps = () => {
config: next.appRouter,
env: envs.withSignInOrUpEmailLinksFlow,
},
{
id: 'next.appRouter.withSessionTasks',
config: next.appRouter,
env: envs.withSessionTasks,
},
{ id: 'quickstart.next.appRouter', config: next.appRouterQuickstart, env: envs.withEmailCodesQuickstart },
{ id: 'elements.next.appRouter', config: elements.nextAppRouter, env: envs.withEmailCodes },
{ id: 'astro.node.withCustomRoles', config: astro.node, env: envs.withCustomRoles },
Expand Down
1 change: 1 addition & 0 deletions integration/tests/session-tasks-multi-session.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
// TODO - add tests
72 changes: 72 additions & 0 deletions integration/tests/session-tasks-sign-in.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
import { expect, test } from '@playwright/test';

import type { Application } from '../models/application';
import { appConfigs } from '../presets';
import type { FakeUser } from '../testUtils';
import { createTestUtils } from '../testUtils';

test.describe('session tasks sign in flow @nextjs', () => {
test.describe.configure({ mode: 'serial' });
let app: Application;
let fakeUser: FakeUser;

test.beforeAll(async () => {
app = await appConfigs.next.appRouter.clone().commit();
await app.setup();
await app.withEnv(appConfigs.envs.withSessionTasks);
await app.dev();

const m = createTestUtils({ app });
fakeUser = m.services.users.createFakeUser({
withPhoneNumber: true,
withUsername: true,
});
await m.services.users.createBapiUser(fakeUser);
});

test.afterAll(async () => {
await fakeUser.deleteIfExists();
await app.teardown();
});

test('on after sign-in, navigates to tasks', async ({ page, context }) => {
const u = createTestUtils({ app, page, context });
await u.po.signIn.goTo();
await u.po.signIn.setIdentifier(fakeUser.email);
await u.po.signIn.continue();
await u.po.signIn.setPassword(fakeUser.password);
await u.po.signIn.continue();
await u.po.expect.toBeSignedIn();
await expect(u.page.getByRole('heading', { name: 'Create Organization' })).toBeVisible();
expect(u.page.url()).toContain('/sign-in/add-organization');
});

test.fixme('redirects to after sign-in url when session tasks has been resolved', () => {
// todo
});

test.fixme('redirects to after sign-in url when accessing root sign in with a active session', {
// todo
});

test('redirects back to tasks when accessing root sign in', async ({ page, context }) => {
const u = createTestUtils({ app, page, context });
await u.po.signIn.goTo();
await u.po.signIn.setIdentifier(fakeUser.email);
await u.po.signIn.continue();
await u.po.signIn.setPassword(fakeUser.password);
await u.po.signIn.continue();
await u.po.expect.toBeSignedIn();
await expect(u.page.getByRole('heading', { name: 'Create Organization' })).toBeVisible();
expect(u.page.url()).toContain('/sign-in/add-organization');
await u.po.signIn.goTo();
await expect(u.page.getByRole('heading', { name: 'Create Organization' })).toBeVisible();
expect(u.page.url()).toContain('/sign-in/add-organization');
});

test('without a session, does not allow to access tasks', async ({ page, context }) => {
const u = createTestUtils({ app, page, context });
await u.page.goto('/sign-in/add-organization');
expect(u.page.url()).not.toContain('/sign-in/add-organization');
});
});
73 changes: 73 additions & 0 deletions integration/tests/session-tasks-sign-up.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
import { expect, test } from '@playwright/test';

import type { Application } from '../models/application';
import { appConfigs } from '../presets';
import { createTestUtils } from '../testUtils';

test.describe('session tasks sign in flow @nextjs', () => {
test.describe.configure({ mode: 'serial' });
let app: Application;

test.beforeAll(async () => {
app = await appConfigs.next.appRouter.clone().commit();
await app.setup();
await app.withEnv(appConfigs.envs.withSessionTasks);
await app.dev();
});

test.afterAll(async () => {
await app.teardown();
});

test('on after sign-up, navigates to tasks', async ({ page, context }) => {
const u = createTestUtils({ app, page, context });
const fakeUser = u.services.users.createFakeUser({
fictionalEmail: true,
});
await u.po.signUp.goTo();
await u.po.signUp.signUpWithEmailAndPassword({
email: fakeUser.email,
password: fakeUser.password,
});
await u.po.signUp.enterTestOtpCode();
await u.po.expect.toBeSignedIn();

await expect(u.page.getByRole('heading', { name: 'Create Organization' })).toBeVisible();
expect(u.page.url()).toContain('/sign-up/add-organization');

await fakeUser.deleteIfExists();
});

test.fixme('redirects to after sign-up url when session tasks has been resolved', () => {
// todo
});

test.fixme('redirects to after sign-up url when accessing root sign in with a active session', {
// todo
});

test('redirects back to tasks when accessing root sign in', async ({ page, context }) => {
const u = createTestUtils({ app, page, context });
const fakeUser = u.services.users.createFakeUser({
fictionalEmail: true,
});
await u.po.signUp.goTo();
await u.po.signUp.signUpWithEmailAndPassword({
email: fakeUser.email,
password: fakeUser.password,
});
await u.po.signUp.enterTestOtpCode();
await u.po.expect.toBeSignedIn();
await expect(u.page.getByRole('heading', { name: 'Create Organization' })).toBeVisible();
expect(u.page.url()).toContain('/sign-up/add-organization');
await u.po.signIn.goTo();
await expect(u.page.getByRole('heading', { name: 'Create Organization' })).toBeVisible();
expect(u.page.url()).toContain('/sign-up/add-organization');
});

test('without a session, does not allow to access tasks', async ({ page, context }) => {
const u = createTestUtils({ app, page, context });
await u.page.goto('/sign-up/add-organization');
expect(u.page.url()).not.toContain('/sign-up/add-organization');
});
});
39 changes: 38 additions & 1 deletion packages/clerk-js/src/core/clerk.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,6 +23,7 @@ import type {
CreateOrganizationParams,
CreateOrganizationProps,
CredentialReturn,
CustomNavigation,
DomainOrProxyUrl,
EnvironmentJSON,
EnvironmentJSONSnapshot,
Expand All@@ -45,6 +46,7 @@ import type {
RedirectOptions,
Resources,
SDKMetadata,
SessionTask,
SetActiveParams,
SignedInSessionResource,
SignInProps,
Expand All@@ -65,6 +67,7 @@ import type {
WaitlistResource,
Web3Provider,
} from '@clerk/types';
import type { SessionTaskRoutePath } from 'ui/common/tasks';

import type { MountComponentRenderer } from '../ui/Components';
import {
Expand DownExpand Up@@ -946,7 +949,10 @@ export class Clerk implements ClerkInterface {
beforeUnloadTracker?.stopTracking();
}

if (redirectUrl && !beforeEmit) {
// Overrides the default behavior of redirects to `afterSignInUrl`
// or `afterSignUpUrl` to redirect the user to their assigned tasks
const hasSessionToResolve = newSession?.currentTask;
if (redirectUrl && !beforeEmit && !hasSessionToResolve) {
beforeUnloadTracker?.startTracking();
this.#setTransitiveState();

Expand DownExpand Up@@ -1728,6 +1734,8 @@ export class Clerk implements ClerkInterface {
if (this.session) {
const session = this.#getSessionFromClient(this.session.id);

this.maybeNavigateToTaskResolution(this.navigate);

// Note: this might set this.session to null
this.#setAccessors(session);

Expand DownExpand Up@@ -2260,4 +2268,33 @@ export class Clerk implements ClerkInterface {

return allowedProtocols;
}

maybeNavigateToTaskResolution(customNavigate?: (to: string) => Promise<unknown>) {
if (!this.session?.currentTask || !inBrowser()) {
return;
}

const isOnTaskResolutionPath = window.location.href.includes('navigate-to-task');
if (isOnTaskResolutionPath) {
return;
}

const url = buildURL({ base: `${this.#options.signInUrl}/navigate-to-task` }, { stringify: true });

void customNavigate?.(url);
}

navigateToTaskPath(customNavigate?: CustomNavigation) {
if (!this.session?.currentTask || !inBrowser()) {
return;
}

const taskKeyToRoutePaths: Record<SessionTask['key'], SessionTaskRoutePath> = {
org: 'add-organization',
};

const routePath = taskKeyToRoutePaths[this.session.currentTask.key];

void customNavigate?.(routePath);
}
}
4 changes: 4 additions & 0 deletions packages/clerk-js/src/core/resources/Session.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -303,4 +303,8 @@ export class Session extends BaseResource implements SessionResource {
return token.getRawString() || null;
});
}

get currentTask(): SessionTask | undefined {
return (this.tasks ?? [])[0];
}
}
23 changes: 23 additions & 0 deletions packages/clerk-js/src/ui/common/TaskNavigation.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
import { Flow } from '../customizables';
import { Card, LoadingCardContainer, withCardStateProvider } from '../elements';

export const TaskNavigation = withCardStateProvider(() => {
return (
<Flow.Part part='taskNavigation'>
<TaskNavigationCard />
</Flow.Part>
);
});

export const TaskNavigationCard = () => {
return (
<Flow.Part part='taskNavigation'>
<Card.Root>
<Card.Content>
<LoadingCardContainer />
</Card.Content>
<Card.Footer />
</Card.Root>
</Flow.Part>
);
};
1 change: 1 addition & 0 deletions packages/clerk-js/src/ui/common/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -13,6 +13,7 @@ export * from './QRCode';
export * from './redirects';
export * from './RemoveResourceForm';
export * from './SSOCallback';
export * from './TaskNavigation';
export * from './verification';
export * from './withRedirect';
export * from './Wizard';
3 changes: 3 additions & 0 deletions packages/clerk-js/src/ui/common/tasks.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
export const sessionTaskRoutePaths = ['add-organization'] as const;

export type SessionTaskRoutePath = (typeof sessionTaskRoutePaths)[number];
12 changes: 10 additions & 2 deletions packages/clerk-js/src/ui/common/withRedirect.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -28,8 +28,16 @@ export function withRedirect<P extends AvailableComponentProps>(
const environment = useEnvironment();
const options = useOptions();

const shouldRedirect = condition(clerk, environment, options);
const hasTasksAndSingleSessionMode = clerk.session?.currentTask && environment?.authConfig.singleSessionMode;
const shouldRedirect =
// Overrides default redirect guards to not lead with race conditions on redirection for session tasks
hasTasksAndSingleSessionMode ? false : condition(clerk, environment, options);
React.useEffect(() => {
if (hasTasksAndSingleSessionMode) {
void clerk.maybeNavigateToTaskResolution(navigate);
return;
}

if (shouldRedirect) {
if (warning && isDevelopmentFromPublishableKey(clerk.publishableKey)) {
console.info(warning);
Expand All@@ -38,7 +46,7 @@ export function withRedirect<P extends AvailableComponentProps>(
// eslint-disable-next-line @typescript-eslint/no-floating-promises
navigate(redirectUrl({ clerk, environment, options }));
}
}, []);
}, [hasTasksAndSingleSessionMode]);

if (shouldRedirect) {
return null;
Expand Down
14 changes: 14 additions & 0 deletions packages/clerk-js/src/ui/components/SignIn/SignIn.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,7 @@ import { useClerk } from '@clerk/shared/react';
import type { SignInModalProps, SignInProps } from '@clerk/types';
import React from 'react';

import { sessionTaskRoutePaths } from '../../../ui/common/tasks';
import { normalizeRoutingOptions } from '../../../utils/normalizeRoutingOptions';
import { SignInEmailLinkFlowComplete, SignUpEmailLinkFlowComplete } from '../../common/EmailLinkCompleteFlowCard';
import type { SignUpContextType } from '../../contexts';
Expand All@@ -19,13 +20,15 @@ import { SignUpSSOCallback } from '../SignUp/SignUpSSOCallback';
import { SignUpStart } from '../SignUp/SignUpStart';
import { SignUpVerifyEmail } from '../SignUp/SignUpVerifyEmail';
import { SignUpVerifyPhone } from '../SignUp/SignUpVerifyPhone';
import { Task } from '../Task';
import { ResetPassword } from './ResetPassword';
import { ResetPasswordSuccess } from './ResetPasswordSuccess';
import { SignInAccountSwitcher } from './SignInAccountSwitcher';
import { SignInFactorOne } from './SignInFactorOne';
import { SignInFactorTwo } from './SignInFactorTwo';
import { SignInSSOCallback } from './SignInSSOCallback';
import { SignInStart } from './SignInStart';
import { SignInTaskNavigation } from './SignInTaskNavigation';

function RedirectToSignIn() {
const clerk = useClerk();
Expand DownExpand Up@@ -132,6 +135,17 @@ function SignInRoutes(): JSX.Element {
</Route>
</Route>
)}
<Route path='navigate-to-task'>
<SignInTaskNavigation />
</Route>
{sessionTaskRoutePaths.map(path => (
<Route
key={path}
path={path}
>
<Task />
</Route>
))}
<Route index>
<SignInStart />
</Route>
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
import { TaskNavigation, withRedirectToAfterSignIn } from '../../common';

export const SignInTaskNavigation = withRedirectToAfterSignIn(TaskNavigation);
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
6622e0a
Add `tasks` on `Session` resource
LauraBeatris Feb 14, 2025
7d2d43d
Add changeset
LauraBeatris Feb 14, 2025
d4f072e
Update redirect guard to explict check for `active`
LauraBeatris Feb 17, 2025
e307f3a
Display pending task routes
LauraBeatris Feb 17, 2025
0d0a717
Update handling for array of tasks
LauraBeatris Feb 18, 2025
f44a7ec
Fix session task key
LauraBeatris Feb 18, 2025
43beb76
Implement unit tests for Task component
LauraBeatris Feb 19, 2025
46fe2b8
Add unit tests for `useTaskRoute`
LauraBeatris Feb 19, 2025
9234c79
Add option for a custom tasks URL to cover custom flow
LauraBeatris Feb 20, 2025
d38dbbc
Refactor redirect guards for tasks
LauraBeatris Feb 20, 2025
1dc30d5
Does not trigger `redirectUrl` logic
LauraBeatris Feb 20, 2025
36e0c91
Trigger navigation on client-piggybacking
LauraBeatris Feb 21, 2025
f1e64f1
Set tasks URL on the sign-in/sign-up context
LauraBeatris Feb 24, 2025
9a5415d
Introduce separate redirect guards for tasks
LauraBeatris Feb 24, 2025
1233d3b
Add unit test for redirection to task
LauraBeatris Feb 24, 2025
e15d124
Introduce skeleton for integration tests
LauraBeatris Feb 25, 2025
b9f8870
Introduce new base skeleton for URL resolution
LauraBeatris Feb 25, 2025
22b4a4a
Add `with-session-tasks` to integration tests
LauraBeatris Feb 26, 2025
af7f79a
Implement integration tests
LauraBeatris Feb 27, 2025
8818499
Do not close modals on `Clerk.navigate` if the origin is not outisde …
LauraBeatris Feb 27, 2025
be6c67e
Add intermediary route for task resolution
LauraBeatris Mar 4, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/old-cherries-laugh.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
---
'@clerk/clerk-js': patch
'@clerk/types': patch
---

Navigate to session tasks on after sign-in/sign-up
4 changes: 4 additions & 0 deletions integration/.keys.json.sample
Original file line numberDiff line numberDiff line change
Expand Up@@ -46,5 +46,9 @@
"with-waitlist-mode": {
"pk": "",
"sk": ""
},
"with-session-tasks": {
"pk": "",
"sk": ""
}
}
8 changes: 8 additions & 0 deletions integration/presets/envs.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -42,6 +42,13 @@ const withEmailCodes = base
.setEnvVariable('public', 'CLERK_PUBLISHABLE_KEY', instanceKeys.get('with-email-codes').pk)
.setEnvVariable('private', 'CLERK_ENCRYPTION_KEY', constants.E2E_CLERK_ENCRYPTION_KEY || 'a-key');

const withSessionTasks = base
.clone()
.setId('withSessionTasks')
.setEnvVariable('private', 'CLERK_SECRET_KEY', instanceKeys.get('with-session-tasks').sk)
.setEnvVariable('public', 'CLERK_PUBLISHABLE_KEY', instanceKeys.get('with-session-tasks').pk)
.setEnvVariable('private', 'CLERK_ENCRYPTION_KEY', constants.E2E_CLERK_ENCRYPTION_KEY || 'a-key');

const withEmailCodes_destroy_client = withEmailCodes
.clone()
.setEnvVariable('public', 'EXPERIMENTAL_PERSIST_CLIENT', 'false');
Expand DownExpand Up@@ -157,4 +164,5 @@ export const envs = {
withSignInOrUpFlow,
withSignInOrUpEmailLinksFlow,
withSignInOrUpwithRestrictedModeFlow,
withSessionTasks,
} as const;
5 changes: 5 additions & 0 deletions integration/presets/longRunningApps.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -37,6 +37,11 @@ export const createLongRunningApps = () => {
config: next.appRouter,
env: envs.withSignInOrUpEmailLinksFlow,
},
{
id: 'next.appRouter.withSessionTasks',
config: next.appRouter,
env: envs.withSessionTasks,
},
{ id: 'quickstart.next.appRouter', config: next.appRouterQuickstart, env: envs.withEmailCodesQuickstart },
{ id: 'elements.next.appRouter', config: elements.nextAppRouter, env: envs.withEmailCodes },
{ id: 'astro.node.withCustomRoles', config: astro.node, env: envs.withCustomRoles },
Expand Down
1 change: 1 addition & 0 deletions integration/tests/session-tasks-multi-session.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
// TODO - add tests
72 changes: 72 additions & 0 deletions integration/tests/session-tasks-sign-in.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
import { expect, test } from '@playwright/test';

import type { Application } from '../models/application';
import { appConfigs } from '../presets';
import type { FakeUser } from '../testUtils';
import { createTestUtils } from '../testUtils';

test.describe('session tasks sign in flow @nextjs', () => {
test.describe.configure({ mode: 'serial' });
let app: Application;
let fakeUser: FakeUser;

test.beforeAll(async () => {
app = await appConfigs.next.appRouter.clone().commit();
await app.setup();
await app.withEnv(appConfigs.envs.withSessionTasks);
await app.dev();

const m = createTestUtils({ app });
fakeUser = m.services.users.createFakeUser({
withPhoneNumber: true,
withUsername: true,
});
await m.services.users.createBapiUser(fakeUser);
});

test.afterAll(async () => {
await fakeUser.deleteIfExists();
await app.teardown();
});

test('on after sign-in, navigates to tasks', async ({ page, context }) => {
const u = createTestUtils({ app, page, context });
await u.po.signIn.goTo();
await u.po.signIn.setIdentifier(fakeUser.email);
await u.po.signIn.continue();
await u.po.signIn.setPassword(fakeUser.password);
await u.po.signIn.continue();
await u.po.expect.toBeSignedIn();
await expect(u.page.getByRole('heading', { name: 'Create Organization' })).toBeVisible();
expect(u.page.url()).toContain('/sign-in/add-organization');
});

test.fixme('redirects to after sign-in url when session tasks has been resolved', () => {
// todo
});

test.fixme('redirects to after sign-in url when accessing root sign in with a active session', {
// todo
});

test('redirects back to tasks when accessing root sign in', async ({ page, context }) => {
const u = createTestUtils({ app, page, context });
await u.po.signIn.goTo();
await u.po.signIn.setIdentifier(fakeUser.email);
await u.po.signIn.continue();
await u.po.signIn.setPassword(fakeUser.password);
await u.po.signIn.continue();
await u.po.expect.toBeSignedIn();
await expect(u.page.getByRole('heading', { name: 'Create Organization' })).toBeVisible();
expect(u.page.url()).toContain('/sign-in/add-organization');
await u.po.signIn.goTo();
await expect(u.page.getByRole('heading', { name: 'Create Organization' })).toBeVisible();
expect(u.page.url()).toContain('/sign-in/add-organization');
});

test('without a session, does not allow to access tasks', async ({ page, context }) => {
const u = createTestUtils({ app, page, context });
await u.page.goto('/sign-in/add-organization');
expect(u.page.url()).not.toContain('/sign-in/add-organization');
});
});
73 changes: 73 additions & 0 deletions integration/tests/session-tasks-sign-up.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
import { expect, test } from '@playwright/test';

import type { Application } from '../models/application';
import { appConfigs } from '../presets';
import { createTestUtils } from '../testUtils';

test.describe('session tasks sign in flow @nextjs', () => {
test.describe.configure({ mode: 'serial' });
let app: Application;

test.beforeAll(async () => {
app = await appConfigs.next.appRouter.clone().commit();
await app.setup();
await app.withEnv(appConfigs.envs.withSessionTasks);
await app.dev();
});

test.afterAll(async () => {
await app.teardown();
});

test('on after sign-up, navigates to tasks', async ({ page, context }) => {
const u = createTestUtils({ app, page, context });
const fakeUser = u.services.users.createFakeUser({
fictionalEmail: true,
});
await u.po.signUp.goTo();
await u.po.signUp.signUpWithEmailAndPassword({
email: fakeUser.email,
password: fakeUser.password,
});
await u.po.signUp.enterTestOtpCode();
await u.po.expect.toBeSignedIn();

await expect(u.page.getByRole('heading', { name: 'Create Organization' })).toBeVisible();
expect(u.page.url()).toContain('/sign-up/add-organization');

await fakeUser.deleteIfExists();
});

test.fixme('redirects to after sign-up url when session tasks has been resolved', () => {
// todo
});

test.fixme('redirects to after sign-up url when accessing root sign in with a active session', {
// todo
});

test('redirects back to tasks when accessing root sign in', async ({ page, context }) => {
const u = createTestUtils({ app, page, context });
const fakeUser = u.services.users.createFakeUser({
fictionalEmail: true,
});
await u.po.signUp.goTo();
await u.po.signUp.signUpWithEmailAndPassword({
email: fakeUser.email,
password: fakeUser.password,
});
await u.po.signUp.enterTestOtpCode();
await u.po.expect.toBeSignedIn();
await expect(u.page.getByRole('heading', { name: 'Create Organization' })).toBeVisible();
expect(u.page.url()).toContain('/sign-up/add-organization');
await u.po.signIn.goTo();
await expect(u.page.getByRole('heading', { name: 'Create Organization' })).toBeVisible();
expect(u.page.url()).toContain('/sign-up/add-organization');
});

test('without a session, does not allow to access tasks', async ({ page, context }) => {
const u = createTestUtils({ app, page, context });
await u.page.goto('/sign-up/add-organization');
expect(u.page.url()).not.toContain('/sign-up/add-organization');
});
});
39 changes: 38 additions & 1 deletion packages/clerk-js/src/core/clerk.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,6 +23,7 @@ import type {
CreateOrganizationParams,
CreateOrganizationProps,
CredentialReturn,
CustomNavigation,
DomainOrProxyUrl,
EnvironmentJSON,
EnvironmentJSONSnapshot,
Expand All@@ -45,6 +46,7 @@ import type {
RedirectOptions,
Resources,
SDKMetadata,
SessionTask,
SetActiveParams,
SignedInSessionResource,
SignInProps,
Expand All@@ -65,6 +67,7 @@ import type {
WaitlistResource,
Web3Provider,
} from '@clerk/types';
import type { SessionTaskRoutePath } from 'ui/common/tasks';

import type { MountComponentRenderer } from '../ui/Components';
import {
Expand DownExpand Up@@ -946,7 +949,10 @@ export class Clerk implements ClerkInterface {
beforeUnloadTracker?.stopTracking();
}

if (redirectUrl && !beforeEmit) {
// Overrides the default behavior of redirects to `afterSignInUrl`
// or `afterSignUpUrl` to redirect the user to their assigned tasks
const hasSessionToResolve = newSession?.currentTask;
if (redirectUrl && !beforeEmit && !hasSessionToResolve) {
beforeUnloadTracker?.startTracking();
this.#setTransitiveState();

Expand DownExpand Up@@ -1728,6 +1734,8 @@ export class Clerk implements ClerkInterface {
if (this.session) {
const session = this.#getSessionFromClient(this.session.id);

this.maybeNavigateToTaskResolution(this.navigate);

// Note: this might set this.session to null
this.#setAccessors(session);

Expand DownExpand Up@@ -2260,4 +2268,33 @@ export class Clerk implements ClerkInterface {

return allowedProtocols;
}

maybeNavigateToTaskResolution(customNavigate?: (to: string) => Promise<unknown>) {
if (!this.session?.currentTask || !inBrowser()) {
return;
}

const isOnTaskResolutionPath = window.location.href.includes('navigate-to-task');
if (isOnTaskResolutionPath) {
return;
}

const url = buildURL({ base: `${this.#options.signInUrl}/navigate-to-task` }, { stringify: true });

void customNavigate?.(url);
}

navigateToTaskPath(customNavigate?: CustomNavigation) {
if (!this.session?.currentTask || !inBrowser()) {
return;
}

const taskKeyToRoutePaths: Record<SessionTask['key'], SessionTaskRoutePath> = {
org: 'add-organization',
};

const routePath = taskKeyToRoutePaths[this.session.currentTask.key];

void customNavigate?.(routePath);
}
}
4 changes: 4 additions & 0 deletions packages/clerk-js/src/core/resources/Session.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -303,4 +303,8 @@ export class Session extends BaseResource implements SessionResource {
return token.getRawString() || null;
});
}

get currentTask(): SessionTask | undefined {
return (this.tasks ?? [])[0];
}
}
23 changes: 23 additions & 0 deletions packages/clerk-js/src/ui/common/TaskNavigation.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
import { Flow } from '../customizables';
import { Card, LoadingCardContainer, withCardStateProvider } from '../elements';

export const TaskNavigation = withCardStateProvider(() => {
return (
<Flow.Part part='taskNavigation'>
<TaskNavigationCard />
</Flow.Part>
);
});

export const TaskNavigationCard = () => {
return (
<Flow.Part part='taskNavigation'>
<Card.Root>
<Card.Content>
<LoadingCardContainer />
</Card.Content>
<Card.Footer />
</Card.Root>
</Flow.Part>
);
};
1 change: 1 addition & 0 deletions packages/clerk-js/src/ui/common/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -13,6 +13,7 @@ export * from './QRCode';
export * from './redirects';
export * from './RemoveResourceForm';
export * from './SSOCallback';
export * from './TaskNavigation';
export * from './verification';
export * from './withRedirect';
export * from './Wizard';
3 changes: 3 additions & 0 deletions packages/clerk-js/src/ui/common/tasks.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
export const sessionTaskRoutePaths = ['add-organization'] as const;

export type SessionTaskRoutePath = (typeof sessionTaskRoutePaths)[number];
12 changes: 10 additions & 2 deletions packages/clerk-js/src/ui/common/withRedirect.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -28,8 +28,16 @@ export function withRedirect<P extends AvailableComponentProps>(
const environment = useEnvironment();
const options = useOptions();

const shouldRedirect = condition(clerk, environment, options);
const hasTasksAndSingleSessionMode = clerk.session?.currentTask && environment?.authConfig.singleSessionMode;
const shouldRedirect =
// Overrides default redirect guards to not lead with race conditions on redirection for session tasks
hasTasksAndSingleSessionMode ? false : condition(clerk, environment, options);
React.useEffect(() => {
if (hasTasksAndSingleSessionMode) {
void clerk.maybeNavigateToTaskResolution(navigate);
return;
}

if (shouldRedirect) {
if (warning && isDevelopmentFromPublishableKey(clerk.publishableKey)) {
console.info(warning);
Expand All@@ -38,7 +46,7 @@ export function withRedirect<P extends AvailableComponentProps>(
// eslint-disable-next-line @typescript-eslint/no-floating-promises
navigate(redirectUrl({ clerk, environment, options }));
}
}, []);
}, [hasTasksAndSingleSessionMode]);

if (shouldRedirect) {
return null;
Expand Down
14 changes: 14 additions & 0 deletions packages/clerk-js/src/ui/components/SignIn/SignIn.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,7 @@ import { useClerk } from '@clerk/shared/react';
import type { SignInModalProps, SignInProps } from '@clerk/types';
import React from 'react';

import { sessionTaskRoutePaths } from '../../../ui/common/tasks';
import { normalizeRoutingOptions } from '../../../utils/normalizeRoutingOptions';
import { SignInEmailLinkFlowComplete, SignUpEmailLinkFlowComplete } from '../../common/EmailLinkCompleteFlowCard';
import type { SignUpContextType } from '../../contexts';
Expand All@@ -19,13 +20,15 @@ import { SignUpSSOCallback } from '../SignUp/SignUpSSOCallback';
import { SignUpStart } from '../SignUp/SignUpStart';
import { SignUpVerifyEmail } from '../SignUp/SignUpVerifyEmail';
import { SignUpVerifyPhone } from '../SignUp/SignUpVerifyPhone';
import { Task } from '../Task';
import { ResetPassword } from './ResetPassword';
import { ResetPasswordSuccess } from './ResetPasswordSuccess';
import { SignInAccountSwitcher } from './SignInAccountSwitcher';
import { SignInFactorOne } from './SignInFactorOne';
import { SignInFactorTwo } from './SignInFactorTwo';
import { SignInSSOCallback } from './SignInSSOCallback';
import { SignInStart } from './SignInStart';
import { SignInTaskNavigation } from './SignInTaskNavigation';

function RedirectToSignIn() {
const clerk = useClerk();
Expand DownExpand Up@@ -132,6 +135,17 @@ function SignInRoutes(): JSX.Element {
</Route>
</Route>
)}
<Route path='navigate-to-task'>
<SignInTaskNavigation />
</Route>
{sessionTaskRoutePaths.map(path => (
<Route
key={path}
path={path}
>
<Task />
</Route>
))}
<Route index>
<SignInStart />
</Route>
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
import { TaskNavigation, withRedirectToAfterSignIn } from '../../common';

export const SignInTaskNavigation = withRedirectToAfterSignIn(TaskNavigation);
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
6622e0a
Add `tasks` on `Session` resource
LauraBeatris Feb 14, 2025
7d2d43d
Add changeset
LauraBeatris Feb 14, 2025
d4f072e
Update redirect guard to explict check for `active`
LauraBeatris Feb 17, 2025
e307f3a
Display pending task routes
LauraBeatris Feb 17, 2025
0d0a717
Update handling for array of tasks
LauraBeatris Feb 18, 2025
f44a7ec
Fix session task key
LauraBeatris Feb 18, 2025
43beb76
Implement unit tests for Task component
LauraBeatris Feb 19, 2025
46fe2b8
Add unit tests for `useTaskRoute`
LauraBeatris Feb 19, 2025
9234c79
Add option for a custom tasks URL to cover custom flow
LauraBeatris Feb 20, 2025
d38dbbc
Refactor redirect guards for tasks
LauraBeatris Feb 20, 2025
1dc30d5
Does not trigger `redirectUrl` logic
LauraBeatris Feb 20, 2025
36e0c91
Trigger navigation on client-piggybacking
LauraBeatris Feb 21, 2025
f1e64f1
Set tasks URL on the sign-in/sign-up context
LauraBeatris Feb 24, 2025
9a5415d
Introduce separate redirect guards for tasks
LauraBeatris Feb 24, 2025
1233d3b
Add unit test for redirection to task
LauraBeatris Feb 24, 2025
e15d124
Introduce skeleton for integration tests
LauraBeatris Feb 25, 2025
b9f8870
Introduce new base skeleton for URL resolution
LauraBeatris Feb 25, 2025
22b4a4a
Add `with-session-tasks` to integration tests
LauraBeatris Feb 26, 2025
af7f79a
Implement integration tests
LauraBeatris Feb 27, 2025
8818499
Do not close modals on `Clerk.navigate` if the origin is not outisde …
LauraBeatris Feb 27, 2025
be6c67e
Add intermediary route for task resolution
LauraBeatris Mar 4, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/old-cherries-laugh.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
---
'@clerk/clerk-js': patch
'@clerk/types': patch
---

Navigate to session tasks on after sign-in/sign-up
4 changes: 4 additions & 0 deletions integration/.keys.json.sample
Original file line numberDiff line numberDiff line change
Expand Up@@ -46,5 +46,9 @@
"with-waitlist-mode": {
"pk": "",
"sk": ""
},
"with-session-tasks": {
"pk": "",
"sk": ""
}
}
8 changes: 8 additions & 0 deletions integration/presets/envs.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -42,6 +42,13 @@ const withEmailCodes = base
.setEnvVariable('public', 'CLERK_PUBLISHABLE_KEY', instanceKeys.get('with-email-codes').pk)
.setEnvVariable('private', 'CLERK_ENCRYPTION_KEY', constants.E2E_CLERK_ENCRYPTION_KEY || 'a-key');

const withSessionTasks = base
.clone()
.setId('withSessionTasks')
.setEnvVariable('private', 'CLERK_SECRET_KEY', instanceKeys.get('with-session-tasks').sk)
.setEnvVariable('public', 'CLERK_PUBLISHABLE_KEY', instanceKeys.get('with-session-tasks').pk)
.setEnvVariable('private', 'CLERK_ENCRYPTION_KEY', constants.E2E_CLERK_ENCRYPTION_KEY || 'a-key');

const withEmailCodes_destroy_client = withEmailCodes
.clone()
.setEnvVariable('public', 'EXPERIMENTAL_PERSIST_CLIENT', 'false');
Expand DownExpand Up@@ -157,4 +164,5 @@ export const envs = {
withSignInOrUpFlow,
withSignInOrUpEmailLinksFlow,
withSignInOrUpwithRestrictedModeFlow,
withSessionTasks,
} as const;
5 changes: 5 additions & 0 deletions integration/presets/longRunningApps.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -37,6 +37,11 @@ export const createLongRunningApps = () => {
config: next.appRouter,
env: envs.withSignInOrUpEmailLinksFlow,
},
{
id: 'next.appRouter.withSessionTasks',
config: next.appRouter,
env: envs.withSessionTasks,
},
{ id: 'quickstart.next.appRouter', config: next.appRouterQuickstart, env: envs.withEmailCodesQuickstart },
{ id: 'elements.next.appRouter', config: elements.nextAppRouter, env: envs.withEmailCodes },
{ id: 'astro.node.withCustomRoles', config: astro.node, env: envs.withCustomRoles },
Expand Down
1 change: 1 addition & 0 deletions integration/tests/session-tasks-multi-session.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
// TODO - add tests
72 changes: 72 additions & 0 deletions integration/tests/session-tasks-sign-in.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
import { expect, test } from '@playwright/test';

import type { Application } from '../models/application';
import { appConfigs } from '../presets';
import type { FakeUser } from '../testUtils';
import { createTestUtils } from '../testUtils';

test.describe('session tasks sign in flow @nextjs', () => {
test.describe.configure({ mode: 'serial' });
let app: Application;
let fakeUser: FakeUser;

test.beforeAll(async () => {
app = await appConfigs.next.appRouter.clone().commit();
await app.setup();
await app.withEnv(appConfigs.envs.withSessionTasks);
await app.dev();

const m = createTestUtils({ app });
fakeUser = m.services.users.createFakeUser({
withPhoneNumber: true,
withUsername: true,
});
await m.services.users.createBapiUser(fakeUser);
});

test.afterAll(async () => {
await fakeUser.deleteIfExists();
await app.teardown();
});

test('on after sign-in, navigates to tasks', async ({ page, context }) => {
const u = createTestUtils({ app, page, context });
await u.po.signIn.goTo();
await u.po.signIn.setIdentifier(fakeUser.email);
await u.po.signIn.continue();
await u.po.signIn.setPassword(fakeUser.password);
await u.po.signIn.continue();
await u.po.expect.toBeSignedIn();
await expect(u.page.getByRole('heading', { name: 'Create Organization' })).toBeVisible();
expect(u.page.url()).toContain('/sign-in/add-organization');
});

test.fixme('redirects to after sign-in url when session tasks has been resolved', () => {
// todo
});

test.fixme('redirects to after sign-in url when accessing root sign in with a active session', {
// todo
});

test('redirects back to tasks when accessing root sign in', async ({ page, context }) => {
const u = createTestUtils({ app, page, context });
await u.po.signIn.goTo();
await u.po.signIn.setIdentifier(fakeUser.email);
await u.po.signIn.continue();
await u.po.signIn.setPassword(fakeUser.password);
await u.po.signIn.continue();
await u.po.expect.toBeSignedIn();
await expect(u.page.getByRole('heading', { name: 'Create Organization' })).toBeVisible();
expect(u.page.url()).toContain('/sign-in/add-organization');
await u.po.signIn.goTo();
await expect(u.page.getByRole('heading', { name: 'Create Organization' })).toBeVisible();
expect(u.page.url()).toContain('/sign-in/add-organization');
});

test('without a session, does not allow to access tasks', async ({ page, context }) => {
const u = createTestUtils({ app, page, context });
await u.page.goto('/sign-in/add-organization');
expect(u.page.url()).not.toContain('/sign-in/add-organization');
});
});
73 changes: 73 additions & 0 deletions integration/tests/session-tasks-sign-up.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
import { expect, test } from '@playwright/test';

import type { Application } from '../models/application';
import { appConfigs } from '../presets';
import { createTestUtils } from '../testUtils';

test.describe('session tasks sign in flow @nextjs', () => {
test.describe.configure({ mode: 'serial' });
let app: Application;

test.beforeAll(async () => {
app = await appConfigs.next.appRouter.clone().commit();
await app.setup();
await app.withEnv(appConfigs.envs.withSessionTasks);
await app.dev();
});

test.afterAll(async () => {
await app.teardown();
});

test('on after sign-up, navigates to tasks', async ({ page, context }) => {
const u = createTestUtils({ app, page, context });
const fakeUser = u.services.users.createFakeUser({
fictionalEmail: true,
});
await u.po.signUp.goTo();
await u.po.signUp.signUpWithEmailAndPassword({
email: fakeUser.email,
password: fakeUser.password,
});
await u.po.signUp.enterTestOtpCode();
await u.po.expect.toBeSignedIn();

await expect(u.page.getByRole('heading', { name: 'Create Organization' })).toBeVisible();
expect(u.page.url()).toContain('/sign-up/add-organization');

await fakeUser.deleteIfExists();
});

test.fixme('redirects to after sign-up url when session tasks has been resolved', () => {
// todo
});

test.fixme('redirects to after sign-up url when accessing root sign in with a active session', {
// todo
});

test('redirects back to tasks when accessing root sign in', async ({ page, context }) => {
const u = createTestUtils({ app, page, context });
const fakeUser = u.services.users.createFakeUser({
fictionalEmail: true,
});
await u.po.signUp.goTo();
await u.po.signUp.signUpWithEmailAndPassword({
email: fakeUser.email,
password: fakeUser.password,
});
await u.po.signUp.enterTestOtpCode();
await u.po.expect.toBeSignedIn();
await expect(u.page.getByRole('heading', { name: 'Create Organization' })).toBeVisible();
expect(u.page.url()).toContain('/sign-up/add-organization');
await u.po.signIn.goTo();
await expect(u.page.getByRole('heading', { name: 'Create Organization' })).toBeVisible();
expect(u.page.url()).toContain('/sign-up/add-organization');
});

test('without a session, does not allow to access tasks', async ({ page, context }) => {
const u = createTestUtils({ app, page, context });
await u.page.goto('/sign-up/add-organization');
expect(u.page.url()).not.toContain('/sign-up/add-organization');
});
});
39 changes: 38 additions & 1 deletion packages/clerk-js/src/core/clerk.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,6 +23,7 @@ import type {
CreateOrganizationParams,
CreateOrganizationProps,
CredentialReturn,
CustomNavigation,
DomainOrProxyUrl,
EnvironmentJSON,
EnvironmentJSONSnapshot,
Expand All@@ -45,6 +46,7 @@ import type {
RedirectOptions,
Resources,
SDKMetadata,
SessionTask,
SetActiveParams,
SignedInSessionResource,
SignInProps,
Expand All@@ -65,6 +67,7 @@ import type {
WaitlistResource,
Web3Provider,
} from '@clerk/types';
import type { SessionTaskRoutePath } from 'ui/common/tasks';

import type { MountComponentRenderer } from '../ui/Components';
import {
Expand DownExpand Up@@ -946,7 +949,10 @@ export class Clerk implements ClerkInterface {
beforeUnloadTracker?.stopTracking();
}

if (redirectUrl && !beforeEmit) {
// Overrides the default behavior of redirects to `afterSignInUrl`
// or `afterSignUpUrl` to redirect the user to their assigned tasks
const hasSessionToResolve = newSession?.currentTask;
if (redirectUrl && !beforeEmit && !hasSessionToResolve) {
beforeUnloadTracker?.startTracking();
this.#setTransitiveState();

Expand DownExpand Up@@ -1728,6 +1734,8 @@ export class Clerk implements ClerkInterface {
if (this.session) {
const session = this.#getSessionFromClient(this.session.id);

this.maybeNavigateToTaskResolution(this.navigate);

// Note: this might set this.session to null
this.#setAccessors(session);

Expand DownExpand Up@@ -2260,4 +2268,33 @@ export class Clerk implements ClerkInterface {

return allowedProtocols;
}

maybeNavigateToTaskResolution(customNavigate?: (to: string) => Promise<unknown>) {
if (!this.session?.currentTask || !inBrowser()) {
return;
}

const isOnTaskResolutionPath = window.location.href.includes('navigate-to-task');
if (isOnTaskResolutionPath) {
return;
}

const url = buildURL({ base: `${this.#options.signInUrl}/navigate-to-task` }, { stringify: true });

void customNavigate?.(url);
}

navigateToTaskPath(customNavigate?: CustomNavigation) {
if (!this.session?.currentTask || !inBrowser()) {
return;
}

const taskKeyToRoutePaths: Record<SessionTask['key'], SessionTaskRoutePath> = {
org: 'add-organization',
};

const routePath = taskKeyToRoutePaths[this.session.currentTask.key];

void customNavigate?.(routePath);
}
}
4 changes: 4 additions & 0 deletions packages/clerk-js/src/core/resources/Session.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -303,4 +303,8 @@ export class Session extends BaseResource implements SessionResource {
return token.getRawString() || null;
});
}

get currentTask(): SessionTask | undefined {
return (this.tasks ?? [])[0];
}
}
23 changes: 23 additions & 0 deletions packages/clerk-js/src/ui/common/TaskNavigation.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
import { Flow } from '../customizables';
import { Card, LoadingCardContainer, withCardStateProvider } from '../elements';

export const TaskNavigation = withCardStateProvider(() => {
return (
<Flow.Part part='taskNavigation'>
<TaskNavigationCard />
</Flow.Part>
);
});

export const TaskNavigationCard = () => {
return (
<Flow.Part part='taskNavigation'>
<Card.Root>
<Card.Content>
<LoadingCardContainer />
</Card.Content>
<Card.Footer />
</Card.Root>
</Flow.Part>
);
};
1 change: 1 addition & 0 deletions packages/clerk-js/src/ui/common/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -13,6 +13,7 @@ export * from './QRCode';
export * from './redirects';
export * from './RemoveResourceForm';
export * from './SSOCallback';
export * from './TaskNavigation';
export * from './verification';
export * from './withRedirect';
export * from './Wizard';
3 changes: 3 additions & 0 deletions packages/clerk-js/src/ui/common/tasks.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
export const sessionTaskRoutePaths = ['add-organization'] as const;

export type SessionTaskRoutePath = (typeof sessionTaskRoutePaths)[number];
12 changes: 10 additions & 2 deletions packages/clerk-js/src/ui/common/withRedirect.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -28,8 +28,16 @@ export function withRedirect<P extends AvailableComponentProps>(
const environment = useEnvironment();
const options = useOptions();

const shouldRedirect = condition(clerk, environment, options);
const hasTasksAndSingleSessionMode = clerk.session?.currentTask && environment?.authConfig.singleSessionMode;
const shouldRedirect =
// Overrides default redirect guards to not lead with race conditions on redirection for session tasks
hasTasksAndSingleSessionMode ? false : condition(clerk, environment, options);
React.useEffect(() => {
if (hasTasksAndSingleSessionMode) {
void clerk.maybeNavigateToTaskResolution(navigate);
return;
}

if (shouldRedirect) {
if (warning && isDevelopmentFromPublishableKey(clerk.publishableKey)) {
console.info(warning);
Expand All@@ -38,7 +46,7 @@ export function withRedirect<P extends AvailableComponentProps>(
// eslint-disable-next-line @typescript-eslint/no-floating-promises
navigate(redirectUrl({ clerk, environment, options }));
}
}, []);
}, [hasTasksAndSingleSessionMode]);

if (shouldRedirect) {
return null;
Expand Down
14 changes: 14 additions & 0 deletions packages/clerk-js/src/ui/components/SignIn/SignIn.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,7 @@ import { useClerk } from '@clerk/shared/react';
import type { SignInModalProps, SignInProps } from '@clerk/types';
import React from 'react';

import { sessionTaskRoutePaths } from '../../../ui/common/tasks';
import { normalizeRoutingOptions } from '../../../utils/normalizeRoutingOptions';
import { SignInEmailLinkFlowComplete, SignUpEmailLinkFlowComplete } from '../../common/EmailLinkCompleteFlowCard';
import type { SignUpContextType } from '../../contexts';
Expand All@@ -19,13 +20,15 @@ import { SignUpSSOCallback } from '../SignUp/SignUpSSOCallback';
import { SignUpStart } from '../SignUp/SignUpStart';
import { SignUpVerifyEmail } from '../SignUp/SignUpVerifyEmail';
import { SignUpVerifyPhone } from '../SignUp/SignUpVerifyPhone';
import { Task } from '../Task';
import { ResetPassword } from './ResetPassword';
import { ResetPasswordSuccess } from './ResetPasswordSuccess';
import { SignInAccountSwitcher } from './SignInAccountSwitcher';
import { SignInFactorOne } from './SignInFactorOne';
import { SignInFactorTwo } from './SignInFactorTwo';
import { SignInSSOCallback } from './SignInSSOCallback';
import { SignInStart } from './SignInStart';
import { SignInTaskNavigation } from './SignInTaskNavigation';

function RedirectToSignIn() {
const clerk = useClerk();
Expand DownExpand Up@@ -132,6 +135,17 @@ function SignInRoutes(): JSX.Element {
</Route>
</Route>
)}
<Route path='navigate-to-task'>
<SignInTaskNavigation />
</Route>
{sessionTaskRoutePaths.map(path => (
<Route
key={path}
path={path}
>
<Task />
</Route>
))}
<Route index>
<SignInStart />
</Route>
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
import { TaskNavigation, withRedirectToAfterSignIn } from '../../common';

export const SignInTaskNavigation = withRedirectToAfterSignIn(TaskNavigation);
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
6622e0a
Add `tasks` on `Session` resource
LauraBeatris Feb 14, 2025
7d2d43d
Add changeset
LauraBeatris Feb 14, 2025
d4f072e
Update redirect guard to explict check for `active`
LauraBeatris Feb 17, 2025
e307f3a
Display pending task routes
LauraBeatris Feb 17, 2025
0d0a717
Update handling for array of tasks
LauraBeatris Feb 18, 2025
f44a7ec
Fix session task key
LauraBeatris Feb 18, 2025
43beb76
Implement unit tests for Task component
LauraBeatris Feb 19, 2025
46fe2b8
Add unit tests for `useTaskRoute`
LauraBeatris Feb 19, 2025
9234c79
Add option for a custom tasks URL to cover custom flow
LauraBeatris Feb 20, 2025
d38dbbc
Refactor redirect guards for tasks
LauraBeatris Feb 20, 2025
1dc30d5
Does not trigger `redirectUrl` logic
LauraBeatris Feb 20, 2025
36e0c91
Trigger navigation on client-piggybacking
LauraBeatris Feb 21, 2025
f1e64f1
Set tasks URL on the sign-in/sign-up context
LauraBeatris Feb 24, 2025
9a5415d
Introduce separate redirect guards for tasks
LauraBeatris Feb 24, 2025
1233d3b
Add unit test for redirection to task
LauraBeatris Feb 24, 2025
e15d124
Introduce skeleton for integration tests
LauraBeatris Feb 25, 2025
b9f8870
Introduce new base skeleton for URL resolution
LauraBeatris Feb 25, 2025
22b4a4a
Add `with-session-tasks` to integration tests
LauraBeatris Feb 26, 2025
af7f79a
Implement integration tests
LauraBeatris Feb 27, 2025
8818499
Do not close modals on `Clerk.navigate` if the origin is not outisde …
LauraBeatris Feb 27, 2025
be6c67e
Add intermediary route for task resolution
LauraBeatris Mar 4, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/old-cherries-laugh.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
---
'@clerk/clerk-js': patch
'@clerk/types': patch
---

Navigate to session tasks on after sign-in/sign-up
4 changes: 4 additions & 0 deletions integration/.keys.json.sample
Original file line numberDiff line numberDiff line change
Expand Up@@ -46,5 +46,9 @@
"with-waitlist-mode": {
"pk": "",
"sk": ""
},
"with-session-tasks": {
"pk": "",
"sk": ""
}
}
8 changes: 8 additions & 0 deletions integration/presets/envs.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -42,6 +42,13 @@ const withEmailCodes = base
.setEnvVariable('public', 'CLERK_PUBLISHABLE_KEY', instanceKeys.get('with-email-codes').pk)
.setEnvVariable('private', 'CLERK_ENCRYPTION_KEY', constants.E2E_CLERK_ENCRYPTION_KEY || 'a-key');

const withSessionTasks = base
.clone()
.setId('withSessionTasks')
.setEnvVariable('private', 'CLERK_SECRET_KEY', instanceKeys.get('with-session-tasks').sk)
.setEnvVariable('public', 'CLERK_PUBLISHABLE_KEY', instanceKeys.get('with-session-tasks').pk)
.setEnvVariable('private', 'CLERK_ENCRYPTION_KEY', constants.E2E_CLERK_ENCRYPTION_KEY || 'a-key');

const withEmailCodes_destroy_client = withEmailCodes
.clone()
.setEnvVariable('public', 'EXPERIMENTAL_PERSIST_CLIENT', 'false');
Expand DownExpand Up@@ -157,4 +164,5 @@ export const envs = {
withSignInOrUpFlow,
withSignInOrUpEmailLinksFlow,
withSignInOrUpwithRestrictedModeFlow,
withSessionTasks,
} as const;
5 changes: 5 additions & 0 deletions integration/presets/longRunningApps.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -37,6 +37,11 @@ export const createLongRunningApps = () => {
config: next.appRouter,
env: envs.withSignInOrUpEmailLinksFlow,
},
{
id: 'next.appRouter.withSessionTasks',
config: next.appRouter,
env: envs.withSessionTasks,
},
{ id: 'quickstart.next.appRouter', config: next.appRouterQuickstart, env: envs.withEmailCodesQuickstart },
{ id: 'elements.next.appRouter', config: elements.nextAppRouter, env: envs.withEmailCodes },
{ id: 'astro.node.withCustomRoles', config: astro.node, env: envs.withCustomRoles },
Expand Down
1 change: 1 addition & 0 deletions integration/tests/session-tasks-multi-session.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
// TODO - add tests
72 changes: 72 additions & 0 deletions integration/tests/session-tasks-sign-in.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
import { expect, test } from '@playwright/test';

import type { Application } from '../models/application';
import { appConfigs } from '../presets';
import type { FakeUser } from '../testUtils';
import { createTestUtils } from '../testUtils';

test.describe('session tasks sign in flow @nextjs', () => {
test.describe.configure({ mode: 'serial' });
let app: Application;
let fakeUser: FakeUser;

test.beforeAll(async () => {
app = await appConfigs.next.appRouter.clone().commit();
await app.setup();
await app.withEnv(appConfigs.envs.withSessionTasks);
await app.dev();

const m = createTestUtils({ app });
fakeUser = m.services.users.createFakeUser({
withPhoneNumber: true,
withUsername: true,
});
await m.services.users.createBapiUser(fakeUser);
});

test.afterAll(async () => {
await fakeUser.deleteIfExists();
await app.teardown();
});

test('on after sign-in, navigates to tasks', async ({ page, context }) => {
const u = createTestUtils({ app, page, context });
await u.po.signIn.goTo();
await u.po.signIn.setIdentifier(fakeUser.email);
await u.po.signIn.continue();
await u.po.signIn.setPassword(fakeUser.password);
await u.po.signIn.continue();
await u.po.expect.toBeSignedIn();
await expect(u.page.getByRole('heading', { name: 'Create Organization' })).toBeVisible();
expect(u.page.url()).toContain('/sign-in/add-organization');
});

test.fixme('redirects to after sign-in url when session tasks has been resolved', () => {
// todo
});

test.fixme('redirects to after sign-in url when accessing root sign in with a active session', {
// todo
});

test('redirects back to tasks when accessing root sign in', async ({ page, context }) => {
const u = createTestUtils({ app, page, context });
await u.po.signIn.goTo();
await u.po.signIn.setIdentifier(fakeUser.email);
await u.po.signIn.continue();
await u.po.signIn.setPassword(fakeUser.password);
await u.po.signIn.continue();
await u.po.expect.toBeSignedIn();
await expect(u.page.getByRole('heading', { name: 'Create Organization' })).toBeVisible();
expect(u.page.url()).toContain('/sign-in/add-organization');
await u.po.signIn.goTo();
await expect(u.page.getByRole('heading', { name: 'Create Organization' })).toBeVisible();
expect(u.page.url()).toContain('/sign-in/add-organization');
});

test('without a session, does not allow to access tasks', async ({ page, context }) => {
const u = createTestUtils({ app, page, context });
await u.page.goto('/sign-in/add-organization');
expect(u.page.url()).not.toContain('/sign-in/add-organization');
});
});
73 changes: 73 additions & 0 deletions integration/tests/session-tasks-sign-up.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
import { expect, test } from '@playwright/test';

import type { Application } from '../models/application';
import { appConfigs } from '../presets';
import { createTestUtils } from '../testUtils';

test.describe('session tasks sign in flow @nextjs', () => {
test.describe.configure({ mode: 'serial' });
let app: Application;

test.beforeAll(async () => {
app = await appConfigs.next.appRouter.clone().commit();
await app.setup();
await app.withEnv(appConfigs.envs.withSessionTasks);
await app.dev();
});

test.afterAll(async () => {
await app.teardown();
});

test('on after sign-up, navigates to tasks', async ({ page, context }) => {
const u = createTestUtils({ app, page, context });
const fakeUser = u.services.users.createFakeUser({
fictionalEmail: true,
});
await u.po.signUp.goTo();
await u.po.signUp.signUpWithEmailAndPassword({
email: fakeUser.email,
password: fakeUser.password,
});
await u.po.signUp.enterTestOtpCode();
await u.po.expect.toBeSignedIn();

await expect(u.page.getByRole('heading', { name: 'Create Organization' })).toBeVisible();
expect(u.page.url()).toContain('/sign-up/add-organization');

await fakeUser.deleteIfExists();
});

test.fixme('redirects to after sign-up url when session tasks has been resolved', () => {
// todo
});

test.fixme('redirects to after sign-up url when accessing root sign in with a active session', {
// todo
});

test('redirects back to tasks when accessing root sign in', async ({ page, context }) => {
const u = createTestUtils({ app, page, context });
const fakeUser = u.services.users.createFakeUser({
fictionalEmail: true,
});
await u.po.signUp.goTo();
await u.po.signUp.signUpWithEmailAndPassword({
email: fakeUser.email,
password: fakeUser.password,
});
await u.po.signUp.enterTestOtpCode();
await u.po.expect.toBeSignedIn();
await expect(u.page.getByRole('heading', { name: 'Create Organization' })).toBeVisible();
expect(u.page.url()).toContain('/sign-up/add-organization');
await u.po.signIn.goTo();
await expect(u.page.getByRole('heading', { name: 'Create Organization' })).toBeVisible();
expect(u.page.url()).toContain('/sign-up/add-organization');
});

test('without a session, does not allow to access tasks', async ({ page, context }) => {
const u = createTestUtils({ app, page, context });
await u.page.goto('/sign-up/add-organization');
expect(u.page.url()).not.toContain('/sign-up/add-organization');
});
});
39 changes: 38 additions & 1 deletion packages/clerk-js/src/core/clerk.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,6 +23,7 @@ import type {
CreateOrganizationParams,
CreateOrganizationProps,
CredentialReturn,
CustomNavigation,
DomainOrProxyUrl,
EnvironmentJSON,
EnvironmentJSONSnapshot,
Expand All@@ -45,6 +46,7 @@ import type {
RedirectOptions,
Resources,
SDKMetadata,
SessionTask,
SetActiveParams,
SignedInSessionResource,
SignInProps,
Expand All@@ -65,6 +67,7 @@ import type {
WaitlistResource,
Web3Provider,
} from '@clerk/types';
import type { SessionTaskRoutePath } from 'ui/common/tasks';

import type { MountComponentRenderer } from '../ui/Components';
import {
Expand DownExpand Up@@ -946,7 +949,10 @@ export class Clerk implements ClerkInterface {
beforeUnloadTracker?.stopTracking();
}

if (redirectUrl && !beforeEmit) {
// Overrides the default behavior of redirects to `afterSignInUrl`
// or `afterSignUpUrl` to redirect the user to their assigned tasks
const hasSessionToResolve = newSession?.currentTask;
if (redirectUrl && !beforeEmit && !hasSessionToResolve) {
beforeUnloadTracker?.startTracking();
this.#setTransitiveState();

Expand DownExpand Up@@ -1728,6 +1734,8 @@ export class Clerk implements ClerkInterface {
if (this.session) {
const session = this.#getSessionFromClient(this.session.id);

this.maybeNavigateToTaskResolution(this.navigate);

// Note: this might set this.session to null
this.#setAccessors(session);

Expand DownExpand Up@@ -2260,4 +2268,33 @@ export class Clerk implements ClerkInterface {

return allowedProtocols;
}

maybeNavigateToTaskResolution(customNavigate?: (to: string) => Promise<unknown>) {
if (!this.session?.currentTask || !inBrowser()) {
return;
}

const isOnTaskResolutionPath = window.location.href.includes('navigate-to-task');
if (isOnTaskResolutionPath) {
return;
}

const url = buildURL({ base: `${this.#options.signInUrl}/navigate-to-task` }, { stringify: true });

void customNavigate?.(url);
}

navigateToTaskPath(customNavigate?: CustomNavigation) {
if (!this.session?.currentTask || !inBrowser()) {
return;
}

const taskKeyToRoutePaths: Record<SessionTask['key'], SessionTaskRoutePath> = {
org: 'add-organization',
};

const routePath = taskKeyToRoutePaths[this.session.currentTask.key];

void customNavigate?.(routePath);
}
}
4 changes: 4 additions & 0 deletions packages/clerk-js/src/core/resources/Session.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -303,4 +303,8 @@ export class Session extends BaseResource implements SessionResource {
return token.getRawString() || null;
});
}

get currentTask(): SessionTask | undefined {
return (this.tasks ?? [])[0];
}
}
23 changes: 23 additions & 0 deletions packages/clerk-js/src/ui/common/TaskNavigation.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
import { Flow } from '../customizables';
import { Card, LoadingCardContainer, withCardStateProvider } from '../elements';

export const TaskNavigation = withCardStateProvider(() => {
return (
<Flow.Part part='taskNavigation'>
<TaskNavigationCard />
</Flow.Part>
);
});

export const TaskNavigationCard = () => {
return (
<Flow.Part part='taskNavigation'>
<Card.Root>
<Card.Content>
<LoadingCardContainer />
</Card.Content>
<Card.Footer />
</Card.Root>
</Flow.Part>
);
};
1 change: 1 addition & 0 deletions packages/clerk-js/src/ui/common/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -13,6 +13,7 @@ export * from './QRCode';
export * from './redirects';
export * from './RemoveResourceForm';
export * from './SSOCallback';
export * from './TaskNavigation';
export * from './verification';
export * from './withRedirect';
export * from './Wizard';
3 changes: 3 additions & 0 deletions packages/clerk-js/src/ui/common/tasks.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
export const sessionTaskRoutePaths = ['add-organization'] as const;

export type SessionTaskRoutePath = (typeof sessionTaskRoutePaths)[number];
12 changes: 10 additions & 2 deletions packages/clerk-js/src/ui/common/withRedirect.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -28,8 +28,16 @@ export function withRedirect<P extends AvailableComponentProps>(
const environment = useEnvironment();
const options = useOptions();

const shouldRedirect = condition(clerk, environment, options);
const hasTasksAndSingleSessionMode = clerk.session?.currentTask && environment?.authConfig.singleSessionMode;
const shouldRedirect =
// Overrides default redirect guards to not lead with race conditions on redirection for session tasks
hasTasksAndSingleSessionMode ? false : condition(clerk, environment, options);
React.useEffect(() => {
if (hasTasksAndSingleSessionMode) {
void clerk.maybeNavigateToTaskResolution(navigate);
return;
}

if (shouldRedirect) {
if (warning && isDevelopmentFromPublishableKey(clerk.publishableKey)) {
console.info(warning);
Expand All@@ -38,7 +46,7 @@ export function withRedirect<P extends AvailableComponentProps>(
// eslint-disable-next-line @typescript-eslint/no-floating-promises
navigate(redirectUrl({ clerk, environment, options }));
}
}, []);
}, [hasTasksAndSingleSessionMode]);

if (shouldRedirect) {
return null;
Expand Down
14 changes: 14 additions & 0 deletions packages/clerk-js/src/ui/components/SignIn/SignIn.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,7 @@ import { useClerk } from '@clerk/shared/react';
import type { SignInModalProps, SignInProps } from '@clerk/types';
import React from 'react';

import { sessionTaskRoutePaths } from '../../../ui/common/tasks';
import { normalizeRoutingOptions } from '../../../utils/normalizeRoutingOptions';
import { SignInEmailLinkFlowComplete, SignUpEmailLinkFlowComplete } from '../../common/EmailLinkCompleteFlowCard';
import type { SignUpContextType } from '../../contexts';
Expand All@@ -19,13 +20,15 @@ import { SignUpSSOCallback } from '../SignUp/SignUpSSOCallback';
import { SignUpStart } from '../SignUp/SignUpStart';
import { SignUpVerifyEmail } from '../SignUp/SignUpVerifyEmail';
import { SignUpVerifyPhone } from '../SignUp/SignUpVerifyPhone';
import { Task } from '../Task';
import { ResetPassword } from './ResetPassword';
import { ResetPasswordSuccess } from './ResetPasswordSuccess';
import { SignInAccountSwitcher } from './SignInAccountSwitcher';
import { SignInFactorOne } from './SignInFactorOne';
import { SignInFactorTwo } from './SignInFactorTwo';
import { SignInSSOCallback } from './SignInSSOCallback';
import { SignInStart } from './SignInStart';
import { SignInTaskNavigation } from './SignInTaskNavigation';

function RedirectToSignIn() {
const clerk = useClerk();
Expand DownExpand Up@@ -132,6 +135,17 @@ function SignInRoutes(): JSX.Element {
</Route>
</Route>
)}
<Route path='navigate-to-task'>
<SignInTaskNavigation />
</Route>
{sessionTaskRoutePaths.map(path => (
<Route
key={path}
path={path}
>
<Task />
</Route>
))}
<Route index>
<SignInStart />
</Route>
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
import { TaskNavigation, withRedirectToAfterSignIn } from '../../common';

export const SignInTaskNavigation = withRedirectToAfterSignIn(TaskNavigation);
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
6622e0a
Add `tasks` on `Session` resource
LauraBeatris Feb 14, 2025
7d2d43d
Add changeset
LauraBeatris Feb 14, 2025
d4f072e
Update redirect guard to explict check for `active`
LauraBeatris Feb 17, 2025
e307f3a
Display pending task routes
LauraBeatris Feb 17, 2025
0d0a717
Update handling for array of tasks
LauraBeatris Feb 18, 2025
f44a7ec
Fix session task key
LauraBeatris Feb 18, 2025
43beb76
Implement unit tests for Task component
LauraBeatris Feb 19, 2025
46fe2b8
Add unit tests for `useTaskRoute`
LauraBeatris Feb 19, 2025
9234c79
Add option for a custom tasks URL to cover custom flow
LauraBeatris Feb 20, 2025
d38dbbc
Refactor redirect guards for tasks
LauraBeatris Feb 20, 2025
1dc30d5
Does not trigger `redirectUrl` logic
LauraBeatris Feb 20, 2025
36e0c91
Trigger navigation on client-piggybacking
LauraBeatris Feb 21, 2025
f1e64f1
Set tasks URL on the sign-in/sign-up context
LauraBeatris Feb 24, 2025
9a5415d
Introduce separate redirect guards for tasks
LauraBeatris Feb 24, 2025
1233d3b
Add unit test for redirection to task
LauraBeatris Feb 24, 2025
e15d124
Introduce skeleton for integration tests
LauraBeatris Feb 25, 2025
b9f8870
Introduce new base skeleton for URL resolution
LauraBeatris Feb 25, 2025
22b4a4a
Add `with-session-tasks` to integration tests
LauraBeatris Feb 26, 2025
af7f79a
Implement integration tests
LauraBeatris Feb 27, 2025
8818499
Do not close modals on `Clerk.navigate` if the origin is not outisde …
LauraBeatris Feb 27, 2025
be6c67e
Add intermediary route for task resolution
LauraBeatris Mar 4, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/old-cherries-laugh.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
---
'@clerk/clerk-js': patch
'@clerk/types': patch
---

Navigate to session tasks on after sign-in/sign-up
4 changes: 4 additions & 0 deletions integration/.keys.json.sample
Original file line numberDiff line numberDiff line change
Expand Up@@ -46,5 +46,9 @@
"with-waitlist-mode": {
"pk": "",
"sk": ""
},
"with-session-tasks": {
"pk": "",
"sk": ""
}
}
8 changes: 8 additions & 0 deletions integration/presets/envs.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -42,6 +42,13 @@ const withEmailCodes = base
.setEnvVariable('public', 'CLERK_PUBLISHABLE_KEY', instanceKeys.get('with-email-codes').pk)
.setEnvVariable('private', 'CLERK_ENCRYPTION_KEY', constants.E2E_CLERK_ENCRYPTION_KEY || 'a-key');

const withSessionTasks = base
.clone()
.setId('withSessionTasks')
.setEnvVariable('private', 'CLERK_SECRET_KEY', instanceKeys.get('with-session-tasks').sk)
.setEnvVariable('public', 'CLERK_PUBLISHABLE_KEY', instanceKeys.get('with-session-tasks').pk)
.setEnvVariable('private', 'CLERK_ENCRYPTION_KEY', constants.E2E_CLERK_ENCRYPTION_KEY || 'a-key');

const withEmailCodes_destroy_client = withEmailCodes
.clone()
.setEnvVariable('public', 'EXPERIMENTAL_PERSIST_CLIENT', 'false');
Expand DownExpand Up@@ -157,4 +164,5 @@ export const envs = {
withSignInOrUpFlow,
withSignInOrUpEmailLinksFlow,
withSignInOrUpwithRestrictedModeFlow,
withSessionTasks,
} as const;
5 changes: 5 additions & 0 deletions integration/presets/longRunningApps.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -37,6 +37,11 @@ export const createLongRunningApps = () => {
config: next.appRouter,
env: envs.withSignInOrUpEmailLinksFlow,
},
{
id: 'next.appRouter.withSessionTasks',
config: next.appRouter,
env: envs.withSessionTasks,
},
{ id: 'quickstart.next.appRouter', config: next.appRouterQuickstart, env: envs.withEmailCodesQuickstart },
{ id: 'elements.next.appRouter', config: elements.nextAppRouter, env: envs.withEmailCodes },
{ id: 'astro.node.withCustomRoles', config: astro.node, env: envs.withCustomRoles },
Expand Down
1 change: 1 addition & 0 deletions integration/tests/session-tasks-multi-session.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
// TODO - add tests
72 changes: 72 additions & 0 deletions integration/tests/session-tasks-sign-in.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
import { expect, test } from '@playwright/test';

import type { Application } from '../models/application';
import { appConfigs } from '../presets';
import type { FakeUser } from '../testUtils';
import { createTestUtils } from '../testUtils';

test.describe('session tasks sign in flow @nextjs', () => {
test.describe.configure({ mode: 'serial' });
let app: Application;
let fakeUser: FakeUser;

test.beforeAll(async () => {
app = await appConfigs.next.appRouter.clone().commit();
await app.setup();
await app.withEnv(appConfigs.envs.withSessionTasks);
await app.dev();

const m = createTestUtils({ app });
fakeUser = m.services.users.createFakeUser({
withPhoneNumber: true,
withUsername: true,
});
await m.services.users.createBapiUser(fakeUser);
});

test.afterAll(async () => {
await fakeUser.deleteIfExists();
await app.teardown();
});

test('on after sign-in, navigates to tasks', async ({ page, context }) => {
const u = createTestUtils({ app, page, context });
await u.po.signIn.goTo();
await u.po.signIn.setIdentifier(fakeUser.email);
await u.po.signIn.continue();
await u.po.signIn.setPassword(fakeUser.password);
await u.po.signIn.continue();
await u.po.expect.toBeSignedIn();
await expect(u.page.getByRole('heading', { name: 'Create Organization' })).toBeVisible();
expect(u.page.url()).toContain('/sign-in/add-organization');
});

test.fixme('redirects to after sign-in url when session tasks has been resolved', () => {
// todo
});

test.fixme('redirects to after sign-in url when accessing root sign in with a active session', {
// todo
});

test('redirects back to tasks when accessing root sign in', async ({ page, context }) => {
const u = createTestUtils({ app, page, context });
await u.po.signIn.goTo();
await u.po.signIn.setIdentifier(fakeUser.email);
await u.po.signIn.continue();
await u.po.signIn.setPassword(fakeUser.password);
await u.po.signIn.continue();
await u.po.expect.toBeSignedIn();
await expect(u.page.getByRole('heading', { name: 'Create Organization' })).toBeVisible();
expect(u.page.url()).toContain('/sign-in/add-organization');
await u.po.signIn.goTo();
await expect(u.page.getByRole('heading', { name: 'Create Organization' })).toBeVisible();
expect(u.page.url()).toContain('/sign-in/add-organization');
});

test('without a session, does not allow to access tasks', async ({ page, context }) => {
const u = createTestUtils({ app, page, context });
await u.page.goto('/sign-in/add-organization');
expect(u.page.url()).not.toContain('/sign-in/add-organization');
});
});
73 changes: 73 additions & 0 deletions integration/tests/session-tasks-sign-up.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
import { expect, test } from '@playwright/test';

import type { Application } from '../models/application';
import { appConfigs } from '../presets';
import { createTestUtils } from '../testUtils';

test.describe('session tasks sign in flow @nextjs', () => {
test.describe.configure({ mode: 'serial' });
let app: Application;

test.beforeAll(async () => {
app = await appConfigs.next.appRouter.clone().commit();
await app.setup();
await app.withEnv(appConfigs.envs.withSessionTasks);
await app.dev();
});

test.afterAll(async () => {
await app.teardown();
});

test('on after sign-up, navigates to tasks', async ({ page, context }) => {
const u = createTestUtils({ app, page, context });
const fakeUser = u.services.users.createFakeUser({
fictionalEmail: true,
});
await u.po.signUp.goTo();
await u.po.signUp.signUpWithEmailAndPassword({
email: fakeUser.email,
password: fakeUser.password,
});
await u.po.signUp.enterTestOtpCode();
await u.po.expect.toBeSignedIn();

await expect(u.page.getByRole('heading', { name: 'Create Organization' })).toBeVisible();
expect(u.page.url()).toContain('/sign-up/add-organization');

await fakeUser.deleteIfExists();
});

test.fixme('redirects to after sign-up url when session tasks has been resolved', () => {
// todo
});

test.fixme('redirects to after sign-up url when accessing root sign in with a active session', {
// todo
});

test('redirects back to tasks when accessing root sign in', async ({ page, context }) => {
const u = createTestUtils({ app, page, context });
const fakeUser = u.services.users.createFakeUser({
fictionalEmail: true,
});
await u.po.signUp.goTo();
await u.po.signUp.signUpWithEmailAndPassword({
email: fakeUser.email,
password: fakeUser.password,
});
await u.po.signUp.enterTestOtpCode();
await u.po.expect.toBeSignedIn();
await expect(u.page.getByRole('heading', { name: 'Create Organization' })).toBeVisible();
expect(u.page.url()).toContain('/sign-up/add-organization');
await u.po.signIn.goTo();
await expect(u.page.getByRole('heading', { name: 'Create Organization' })).toBeVisible();
expect(u.page.url()).toContain('/sign-up/add-organization');
});

test('without a session, does not allow to access tasks', async ({ page, context }) => {
const u = createTestUtils({ app, page, context });
await u.page.goto('/sign-up/add-organization');
expect(u.page.url()).not.toContain('/sign-up/add-organization');
});
});
39 changes: 38 additions & 1 deletion packages/clerk-js/src/core/clerk.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,6 +23,7 @@ import type {
CreateOrganizationParams,
CreateOrganizationProps,
CredentialReturn,
CustomNavigation,
DomainOrProxyUrl,
EnvironmentJSON,
EnvironmentJSONSnapshot,
Expand All@@ -45,6 +46,7 @@ import type {
RedirectOptions,
Resources,
SDKMetadata,
SessionTask,
SetActiveParams,
SignedInSessionResource,
SignInProps,
Expand All@@ -65,6 +67,7 @@ import type {
WaitlistResource,
Web3Provider,
} from '@clerk/types';
import type { SessionTaskRoutePath } from 'ui/common/tasks';

import type { MountComponentRenderer } from '../ui/Components';
import {
Expand DownExpand Up@@ -946,7 +949,10 @@ export class Clerk implements ClerkInterface {
beforeUnloadTracker?.stopTracking();
}

if (redirectUrl && !beforeEmit) {
// Overrides the default behavior of redirects to `afterSignInUrl`
// or `afterSignUpUrl` to redirect the user to their assigned tasks
const hasSessionToResolve = newSession?.currentTask;
if (redirectUrl && !beforeEmit && !hasSessionToResolve) {
beforeUnloadTracker?.startTracking();
this.#setTransitiveState();

Expand DownExpand Up@@ -1728,6 +1734,8 @@ export class Clerk implements ClerkInterface {
if (this.session) {
const session = this.#getSessionFromClient(this.session.id);

this.maybeNavigateToTaskResolution(this.navigate);

// Note: this might set this.session to null
this.#setAccessors(session);

Expand DownExpand Up@@ -2260,4 +2268,33 @@ export class Clerk implements ClerkInterface {

return allowedProtocols;
}

maybeNavigateToTaskResolution(customNavigate?: (to: string) => Promise<unknown>) {
if (!this.session?.currentTask || !inBrowser()) {
return;
}

const isOnTaskResolutionPath = window.location.href.includes('navigate-to-task');
if (isOnTaskResolutionPath) {
return;
}

const url = buildURL({ base: `${this.#options.signInUrl}/navigate-to-task` }, { stringify: true });

void customNavigate?.(url);
}

navigateToTaskPath(customNavigate?: CustomNavigation) {
if (!this.session?.currentTask || !inBrowser()) {
return;
}

const taskKeyToRoutePaths: Record<SessionTask['key'], SessionTaskRoutePath> = {
org: 'add-organization',
};

const routePath = taskKeyToRoutePaths[this.session.currentTask.key];

void customNavigate?.(routePath);
}
}
4 changes: 4 additions & 0 deletions packages/clerk-js/src/core/resources/Session.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -303,4 +303,8 @@ export class Session extends BaseResource implements SessionResource {
return token.getRawString() || null;
});
}

get currentTask(): SessionTask | undefined {
return (this.tasks ?? [])[0];
}
}
23 changes: 23 additions & 0 deletions packages/clerk-js/src/ui/common/TaskNavigation.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
import { Flow } from '../customizables';
import { Card, LoadingCardContainer, withCardStateProvider } from '../elements';

export const TaskNavigation = withCardStateProvider(() => {
return (
<Flow.Part part='taskNavigation'>
<TaskNavigationCard />
</Flow.Part>
);
});

export const TaskNavigationCard = () => {
return (
<Flow.Part part='taskNavigation'>
<Card.Root>
<Card.Content>
<LoadingCardContainer />
</Card.Content>
<Card.Footer />
</Card.Root>
</Flow.Part>
);
};
1 change: 1 addition & 0 deletions packages/clerk-js/src/ui/common/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -13,6 +13,7 @@ export * from './QRCode';
export * from './redirects';
export * from './RemoveResourceForm';
export * from './SSOCallback';
export * from './TaskNavigation';
export * from './verification';
export * from './withRedirect';
export * from './Wizard';
3 changes: 3 additions & 0 deletions packages/clerk-js/src/ui/common/tasks.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
export const sessionTaskRoutePaths = ['add-organization'] as const;

export type SessionTaskRoutePath = (typeof sessionTaskRoutePaths)[number];
12 changes: 10 additions & 2 deletions packages/clerk-js/src/ui/common/withRedirect.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -28,8 +28,16 @@ export function withRedirect<P extends AvailableComponentProps>(
const environment = useEnvironment();
const options = useOptions();

const shouldRedirect = condition(clerk, environment, options);
const hasTasksAndSingleSessionMode = clerk.session?.currentTask && environment?.authConfig.singleSessionMode;
const shouldRedirect =
// Overrides default redirect guards to not lead with race conditions on redirection for session tasks
hasTasksAndSingleSessionMode ? false : condition(clerk, environment, options);
React.useEffect(() => {
if (hasTasksAndSingleSessionMode) {
void clerk.maybeNavigateToTaskResolution(navigate);
return;
}

if (shouldRedirect) {
if (warning && isDevelopmentFromPublishableKey(clerk.publishableKey)) {
console.info(warning);
Expand All@@ -38,7 +46,7 @@ export function withRedirect<P extends AvailableComponentProps>(
// eslint-disable-next-line @typescript-eslint/no-floating-promises
navigate(redirectUrl({ clerk, environment, options }));
}
}, []);
}, [hasTasksAndSingleSessionMode]);

if (shouldRedirect) {
return null;
Expand Down
14 changes: 14 additions & 0 deletions packages/clerk-js/src/ui/components/SignIn/SignIn.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,7 @@ import { useClerk } from '@clerk/shared/react';
import type { SignInModalProps, SignInProps } from '@clerk/types';
import React from 'react';

import { sessionTaskRoutePaths } from '../../../ui/common/tasks';
import { normalizeRoutingOptions } from '../../../utils/normalizeRoutingOptions';
import { SignInEmailLinkFlowComplete, SignUpEmailLinkFlowComplete } from '../../common/EmailLinkCompleteFlowCard';
import type { SignUpContextType } from '../../contexts';
Expand All@@ -19,13 +20,15 @@ import { SignUpSSOCallback } from '../SignUp/SignUpSSOCallback';
import { SignUpStart } from '../SignUp/SignUpStart';
import { SignUpVerifyEmail } from '../SignUp/SignUpVerifyEmail';
import { SignUpVerifyPhone } from '../SignUp/SignUpVerifyPhone';
import { Task } from '../Task';
import { ResetPassword } from './ResetPassword';
import { ResetPasswordSuccess } from './ResetPasswordSuccess';
import { SignInAccountSwitcher } from './SignInAccountSwitcher';
import { SignInFactorOne } from './SignInFactorOne';
import { SignInFactorTwo } from './SignInFactorTwo';
import { SignInSSOCallback } from './SignInSSOCallback';
import { SignInStart } from './SignInStart';
import { SignInTaskNavigation } from './SignInTaskNavigation';

function RedirectToSignIn() {
const clerk = useClerk();
Expand DownExpand Up@@ -132,6 +135,17 @@ function SignInRoutes(): JSX.Element {
</Route>
</Route>
)}
<Route path='navigate-to-task'>
<SignInTaskNavigation />
</Route>
{sessionTaskRoutePaths.map(path => (
<Route
key={path}
path={path}
>
<Task />
</Route>
))}
<Route index>
<SignInStart />
</Route>
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
import { TaskNavigation, withRedirectToAfterSignIn } from '../../common';

export const SignInTaskNavigation = withRedirectToAfterSignIn(TaskNavigation);
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
6622e0a
Add `tasks` on `Session` resource
LauraBeatris Feb 14, 2025
7d2d43d
Add changeset
LauraBeatris Feb 14, 2025
d4f072e
Update redirect guard to explict check for `active`
LauraBeatris Feb 17, 2025
e307f3a
Display pending task routes
LauraBeatris Feb 17, 2025
0d0a717
Update handling for array of tasks
LauraBeatris Feb 18, 2025
f44a7ec
Fix session task key
LauraBeatris Feb 18, 2025
43beb76
Implement unit tests for Task component
LauraBeatris Feb 19, 2025
46fe2b8
Add unit tests for `useTaskRoute`
LauraBeatris Feb 19, 2025
9234c79
Add option for a custom tasks URL to cover custom flow
LauraBeatris Feb 20, 2025
d38dbbc
Refactor redirect guards for tasks
LauraBeatris Feb 20, 2025
1dc30d5
Does not trigger `redirectUrl` logic
LauraBeatris Feb 20, 2025
36e0c91
Trigger navigation on client-piggybacking
LauraBeatris Feb 21, 2025
f1e64f1
Set tasks URL on the sign-in/sign-up context
LauraBeatris Feb 24, 2025
9a5415d
Introduce separate redirect guards for tasks
LauraBeatris Feb 24, 2025
1233d3b
Add unit test for redirection to task
LauraBeatris Feb 24, 2025
e15d124
Introduce skeleton for integration tests
LauraBeatris Feb 25, 2025
b9f8870
Introduce new base skeleton for URL resolution
LauraBeatris Feb 25, 2025
22b4a4a
Add `with-session-tasks` to integration tests
LauraBeatris Feb 26, 2025
af7f79a
Implement integration tests
LauraBeatris Feb 27, 2025
8818499
Do not close modals on `Clerk.navigate` if the origin is not outisde …
LauraBeatris Feb 27, 2025
be6c67e
Add intermediary route for task resolution
LauraBeatris Mar 4, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/old-cherries-laugh.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
---
'@clerk/clerk-js': patch
'@clerk/types': patch
---

Navigate to session tasks on after sign-in/sign-up
4 changes: 4 additions & 0 deletions integration/.keys.json.sample
Original file line numberDiff line numberDiff line change
Expand Up@@ -46,5 +46,9 @@
"with-waitlist-mode": {
"pk": "",
"sk": ""
},
"with-session-tasks": {
"pk": "",
"sk": ""
}
}
8 changes: 8 additions & 0 deletions integration/presets/envs.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -42,6 +42,13 @@ const withEmailCodes = base
.setEnvVariable('public', 'CLERK_PUBLISHABLE_KEY', instanceKeys.get('with-email-codes').pk)
.setEnvVariable('private', 'CLERK_ENCRYPTION_KEY', constants.E2E_CLERK_ENCRYPTION_KEY || 'a-key');

const withSessionTasks = base
.clone()
.setId('withSessionTasks')
.setEnvVariable('private', 'CLERK_SECRET_KEY', instanceKeys.get('with-session-tasks').sk)
.setEnvVariable('public', 'CLERK_PUBLISHABLE_KEY', instanceKeys.get('with-session-tasks').pk)
.setEnvVariable('private', 'CLERK_ENCRYPTION_KEY', constants.E2E_CLERK_ENCRYPTION_KEY || 'a-key');

const withEmailCodes_destroy_client = withEmailCodes
.clone()
.setEnvVariable('public', 'EXPERIMENTAL_PERSIST_CLIENT', 'false');
Expand DownExpand Up@@ -157,4 +164,5 @@ export const envs = {
withSignInOrUpFlow,
withSignInOrUpEmailLinksFlow,
withSignInOrUpwithRestrictedModeFlow,
withSessionTasks,
} as const;
5 changes: 5 additions & 0 deletions integration/presets/longRunningApps.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -37,6 +37,11 @@ export const createLongRunningApps = () => {
config: next.appRouter,
env: envs.withSignInOrUpEmailLinksFlow,
},
{
id: 'next.appRouter.withSessionTasks',
config: next.appRouter,
env: envs.withSessionTasks,
},
{ id: 'quickstart.next.appRouter', config: next.appRouterQuickstart, env: envs.withEmailCodesQuickstart },
{ id: 'elements.next.appRouter', config: elements.nextAppRouter, env: envs.withEmailCodes },
{ id: 'astro.node.withCustomRoles', config: astro.node, env: envs.withCustomRoles },
Expand Down
1 change: 1 addition & 0 deletions integration/tests/session-tasks-multi-session.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
// TODO - add tests
72 changes: 72 additions & 0 deletions integration/tests/session-tasks-sign-in.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
import { expect, test } from '@playwright/test';

import type { Application } from '../models/application';
import { appConfigs } from '../presets';
import type { FakeUser } from '../testUtils';
import { createTestUtils } from '../testUtils';

test.describe('session tasks sign in flow @nextjs', () => {
test.describe.configure({ mode: 'serial' });
let app: Application;
let fakeUser: FakeUser;

test.beforeAll(async () => {
app = await appConfigs.next.appRouter.clone().commit();
await app.setup();
await app.withEnv(appConfigs.envs.withSessionTasks);
await app.dev();

const m = createTestUtils({ app });
fakeUser = m.services.users.createFakeUser({
withPhoneNumber: true,
withUsername: true,
});
await m.services.users.createBapiUser(fakeUser);
});

test.afterAll(async () => {
await fakeUser.deleteIfExists();
await app.teardown();
});

test('on after sign-in, navigates to tasks', async ({ page, context }) => {
const u = createTestUtils({ app, page, context });
await u.po.signIn.goTo();
await u.po.signIn.setIdentifier(fakeUser.email);
await u.po.signIn.continue();
await u.po.signIn.setPassword(fakeUser.password);
await u.po.signIn.continue();
await u.po.expect.toBeSignedIn();
await expect(u.page.getByRole('heading', { name: 'Create Organization' })).toBeVisible();
expect(u.page.url()).toContain('/sign-in/add-organization');
});

test.fixme('redirects to after sign-in url when session tasks has been resolved', () => {
// todo
});

test.fixme('redirects to after sign-in url when accessing root sign in with a active session', {
// todo
});

test('redirects back to tasks when accessing root sign in', async ({ page, context }) => {
const u = createTestUtils({ app, page, context });
await u.po.signIn.goTo();
await u.po.signIn.setIdentifier(fakeUser.email);
await u.po.signIn.continue();
await u.po.signIn.setPassword(fakeUser.password);
await u.po.signIn.continue();
await u.po.expect.toBeSignedIn();
await expect(u.page.getByRole('heading', { name: 'Create Organization' })).toBeVisible();
expect(u.page.url()).toContain('/sign-in/add-organization');
await u.po.signIn.goTo();
await expect(u.page.getByRole('heading', { name: 'Create Organization' })).toBeVisible();
expect(u.page.url()).toContain('/sign-in/add-organization');
});

test('without a session, does not allow to access tasks', async ({ page, context }) => {
const u = createTestUtils({ app, page, context });
await u.page.goto('/sign-in/add-organization');
expect(u.page.url()).not.toContain('/sign-in/add-organization');
});
});
73 changes: 73 additions & 0 deletions integration/tests/session-tasks-sign-up.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
import { expect, test } from '@playwright/test';

import type { Application } from '../models/application';
import { appConfigs } from '../presets';
import { createTestUtils } from '../testUtils';

test.describe('session tasks sign in flow @nextjs', () => {
test.describe.configure({ mode: 'serial' });
let app: Application;

test.beforeAll(async () => {
app = await appConfigs.next.appRouter.clone().commit();
await app.setup();
await app.withEnv(appConfigs.envs.withSessionTasks);
await app.dev();
});

test.afterAll(async () => {
await app.teardown();
});

test('on after sign-up, navigates to tasks', async ({ page, context }) => {
const u = createTestUtils({ app, page, context });
const fakeUser = u.services.users.createFakeUser({
fictionalEmail: true,
});
await u.po.signUp.goTo();
await u.po.signUp.signUpWithEmailAndPassword({
email: fakeUser.email,
password: fakeUser.password,
});
await u.po.signUp.enterTestOtpCode();
await u.po.expect.toBeSignedIn();

await expect(u.page.getByRole('heading', { name: 'Create Organization' })).toBeVisible();
expect(u.page.url()).toContain('/sign-up/add-organization');

await fakeUser.deleteIfExists();
});

test.fixme('redirects to after sign-up url when session tasks has been resolved', () => {
// todo
});

test.fixme('redirects to after sign-up url when accessing root sign in with a active session', {
// todo
});

test('redirects back to tasks when accessing root sign in', async ({ page, context }) => {
const u = createTestUtils({ app, page, context });
const fakeUser = u.services.users.createFakeUser({
fictionalEmail: true,
});
await u.po.signUp.goTo();
await u.po.signUp.signUpWithEmailAndPassword({
email: fakeUser.email,
password: fakeUser.password,
});
await u.po.signUp.enterTestOtpCode();
await u.po.expect.toBeSignedIn();
await expect(u.page.getByRole('heading', { name: 'Create Organization' })).toBeVisible();
expect(u.page.url()).toContain('/sign-up/add-organization');
await u.po.signIn.goTo();
await expect(u.page.getByRole('heading', { name: 'Create Organization' })).toBeVisible();
expect(u.page.url()).toContain('/sign-up/add-organization');
});

test('without a session, does not allow to access tasks', async ({ page, context }) => {
const u = createTestUtils({ app, page, context });
await u.page.goto('/sign-up/add-organization');
expect(u.page.url()).not.toContain('/sign-up/add-organization');
});
});
39 changes: 38 additions & 1 deletion packages/clerk-js/src/core/clerk.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,6 +23,7 @@ import type {
CreateOrganizationParams,
CreateOrganizationProps,
CredentialReturn,
CustomNavigation,
DomainOrProxyUrl,
EnvironmentJSON,
EnvironmentJSONSnapshot,
Expand All@@ -45,6 +46,7 @@ import type {
RedirectOptions,
Resources,
SDKMetadata,
SessionTask,
SetActiveParams,
SignedInSessionResource,
SignInProps,
Expand All@@ -65,6 +67,7 @@ import type {
WaitlistResource,
Web3Provider,
} from '@clerk/types';
import type { SessionTaskRoutePath } from 'ui/common/tasks';

import type { MountComponentRenderer } from '../ui/Components';
import {
Expand DownExpand Up@@ -946,7 +949,10 @@ export class Clerk implements ClerkInterface {
beforeUnloadTracker?.stopTracking();
}

if (redirectUrl && !beforeEmit) {
// Overrides the default behavior of redirects to `afterSignInUrl`
// or `afterSignUpUrl` to redirect the user to their assigned tasks
const hasSessionToResolve = newSession?.currentTask;
if (redirectUrl && !beforeEmit && !hasSessionToResolve) {
beforeUnloadTracker?.startTracking();
this.#setTransitiveState();

Expand DownExpand Up@@ -1728,6 +1734,8 @@ export class Clerk implements ClerkInterface {
if (this.session) {
const session = this.#getSessionFromClient(this.session.id);

this.maybeNavigateToTaskResolution(this.navigate);

// Note: this might set this.session to null
this.#setAccessors(session);

Expand DownExpand Up@@ -2260,4 +2268,33 @@ export class Clerk implements ClerkInterface {

return allowedProtocols;
}

maybeNavigateToTaskResolution(customNavigate?: (to: string) => Promise<unknown>) {
if (!this.session?.currentTask || !inBrowser()) {
return;
}

const isOnTaskResolutionPath = window.location.href.includes('navigate-to-task');
if (isOnTaskResolutionPath) {
return;
}

const url = buildURL({ base: `${this.#options.signInUrl}/navigate-to-task` }, { stringify: true });

void customNavigate?.(url);
}

navigateToTaskPath(customNavigate?: CustomNavigation) {
if (!this.session?.currentTask || !inBrowser()) {
return;
}

const taskKeyToRoutePaths: Record<SessionTask['key'], SessionTaskRoutePath> = {
org: 'add-organization',
};

const routePath = taskKeyToRoutePaths[this.session.currentTask.key];

void customNavigate?.(routePath);
}
}
4 changes: 4 additions & 0 deletions packages/clerk-js/src/core/resources/Session.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -303,4 +303,8 @@ export class Session extends BaseResource implements SessionResource {
return token.getRawString() || null;
});
}

get currentTask(): SessionTask | undefined {
return (this.tasks ?? [])[0];
}
}
23 changes: 23 additions & 0 deletions packages/clerk-js/src/ui/common/TaskNavigation.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
import { Flow } from '../customizables';
import { Card, LoadingCardContainer, withCardStateProvider } from '../elements';

export const TaskNavigation = withCardStateProvider(() => {
return (
<Flow.Part part='taskNavigation'>
<TaskNavigationCard />
</Flow.Part>
);
});

export const TaskNavigationCard = () => {
return (
<Flow.Part part='taskNavigation'>
<Card.Root>
<Card.Content>
<LoadingCardContainer />
</Card.Content>
<Card.Footer />
</Card.Root>
</Flow.Part>
);
};
1 change: 1 addition & 0 deletions packages/clerk-js/src/ui/common/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -13,6 +13,7 @@ export * from './QRCode';
export * from './redirects';
export * from './RemoveResourceForm';
export * from './SSOCallback';
export * from './TaskNavigation';
export * from './verification';
export * from './withRedirect';
export * from './Wizard';
3 changes: 3 additions & 0 deletions packages/clerk-js/src/ui/common/tasks.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
export const sessionTaskRoutePaths = ['add-organization'] as const;

export type SessionTaskRoutePath = (typeof sessionTaskRoutePaths)[number];
12 changes: 10 additions & 2 deletions packages/clerk-js/src/ui/common/withRedirect.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -28,8 +28,16 @@ export function withRedirect<P extends AvailableComponentProps>(
const environment = useEnvironment();
const options = useOptions();

const shouldRedirect = condition(clerk, environment, options);
const hasTasksAndSingleSessionMode = clerk.session?.currentTask && environment?.authConfig.singleSessionMode;
const shouldRedirect =
// Overrides default redirect guards to not lead with race conditions on redirection for session tasks
hasTasksAndSingleSessionMode ? false : condition(clerk, environment, options);
React.useEffect(() => {
if (hasTasksAndSingleSessionMode) {
void clerk.maybeNavigateToTaskResolution(navigate);
return;
}

if (shouldRedirect) {
if (warning && isDevelopmentFromPublishableKey(clerk.publishableKey)) {
console.info(warning);
Expand All@@ -38,7 +46,7 @@ export function withRedirect<P extends AvailableComponentProps>(
// eslint-disable-next-line @typescript-eslint/no-floating-promises
navigate(redirectUrl({ clerk, environment, options }));
}
}, []);
}, [hasTasksAndSingleSessionMode]);

if (shouldRedirect) {
return null;
Expand Down
14 changes: 14 additions & 0 deletions packages/clerk-js/src/ui/components/SignIn/SignIn.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,7 @@ import { useClerk } from '@clerk/shared/react';
import type { SignInModalProps, SignInProps } from '@clerk/types';
import React from 'react';

import { sessionTaskRoutePaths } from '../../../ui/common/tasks';
import { normalizeRoutingOptions } from '../../../utils/normalizeRoutingOptions';
import { SignInEmailLinkFlowComplete, SignUpEmailLinkFlowComplete } from '../../common/EmailLinkCompleteFlowCard';
import type { SignUpContextType } from '../../contexts';
Expand All@@ -19,13 +20,15 @@ import { SignUpSSOCallback } from '../SignUp/SignUpSSOCallback';
import { SignUpStart } from '../SignUp/SignUpStart';
import { SignUpVerifyEmail } from '../SignUp/SignUpVerifyEmail';
import { SignUpVerifyPhone } from '../SignUp/SignUpVerifyPhone';
import { Task } from '../Task';
import { ResetPassword } from './ResetPassword';
import { ResetPasswordSuccess } from './ResetPasswordSuccess';
import { SignInAccountSwitcher } from './SignInAccountSwitcher';
import { SignInFactorOne } from './SignInFactorOne';
import { SignInFactorTwo } from './SignInFactorTwo';
import { SignInSSOCallback } from './SignInSSOCallback';
import { SignInStart } from './SignInStart';
import { SignInTaskNavigation } from './SignInTaskNavigation';

function RedirectToSignIn() {
const clerk = useClerk();
Expand DownExpand Up@@ -132,6 +135,17 @@ function SignInRoutes(): JSX.Element {
</Route>
</Route>
)}
<Route path='navigate-to-task'>
<SignInTaskNavigation />
</Route>
{sessionTaskRoutePaths.map(path => (
<Route
key={path}
path={path}
>
<Task />
</Route>
))}
<Route index>
<SignInStart />
</Route>
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
import { TaskNavigation, withRedirectToAfterSignIn } from '../../common';

export const SignInTaskNavigation = withRedirectToAfterSignIn(TaskNavigation);
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
6622e0a
Add `tasks` on `Session` resource
LauraBeatris Feb 14, 2025
7d2d43d
Add changeset
LauraBeatris Feb 14, 2025
d4f072e
Update redirect guard to explict check for `active`
LauraBeatris Feb 17, 2025
e307f3a
Display pending task routes
LauraBeatris Feb 17, 2025
0d0a717
Update handling for array of tasks
LauraBeatris Feb 18, 2025
f44a7ec
Fix session task key
LauraBeatris Feb 18, 2025
43beb76
Implement unit tests for Task component
LauraBeatris Feb 19, 2025
46fe2b8
Add unit tests for `useTaskRoute`
LauraBeatris Feb 19, 2025
9234c79
Add option for a custom tasks URL to cover custom flow
LauraBeatris Feb 20, 2025
d38dbbc
Refactor redirect guards for tasks
LauraBeatris Feb 20, 2025
1dc30d5
Does not trigger `redirectUrl` logic
LauraBeatris Feb 20, 2025
36e0c91
Trigger navigation on client-piggybacking
LauraBeatris Feb 21, 2025
f1e64f1
Set tasks URL on the sign-in/sign-up context
LauraBeatris Feb 24, 2025
9a5415d
Introduce separate redirect guards for tasks
LauraBeatris Feb 24, 2025
1233d3b
Add unit test for redirection to task
LauraBeatris Feb 24, 2025
e15d124
Introduce skeleton for integration tests
LauraBeatris Feb 25, 2025
b9f8870
Introduce new base skeleton for URL resolution
LauraBeatris Feb 25, 2025
22b4a4a
Add `with-session-tasks` to integration tests
LauraBeatris Feb 26, 2025
af7f79a
Implement integration tests
LauraBeatris Feb 27, 2025
8818499
Do not close modals on `Clerk.navigate` if the origin is not outisde …
LauraBeatris Feb 27, 2025
be6c67e
Add intermediary route for task resolution
LauraBeatris Mar 4, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/old-cherries-laugh.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
---
'@clerk/clerk-js': patch
'@clerk/types': patch
---

Navigate to session tasks on after sign-in/sign-up
4 changes: 4 additions & 0 deletions integration/.keys.json.sample
Original file line numberDiff line numberDiff line change
Expand Up@@ -46,5 +46,9 @@
"with-waitlist-mode": {
"pk": "",
"sk": ""
},
"with-session-tasks": {
"pk": "",
"sk": ""
}
}
8 changes: 8 additions & 0 deletions integration/presets/envs.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -42,6 +42,13 @@ const withEmailCodes = base
.setEnvVariable('public', 'CLERK_PUBLISHABLE_KEY', instanceKeys.get('with-email-codes').pk)
.setEnvVariable('private', 'CLERK_ENCRYPTION_KEY', constants.E2E_CLERK_ENCRYPTION_KEY || 'a-key');

const withSessionTasks = base
.clone()
.setId('withSessionTasks')
.setEnvVariable('private', 'CLERK_SECRET_KEY', instanceKeys.get('with-session-tasks').sk)
.setEnvVariable('public', 'CLERK_PUBLISHABLE_KEY', instanceKeys.get('with-session-tasks').pk)
.setEnvVariable('private', 'CLERK_ENCRYPTION_KEY', constants.E2E_CLERK_ENCRYPTION_KEY || 'a-key');

const withEmailCodes_destroy_client = withEmailCodes
.clone()
.setEnvVariable('public', 'EXPERIMENTAL_PERSIST_CLIENT', 'false');
Expand DownExpand Up@@ -157,4 +164,5 @@ export const envs = {
withSignInOrUpFlow,
withSignInOrUpEmailLinksFlow,
withSignInOrUpwithRestrictedModeFlow,
withSessionTasks,
} as const;
5 changes: 5 additions & 0 deletions integration/presets/longRunningApps.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -37,6 +37,11 @@ export const createLongRunningApps = () => {
config: next.appRouter,
env: envs.withSignInOrUpEmailLinksFlow,
},
{
id: 'next.appRouter.withSessionTasks',
config: next.appRouter,
env: envs.withSessionTasks,
},
{ id: 'quickstart.next.appRouter', config: next.appRouterQuickstart, env: envs.withEmailCodesQuickstart },
{ id: 'elements.next.appRouter', config: elements.nextAppRouter, env: envs.withEmailCodes },
{ id: 'astro.node.withCustomRoles', config: astro.node, env: envs.withCustomRoles },
Expand Down
1 change: 1 addition & 0 deletions integration/tests/session-tasks-multi-session.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
// TODO - add tests
72 changes: 72 additions & 0 deletions integration/tests/session-tasks-sign-in.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
import { expect, test } from '@playwright/test';

import type { Application } from '../models/application';
import { appConfigs } from '../presets';
import type { FakeUser } from '../testUtils';
import { createTestUtils } from '../testUtils';

test.describe('session tasks sign in flow @nextjs', () => {
test.describe.configure({ mode: 'serial' });
let app: Application;
let fakeUser: FakeUser;

test.beforeAll(async () => {
app = await appConfigs.next.appRouter.clone().commit();
await app.setup();
await app.withEnv(appConfigs.envs.withSessionTasks);
await app.dev();

const m = createTestUtils({ app });
fakeUser = m.services.users.createFakeUser({
withPhoneNumber: true,
withUsername: true,
});
await m.services.users.createBapiUser(fakeUser);
});

test.afterAll(async () => {
await fakeUser.deleteIfExists();
await app.teardown();
});

test('on after sign-in, navigates to tasks', async ({ page, context }) => {
const u = createTestUtils({ app, page, context });
await u.po.signIn.goTo();
await u.po.signIn.setIdentifier(fakeUser.email);
await u.po.signIn.continue();
await u.po.signIn.setPassword(fakeUser.password);
await u.po.signIn.continue();
await u.po.expect.toBeSignedIn();
await expect(u.page.getByRole('heading', { name: 'Create Organization' })).toBeVisible();
expect(u.page.url()).toContain('/sign-in/add-organization');
});

test.fixme('redirects to after sign-in url when session tasks has been resolved', () => {
// todo
});

test.fixme('redirects to after sign-in url when accessing root sign in with a active session', {
// todo
});

test('redirects back to tasks when accessing root sign in', async ({ page, context }) => {
const u = createTestUtils({ app, page, context });
await u.po.signIn.goTo();
await u.po.signIn.setIdentifier(fakeUser.email);
await u.po.signIn.continue();
await u.po.signIn.setPassword(fakeUser.password);
await u.po.signIn.continue();
await u.po.expect.toBeSignedIn();
await expect(u.page.getByRole('heading', { name: 'Create Organization' })).toBeVisible();
expect(u.page.url()).toContain('/sign-in/add-organization');
await u.po.signIn.goTo();
await expect(u.page.getByRole('heading', { name: 'Create Organization' })).toBeVisible();
expect(u.page.url()).toContain('/sign-in/add-organization');
});

test('without a session, does not allow to access tasks', async ({ page, context }) => {
const u = createTestUtils({ app, page, context });
await u.page.goto('/sign-in/add-organization');
expect(u.page.url()).not.toContain('/sign-in/add-organization');
});
});
73 changes: 73 additions & 0 deletions integration/tests/session-tasks-sign-up.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
import { expect, test } from '@playwright/test';

import type { Application } from '../models/application';
import { appConfigs } from '../presets';
import { createTestUtils } from '../testUtils';

test.describe('session tasks sign in flow @nextjs', () => {
test.describe.configure({ mode: 'serial' });
let app: Application;

test.beforeAll(async () => {
app = await appConfigs.next.appRouter.clone().commit();
await app.setup();
await app.withEnv(appConfigs.envs.withSessionTasks);
await app.dev();
});

test.afterAll(async () => {
await app.teardown();
});

test('on after sign-up, navigates to tasks', async ({ page, context }) => {
const u = createTestUtils({ app, page, context });
const fakeUser = u.services.users.createFakeUser({
fictionalEmail: true,
});
await u.po.signUp.goTo();
await u.po.signUp.signUpWithEmailAndPassword({
email: fakeUser.email,
password: fakeUser.password,
});
await u.po.signUp.enterTestOtpCode();
await u.po.expect.toBeSignedIn();

await expect(u.page.getByRole('heading', { name: 'Create Organization' })).toBeVisible();
expect(u.page.url()).toContain('/sign-up/add-organization');

await fakeUser.deleteIfExists();
});

test.fixme('redirects to after sign-up url when session tasks has been resolved', () => {
// todo
});

test.fixme('redirects to after sign-up url when accessing root sign in with a active session', {
// todo
});

test('redirects back to tasks when accessing root sign in', async ({ page, context }) => {
const u = createTestUtils({ app, page, context });
const fakeUser = u.services.users.createFakeUser({
fictionalEmail: true,
});
await u.po.signUp.goTo();
await u.po.signUp.signUpWithEmailAndPassword({
email: fakeUser.email,
password: fakeUser.password,
});
await u.po.signUp.enterTestOtpCode();
await u.po.expect.toBeSignedIn();
await expect(u.page.getByRole('heading', { name: 'Create Organization' })).toBeVisible();
expect(u.page.url()).toContain('/sign-up/add-organization');
await u.po.signIn.goTo();
await expect(u.page.getByRole('heading', { name: 'Create Organization' })).toBeVisible();
expect(u.page.url()).toContain('/sign-up/add-organization');
});

test('without a session, does not allow to access tasks', async ({ page, context }) => {
const u = createTestUtils({ app, page, context });
await u.page.goto('/sign-up/add-organization');
expect(u.page.url()).not.toContain('/sign-up/add-organization');
});
});
39 changes: 38 additions & 1 deletion packages/clerk-js/src/core/clerk.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,6 +23,7 @@ import type {
CreateOrganizationParams,
CreateOrganizationProps,
CredentialReturn,
CustomNavigation,
DomainOrProxyUrl,
EnvironmentJSON,
EnvironmentJSONSnapshot,
Expand All@@ -45,6 +46,7 @@ import type {
RedirectOptions,
Resources,
SDKMetadata,
SessionTask,
SetActiveParams,
SignedInSessionResource,
SignInProps,
Expand All@@ -65,6 +67,7 @@ import type {
WaitlistResource,
Web3Provider,
} from '@clerk/types';
import type { SessionTaskRoutePath } from 'ui/common/tasks';

import type { MountComponentRenderer } from '../ui/Components';
import {
Expand DownExpand Up@@ -946,7 +949,10 @@ export class Clerk implements ClerkInterface {
beforeUnloadTracker?.stopTracking();
}

if (redirectUrl && !beforeEmit) {
// Overrides the default behavior of redirects to `afterSignInUrl`
// or `afterSignUpUrl` to redirect the user to their assigned tasks
const hasSessionToResolve = newSession?.currentTask;
if (redirectUrl && !beforeEmit && !hasSessionToResolve) {
beforeUnloadTracker?.startTracking();
this.#setTransitiveState();

Expand DownExpand Up@@ -1728,6 +1734,8 @@ export class Clerk implements ClerkInterface {
if (this.session) {
const session = this.#getSessionFromClient(this.session.id);

this.maybeNavigateToTaskResolution(this.navigate);

// Note: this might set this.session to null
this.#setAccessors(session);

Expand DownExpand Up@@ -2260,4 +2268,33 @@ export class Clerk implements ClerkInterface {

return allowedProtocols;
}

maybeNavigateToTaskResolution(customNavigate?: (to: string) => Promise<unknown>) {
if (!this.session?.currentTask || !inBrowser()) {
return;
}

const isOnTaskResolutionPath = window.location.href.includes('navigate-to-task');
if (isOnTaskResolutionPath) {
return;
}

const url = buildURL({ base: `${this.#options.signInUrl}/navigate-to-task` }, { stringify: true });

void customNavigate?.(url);
}

navigateToTaskPath(customNavigate?: CustomNavigation) {
if (!this.session?.currentTask || !inBrowser()) {
return;
}

const taskKeyToRoutePaths: Record<SessionTask['key'], SessionTaskRoutePath> = {
org: 'add-organization',
};

const routePath = taskKeyToRoutePaths[this.session.currentTask.key];

void customNavigate?.(routePath);
}
}
4 changes: 4 additions & 0 deletions packages/clerk-js/src/core/resources/Session.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -303,4 +303,8 @@ export class Session extends BaseResource implements SessionResource {
return token.getRawString() || null;
});
}

get currentTask(): SessionTask | undefined {
return (this.tasks ?? [])[0];
}
}
23 changes: 23 additions & 0 deletions packages/clerk-js/src/ui/common/TaskNavigation.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
import { Flow } from '../customizables';
import { Card, LoadingCardContainer, withCardStateProvider } from '../elements';

export const TaskNavigation = withCardStateProvider(() => {
return (
<Flow.Part part='taskNavigation'>
<TaskNavigationCard />
</Flow.Part>
);
});

export const TaskNavigationCard = () => {
return (
<Flow.Part part='taskNavigation'>
<Card.Root>
<Card.Content>
<LoadingCardContainer />
</Card.Content>
<Card.Footer />
</Card.Root>
</Flow.Part>
);
};
1 change: 1 addition & 0 deletions packages/clerk-js/src/ui/common/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -13,6 +13,7 @@ export * from './QRCode';
export * from './redirects';
export * from './RemoveResourceForm';
export * from './SSOCallback';
export * from './TaskNavigation';
export * from './verification';
export * from './withRedirect';
export * from './Wizard';
3 changes: 3 additions & 0 deletions packages/clerk-js/src/ui/common/tasks.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
export const sessionTaskRoutePaths = ['add-organization'] as const;

export type SessionTaskRoutePath = (typeof sessionTaskRoutePaths)[number];
12 changes: 10 additions & 2 deletions packages/clerk-js/src/ui/common/withRedirect.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -28,8 +28,16 @@ export function withRedirect<P extends AvailableComponentProps>(
const environment = useEnvironment();
const options = useOptions();

const shouldRedirect = condition(clerk, environment, options);
const hasTasksAndSingleSessionMode = clerk.session?.currentTask && environment?.authConfig.singleSessionMode;
const shouldRedirect =
// Overrides default redirect guards to not lead with race conditions on redirection for session tasks
hasTasksAndSingleSessionMode ? false : condition(clerk, environment, options);
React.useEffect(() => {
if (hasTasksAndSingleSessionMode) {
void clerk.maybeNavigateToTaskResolution(navigate);
return;
}

if (shouldRedirect) {
if (warning && isDevelopmentFromPublishableKey(clerk.publishableKey)) {
console.info(warning);
Expand All@@ -38,7 +46,7 @@ export function withRedirect<P extends AvailableComponentProps>(
// eslint-disable-next-line @typescript-eslint/no-floating-promises
navigate(redirectUrl({ clerk, environment, options }));
}
}, []);
}, [hasTasksAndSingleSessionMode]);

if (shouldRedirect) {
return null;
Expand Down
14 changes: 14 additions & 0 deletions packages/clerk-js/src/ui/components/SignIn/SignIn.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,7 @@ import { useClerk } from '@clerk/shared/react';
import type { SignInModalProps, SignInProps } from '@clerk/types';
import React from 'react';

import { sessionTaskRoutePaths } from '../../../ui/common/tasks';
import { normalizeRoutingOptions } from '../../../utils/normalizeRoutingOptions';
import { SignInEmailLinkFlowComplete, SignUpEmailLinkFlowComplete } from '../../common/EmailLinkCompleteFlowCard';
import type { SignUpContextType } from '../../contexts';
Expand All@@ -19,13 +20,15 @@ import { SignUpSSOCallback } from '../SignUp/SignUpSSOCallback';
import { SignUpStart } from '../SignUp/SignUpStart';
import { SignUpVerifyEmail } from '../SignUp/SignUpVerifyEmail';
import { SignUpVerifyPhone } from '../SignUp/SignUpVerifyPhone';
import { Task } from '../Task';
import { ResetPassword } from './ResetPassword';
import { ResetPasswordSuccess } from './ResetPasswordSuccess';
import { SignInAccountSwitcher } from './SignInAccountSwitcher';
import { SignInFactorOne } from './SignInFactorOne';
import { SignInFactorTwo } from './SignInFactorTwo';
import { SignInSSOCallback } from './SignInSSOCallback';
import { SignInStart } from './SignInStart';
import { SignInTaskNavigation } from './SignInTaskNavigation';

function RedirectToSignIn() {
const clerk = useClerk();
Expand DownExpand Up@@ -132,6 +135,17 @@ function SignInRoutes(): JSX.Element {
</Route>
</Route>
)}
<Route path='navigate-to-task'>
<SignInTaskNavigation />
</Route>
{sessionTaskRoutePaths.map(path => (
<Route
key={path}
path={path}
>
<Task />
</Route>
))}
<Route index>
<SignInStart />
</Route>
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
import { TaskNavigation, withRedirectToAfterSignIn } from '../../common';

export const SignInTaskNavigation = withRedirectToAfterSignIn(TaskNavigation);
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
6622e0a
Add `tasks` on `Session` resource
LauraBeatris Feb 14, 2025
7d2d43d
Add changeset
LauraBeatris Feb 14, 2025
d4f072e
Update redirect guard to explict check for `active`
LauraBeatris Feb 17, 2025
e307f3a
Display pending task routes
LauraBeatris Feb 17, 2025
0d0a717
Update handling for array of tasks
LauraBeatris Feb 18, 2025
f44a7ec
Fix session task key
LauraBeatris Feb 18, 2025
43beb76
Implement unit tests for Task component
LauraBeatris Feb 19, 2025
46fe2b8
Add unit tests for `useTaskRoute`
LauraBeatris Feb 19, 2025
9234c79
Add option for a custom tasks URL to cover custom flow
LauraBeatris Feb 20, 2025
d38dbbc
Refactor redirect guards for tasks
LauraBeatris Feb 20, 2025
1dc30d5
Does not trigger `redirectUrl` logic
LauraBeatris Feb 20, 2025
36e0c91
Trigger navigation on client-piggybacking
LauraBeatris Feb 21, 2025
f1e64f1
Set tasks URL on the sign-in/sign-up context
LauraBeatris Feb 24, 2025
9a5415d
Introduce separate redirect guards for tasks
LauraBeatris Feb 24, 2025
1233d3b
Add unit test for redirection to task
LauraBeatris Feb 24, 2025
e15d124
Introduce skeleton for integration tests
LauraBeatris Feb 25, 2025
b9f8870
Introduce new base skeleton for URL resolution
LauraBeatris Feb 25, 2025
22b4a4a
Add `with-session-tasks` to integration tests
LauraBeatris Feb 26, 2025
af7f79a
Implement integration tests
LauraBeatris Feb 27, 2025
8818499
Do not close modals on `Clerk.navigate` if the origin is not outisde …
LauraBeatris Feb 27, 2025
be6c67e
Add intermediary route for task resolution
LauraBeatris Mar 4, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/old-cherries-laugh.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
---
'@clerk/clerk-js': patch
'@clerk/types': patch
---

Navigate to session tasks on after sign-in/sign-up
4 changes: 4 additions & 0 deletions integration/.keys.json.sample
Original file line numberDiff line numberDiff line change
Expand Up@@ -46,5 +46,9 @@
"with-waitlist-mode": {
"pk": "",
"sk": ""
},
"with-session-tasks": {
"pk": "",
"sk": ""
}
}
8 changes: 8 additions & 0 deletions integration/presets/envs.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -42,6 +42,13 @@ const withEmailCodes = base
.setEnvVariable('public', 'CLERK_PUBLISHABLE_KEY', instanceKeys.get('with-email-codes').pk)
.setEnvVariable('private', 'CLERK_ENCRYPTION_KEY', constants.E2E_CLERK_ENCRYPTION_KEY || 'a-key');

const withSessionTasks = base
.clone()
.setId('withSessionTasks')
.setEnvVariable('private', 'CLERK_SECRET_KEY', instanceKeys.get('with-session-tasks').sk)
.setEnvVariable('public', 'CLERK_PUBLISHABLE_KEY', instanceKeys.get('with-session-tasks').pk)
.setEnvVariable('private', 'CLERK_ENCRYPTION_KEY', constants.E2E_CLERK_ENCRYPTION_KEY || 'a-key');

const withEmailCodes_destroy_client = withEmailCodes
.clone()
.setEnvVariable('public', 'EXPERIMENTAL_PERSIST_CLIENT', 'false');
Expand DownExpand Up@@ -157,4 +164,5 @@ export const envs = {
withSignInOrUpFlow,
withSignInOrUpEmailLinksFlow,
withSignInOrUpwithRestrictedModeFlow,
withSessionTasks,
} as const;
5 changes: 5 additions & 0 deletions integration/presets/longRunningApps.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -37,6 +37,11 @@ export const createLongRunningApps = () => {
config: next.appRouter,
env: envs.withSignInOrUpEmailLinksFlow,
},
{
id: 'next.appRouter.withSessionTasks',
config: next.appRouter,
env: envs.withSessionTasks,
},
{ id: 'quickstart.next.appRouter', config: next.appRouterQuickstart, env: envs.withEmailCodesQuickstart },
{ id: 'elements.next.appRouter', config: elements.nextAppRouter, env: envs.withEmailCodes },
{ id: 'astro.node.withCustomRoles', config: astro.node, env: envs.withCustomRoles },
Expand Down
1 change: 1 addition & 0 deletions integration/tests/session-tasks-multi-session.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
// TODO - add tests
72 changes: 72 additions & 0 deletions integration/tests/session-tasks-sign-in.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
import { expect, test } from '@playwright/test';

import type { Application } from '../models/application';
import { appConfigs } from '../presets';
import type { FakeUser } from '../testUtils';
import { createTestUtils } from '../testUtils';

test.describe('session tasks sign in flow @nextjs', () => {
test.describe.configure({ mode: 'serial' });
let app: Application;
let fakeUser: FakeUser;

test.beforeAll(async () => {
app = await appConfigs.next.appRouter.clone().commit();
await app.setup();
await app.withEnv(appConfigs.envs.withSessionTasks);
await app.dev();

const m = createTestUtils({ app });
fakeUser = m.services.users.createFakeUser({
withPhoneNumber: true,
withUsername: true,
});
await m.services.users.createBapiUser(fakeUser);
});

test.afterAll(async () => {
await fakeUser.deleteIfExists();
await app.teardown();
});

test('on after sign-in, navigates to tasks', async ({ page, context }) => {
const u = createTestUtils({ app, page, context });
await u.po.signIn.goTo();
await u.po.signIn.setIdentifier(fakeUser.email);
await u.po.signIn.continue();
await u.po.signIn.setPassword(fakeUser.password);
await u.po.signIn.continue();
await u.po.expect.toBeSignedIn();
await expect(u.page.getByRole('heading', { name: 'Create Organization' })).toBeVisible();
expect(u.page.url()).toContain('/sign-in/add-organization');
});

test.fixme('redirects to after sign-in url when session tasks has been resolved', () => {
// todo
});

test.fixme('redirects to after sign-in url when accessing root sign in with a active session', {
// todo
});

test('redirects back to tasks when accessing root sign in', async ({ page, context }) => {
const u = createTestUtils({ app, page, context });
await u.po.signIn.goTo();
await u.po.signIn.setIdentifier(fakeUser.email);
await u.po.signIn.continue();
await u.po.signIn.setPassword(fakeUser.password);
await u.po.signIn.continue();
await u.po.expect.toBeSignedIn();
await expect(u.page.getByRole('heading', { name: 'Create Organization' })).toBeVisible();
expect(u.page.url()).toContain('/sign-in/add-organization');
await u.po.signIn.goTo();
await expect(u.page.getByRole('heading', { name: 'Create Organization' })).toBeVisible();
expect(u.page.url()).toContain('/sign-in/add-organization');
});

test('without a session, does not allow to access tasks', async ({ page, context }) => {
const u = createTestUtils({ app, page, context });
await u.page.goto('/sign-in/add-organization');
expect(u.page.url()).not.toContain('/sign-in/add-organization');
});
});
73 changes: 73 additions & 0 deletions integration/tests/session-tasks-sign-up.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
import { expect, test } from '@playwright/test';

import type { Application } from '../models/application';
import { appConfigs } from '../presets';
import { createTestUtils } from '../testUtils';

test.describe('session tasks sign in flow @nextjs', () => {
test.describe.configure({ mode: 'serial' });
let app: Application;

test.beforeAll(async () => {
app = await appConfigs.next.appRouter.clone().commit();
await app.setup();
await app.withEnv(appConfigs.envs.withSessionTasks);
await app.dev();
});

test.afterAll(async () => {
await app.teardown();
});

test('on after sign-up, navigates to tasks', async ({ page, context }) => {
const u = createTestUtils({ app, page, context });
const fakeUser = u.services.users.createFakeUser({
fictionalEmail: true,
});
await u.po.signUp.goTo();
await u.po.signUp.signUpWithEmailAndPassword({
email: fakeUser.email,
password: fakeUser.password,
});
await u.po.signUp.enterTestOtpCode();
await u.po.expect.toBeSignedIn();

await expect(u.page.getByRole('heading', { name: 'Create Organization' })).toBeVisible();
expect(u.page.url()).toContain('/sign-up/add-organization');

await fakeUser.deleteIfExists();
});

test.fixme('redirects to after sign-up url when session tasks has been resolved', () => {
// todo
});

test.fixme('redirects to after sign-up url when accessing root sign in with a active session', {
// todo
});

test('redirects back to tasks when accessing root sign in', async ({ page, context }) => {
const u = createTestUtils({ app, page, context });
const fakeUser = u.services.users.createFakeUser({
fictionalEmail: true,
});
await u.po.signUp.goTo();
await u.po.signUp.signUpWithEmailAndPassword({
email: fakeUser.email,
password: fakeUser.password,
});
await u.po.signUp.enterTestOtpCode();
await u.po.expect.toBeSignedIn();
await expect(u.page.getByRole('heading', { name: 'Create Organization' })).toBeVisible();
expect(u.page.url()).toContain('/sign-up/add-organization');
await u.po.signIn.goTo();
await expect(u.page.getByRole('heading', { name: 'Create Organization' })).toBeVisible();
expect(u.page.url()).toContain('/sign-up/add-organization');
});

test('without a session, does not allow to access tasks', async ({ page, context }) => {
const u = createTestUtils({ app, page, context });
await u.page.goto('/sign-up/add-organization');
expect(u.page.url()).not.toContain('/sign-up/add-organization');
});
});
39 changes: 38 additions & 1 deletion packages/clerk-js/src/core/clerk.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,6 +23,7 @@ import type {
CreateOrganizationParams,
CreateOrganizationProps,
CredentialReturn,
CustomNavigation,
DomainOrProxyUrl,
EnvironmentJSON,
EnvironmentJSONSnapshot,
Expand All@@ -45,6 +46,7 @@ import type {
RedirectOptions,
Resources,
SDKMetadata,
SessionTask,
SetActiveParams,
SignedInSessionResource,
SignInProps,
Expand All@@ -65,6 +67,7 @@ import type {
WaitlistResource,
Web3Provider,
} from '@clerk/types';
import type { SessionTaskRoutePath } from 'ui/common/tasks';

import type { MountComponentRenderer } from '../ui/Components';
import {
Expand DownExpand Up@@ -946,7 +949,10 @@ export class Clerk implements ClerkInterface {
beforeUnloadTracker?.stopTracking();
}

if (redirectUrl && !beforeEmit) {
// Overrides the default behavior of redirects to `afterSignInUrl`
// or `afterSignUpUrl` to redirect the user to their assigned tasks
const hasSessionToResolve = newSession?.currentTask;
if (redirectUrl && !beforeEmit && !hasSessionToResolve) {
beforeUnloadTracker?.startTracking();
this.#setTransitiveState();

Expand DownExpand Up@@ -1728,6 +1734,8 @@ export class Clerk implements ClerkInterface {
if (this.session) {
const session = this.#getSessionFromClient(this.session.id);

this.maybeNavigateToTaskResolution(this.navigate);

// Note: this might set this.session to null
this.#setAccessors(session);

Expand DownExpand Up@@ -2260,4 +2268,33 @@ export class Clerk implements ClerkInterface {

return allowedProtocols;
}

maybeNavigateToTaskResolution(customNavigate?: (to: string) => Promise<unknown>) {
if (!this.session?.currentTask || !inBrowser()) {
return;
}

const isOnTaskResolutionPath = window.location.href.includes('navigate-to-task');
if (isOnTaskResolutionPath) {
return;
}

const url = buildURL({ base: `${this.#options.signInUrl}/navigate-to-task` }, { stringify: true });

void customNavigate?.(url);
}

navigateToTaskPath(customNavigate?: CustomNavigation) {
if (!this.session?.currentTask || !inBrowser()) {
return;
}

const taskKeyToRoutePaths: Record<SessionTask['key'], SessionTaskRoutePath> = {
org: 'add-organization',
};

const routePath = taskKeyToRoutePaths[this.session.currentTask.key];

void customNavigate?.(routePath);
}
}
4 changes: 4 additions & 0 deletions packages/clerk-js/src/core/resources/Session.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -303,4 +303,8 @@ export class Session extends BaseResource implements SessionResource {
return token.getRawString() || null;
});
}

get currentTask(): SessionTask | undefined {
return (this.tasks ?? [])[0];
}
}
23 changes: 23 additions & 0 deletions packages/clerk-js/src/ui/common/TaskNavigation.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
import { Flow } from '../customizables';
import { Card, LoadingCardContainer, withCardStateProvider } from '../elements';

export const TaskNavigation = withCardStateProvider(() => {
return (
<Flow.Part part='taskNavigation'>
<TaskNavigationCard />
</Flow.Part>
);
});

export const TaskNavigationCard = () => {
return (
<Flow.Part part='taskNavigation'>
<Card.Root>
<Card.Content>
<LoadingCardContainer />
</Card.Content>
<Card.Footer />
</Card.Root>
</Flow.Part>
);
};
1 change: 1 addition & 0 deletions packages/clerk-js/src/ui/common/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -13,6 +13,7 @@ export * from './QRCode';
export * from './redirects';
export * from './RemoveResourceForm';
export * from './SSOCallback';
export * from './TaskNavigation';
export * from './verification';
export * from './withRedirect';
export * from './Wizard';
3 changes: 3 additions & 0 deletions packages/clerk-js/src/ui/common/tasks.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
export const sessionTaskRoutePaths = ['add-organization'] as const;

export type SessionTaskRoutePath = (typeof sessionTaskRoutePaths)[number];
12 changes: 10 additions & 2 deletions packages/clerk-js/src/ui/common/withRedirect.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -28,8 +28,16 @@ export function withRedirect<P extends AvailableComponentProps>(
const environment = useEnvironment();
const options = useOptions();

const shouldRedirect = condition(clerk, environment, options);
const hasTasksAndSingleSessionMode = clerk.session?.currentTask && environment?.authConfig.singleSessionMode;
const shouldRedirect =
// Overrides default redirect guards to not lead with race conditions on redirection for session tasks
hasTasksAndSingleSessionMode ? false : condition(clerk, environment, options);
React.useEffect(() => {
if (hasTasksAndSingleSessionMode) {
void clerk.maybeNavigateToTaskResolution(navigate);
return;
}

if (shouldRedirect) {
if (warning && isDevelopmentFromPublishableKey(clerk.publishableKey)) {
console.info(warning);
Expand All@@ -38,7 +46,7 @@ export function withRedirect<P extends AvailableComponentProps>(
// eslint-disable-next-line @typescript-eslint/no-floating-promises
navigate(redirectUrl({ clerk, environment, options }));
}
}, []);
}, [hasTasksAndSingleSessionMode]);

if (shouldRedirect) {
return null;
Expand Down
14 changes: 14 additions & 0 deletions packages/clerk-js/src/ui/components/SignIn/SignIn.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,7 @@ import { useClerk } from '@clerk/shared/react';
import type { SignInModalProps, SignInProps } from '@clerk/types';
import React from 'react';

import { sessionTaskRoutePaths } from '../../../ui/common/tasks';
import { normalizeRoutingOptions } from '../../../utils/normalizeRoutingOptions';
import { SignInEmailLinkFlowComplete, SignUpEmailLinkFlowComplete } from '../../common/EmailLinkCompleteFlowCard';
import type { SignUpContextType } from '../../contexts';
Expand All@@ -19,13 +20,15 @@ import { SignUpSSOCallback } from '../SignUp/SignUpSSOCallback';
import { SignUpStart } from '../SignUp/SignUpStart';
import { SignUpVerifyEmail } from '../SignUp/SignUpVerifyEmail';
import { SignUpVerifyPhone } from '../SignUp/SignUpVerifyPhone';
import { Task } from '../Task';
import { ResetPassword } from './ResetPassword';
import { ResetPasswordSuccess } from './ResetPasswordSuccess';
import { SignInAccountSwitcher } from './SignInAccountSwitcher';
import { SignInFactorOne } from './SignInFactorOne';
import { SignInFactorTwo } from './SignInFactorTwo';
import { SignInSSOCallback } from './SignInSSOCallback';
import { SignInStart } from './SignInStart';
import { SignInTaskNavigation } from './SignInTaskNavigation';

function RedirectToSignIn() {
const clerk = useClerk();
Expand DownExpand Up@@ -132,6 +135,17 @@ function SignInRoutes(): JSX.Element {
</Route>
</Route>
)}
<Route path='navigate-to-task'>
<SignInTaskNavigation />
</Route>
{sessionTaskRoutePaths.map(path => (
<Route
key={path}
path={path}
>
<Task />
</Route>
))}
<Route index>
<SignInStart />
</Route>
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
import { TaskNavigation, withRedirectToAfterSignIn } from '../../common';

export const SignInTaskNavigation = withRedirectToAfterSignIn(TaskNavigation);
Loading