Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(clerk-js,types): Navigate to session tasks #5187

Draft
wants to merge 17 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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 number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@clerk/clerk-js': patch
'@clerk/types': patch
---

Navigate to session tasks
35 changes: 35 additions & 0 deletions integration/tests/session-tasks.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { appConfigs } from '../presets';
import { testAgainstRunningApps } from '../testUtils';

// TODO ORGS-566 - Write integration tests for after-auth flow
testAgainstRunningApps({ withEnv: [appConfigs.envs.withEmailCodes] })('after-auth flows @generic @nextjs', () => {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is just a placeholder for me to come back later which is helpful to visualize the redirection behavior introduced in this PR

The reason integration tests aren't being implemented is that FAPI changes for that are under development and it could break CI

describe('after sign-in', () => {
// /sign-in -> /sign-in/select-organization
it.todo('navigates to tasks');

// /sign-in -> /sign-in/select-organization -> /app (after-sign-in URL)
it.todo('navigates to after-sign-in URL when tasks get resolved');

// with session status pending -> accesses /sign-in -> redirects to /sign-in/select-organization
it.todo('on single-session mode, sign-in redirects back to tasks when accessed with a pending session');
});

describe('after sign-up', () => {
// /sign-up -> /sign-up/select-organization
it.todo('navigates to tasks');

// /sign-up -> /sign-up/select-organization -> /app/welcome (after-sign-up URL)
it.todo('navigates to after-sign-up URL when tasks get resolved');

// with session status pending -> accesses /sign-up -> redirects to /sign-up/select-organization
it.todo('on single-session mode, sign-up redirects back to tasks when accessed with a pending session');
});

describe('when user is using the app and session transitions to active to pending', () => {
// /my-dashboard/recipes -> /sign-in/select-organization
it.todo('on session transition to pending with tasks, redirects to tasks');

// /my-dashboard/recipes -> /sign-in/select-organization -> /my-dashboard/recipes
it.todo('navigates to middle app origin when tasks get resolved');
});
});
20 changes: 20 additions & 0 deletions packages/clerk-js/src/core/__tests__/clerk.redirects.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ describe('Clerk singleton - Redirects', () => {

afterEach(() => {
mockEnvironmentFetch.mockRestore();
mockClientFetch.mockRestore();
});

it('redirects to signInUrl for development instance', async () => {
Expand Down Expand Up @@ -220,6 +221,7 @@ describe('Clerk singleton - Redirects', () => {

afterEach(() => {
mockEnvironmentFetch.mockRestore();
mockClientFetch.mockRestore();
});

const host = 'http://another-test.host';
Expand Down Expand Up @@ -309,4 +311,22 @@ describe('Clerk singleton - Redirects', () => {
expect(mockHref).toHaveBeenNthCalledWith(2, `${host}/?__clerk_db_jwt=deadbeef`);
});
});

describe('.redirectToTasks', () => {
describe('after sign-in with pending session', () => {
it.todo('redirects to tasks URL with after sign-in URL appended as query param');
});

describe('after sign-up with pending session', () => {
it.todo('redirects to tasks URL with after sign-up URL appended as query param');
});

describe('after sign-up with pending session', () => {
it.todo('redirects to tasks URL with after sign-up URL appended as query param');
});

describe('user already exists and session transitions from active to pending on middle app', () => {
it.todo('redirects to tasks URL with app origin appended as query param');
});
});
});
95 changes: 89 additions & 6 deletions packages/clerk-js/src/core/clerk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ import type {
Web3Provider,
} from '@clerk/types';

import { sessionTaskKeyToRoutePaths } from '../ui/common/tasks';
import type { MountComponentRenderer } from '../ui/Components';
import {
ALLOWED_PROTOCOLS,
Expand All @@ -89,11 +90,11 @@ import {
isError,
isOrganizationId,
isRedirectForFAPIInitiatedFlow,
isSignedInAndSingleSessionModeEnabled,
noOrganizationExists,
noUserExists,
removeClerkQueryParam,
requiresUserInput,
sessionExistsAndSingleSessionModeEnabled,
stripOrigin,
windowNavigate,
} from '../utils';
Expand Down Expand Up @@ -427,7 +428,7 @@ export class Clerk implements ClerkInterface {

public openSignIn = (props?: SignInProps): void => {
this.assertComponentsReady(this.#componentControls);
if (sessionExistsAndSingleSessionModeEnabled(this, this.environment)) {
if (isSignedInAndSingleSessionModeEnabled(this, this.environment)) {
if (this.#instanceType === 'development') {
throw new ClerkRuntimeError(warnings.cannotOpenSignInOrSignUp, {
code: 'cannot_render_single_session_enabled',
Expand Down Expand Up @@ -481,7 +482,7 @@ export class Clerk implements ClerkInterface {

public openSignUp = (props?: SignUpProps): void => {
this.assertComponentsReady(this.#componentControls);
if (sessionExistsAndSingleSessionModeEnabled(this, this.environment)) {
if (isSignedInAndSingleSessionModeEnabled(this, this.environment)) {
if (this.#instanceType === 'development') {
throw new ClerkRuntimeError(warnings.cannotOpenSignInOrSignUp, {
code: 'cannot_render_single_session_enabled',
Expand Down Expand Up @@ -889,6 +890,8 @@ export class Clerk implements ClerkInterface {

let newSession = session === undefined ? this.session : session;

const isResolvingSessionTasks = !!newSession?.currentTask || window.location.href.includes(this.buildTasksUrl());

// At this point, the `session` variable should contain either an `SignedInSessionResource`
// ,`null` or `undefined`.
// We now want to set the last active organization id on that session (if it exists).
Expand Down Expand Up @@ -946,7 +949,7 @@ export class Clerk implements ClerkInterface {
beforeUnloadTracker?.stopTracking();
}

if (redirectUrl && !beforeEmit) {
if (redirectUrl && !beforeEmit && !isResolvingSessionTasks) {
beforeUnloadTracker?.startTracking();
this.#setTransitiveState();

Expand Down Expand Up @@ -1004,6 +1007,8 @@ export class Clerk implements ClerkInterface {
return;
}

console.log('Clerk.navigate is navigating to', { to });

/**
* Trigger all navigation listeners. In order for modal UI components to close.
*/
Expand Down Expand Up @@ -1114,6 +1119,56 @@ export class Clerk implements ClerkInterface {
return buildURL({ base: waitlistUrl, hashSearchParams: [initValues] }, { stringify: true });
}

public buildTasksUrl(): string {
const signInUrl = this.#options.signInUrl || this.environment?.displayConfig.signInUrl;
const signUpUrl = this.#options.signUpUrl || this.environment?.displayConfig.signUpUrl;
const currentTask = this.session?.currentTask;

if (!currentTask) {
return '';
}

let redirectUrl = '';
const referrerIsSignInUrl = signInUrl && window.location.href.startsWith(signInUrl);
const referrerIsSignUpUrl = signUpUrl && window.location.href.startsWith(signUpUrl);

if (referrerIsSignInUrl) {
redirectUrl = this.buildAfterSignInUrl();
} else if (referrerIsSignUpUrl) {
redirectUrl = this.buildAfterSignUpUrl();
} else {
/**
* User already has a active session and gets transition to a pending status
* on the client update
*
* It could happen on instance configuration changes that lead to new tasks that
* need to get resolved by the user, eg: Force MFA
*/
// Preserves the origin path, eg: /my-app/recipes -> /sign-in/select-organization -> /my-app/recipes
redirectUrl = window.location.href;
}

/**
* For after sign-in or after sign-up, it's agnostic to the original base path
* in order to avoid having to check for the referrer
*
* If it's coming from a protected route where the user already exists, then
* the base path becomes the sign in URL
*/
const shouldAppendBasePath = !referrerIsSignInUrl && !referrerIsSignUpUrl;

return buildURL(
{
...(shouldAppendBasePath ? { base: signInUrl } : {}),
hashPath: sessionTaskKeyToRoutePaths[currentTask.key],
hashSearchParams: {
redirect_url: redirectUrl,
},
},
{ stringify: true },
);
}

public buildAfterMultiSessionSingleSignOutUrl(): string {
if (!this.#options.afterMultiSessionSingleSignOutUrl) {
return this.buildUrlWithAuth(
Expand Down Expand Up @@ -1235,6 +1290,13 @@ export class Clerk implements ClerkInterface {
return;
};

public redirectToTasks = async (): Promise<unknown> => {
if (inBrowser()) {
return this.navigate(this.buildTasksUrl());
}
return;
};

public handleEmailLinkVerification = async (
params: HandleEmailLinkVerificationParams,
customNavigate?: (to: string) => Promise<unknown>,
Expand Down Expand Up @@ -1728,14 +1790,24 @@ export class Clerk implements ClerkInterface {
if (this.session) {
const session = this.#getSessionFromClient(this.session.id);

const hasResolvedPreviousTask = this.session.currentTask != session?.currentTask;

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

// A client response contains its associated sessions, along with a fresh token, so we dispatch a token update event.
eventBus.dispatch(events.TokenUpdate, { token: this.session?.lastActiveToken });
}

this.#emit();
this.#emit();

// Tasks handling must be reactive on client piggybacking to support
// immediate instance-level configuration changes by app owners
if (session?.currentTask) {
eventBus.dispatch(events.NewSessionTask, session);
} else if (session && hasResolvedPreviousTask) {
eventBus.dispatch(events.ResolvedSessionTask, session);
}
}
};

get __unstable__environment(): EnvironmentResource | null | undefined {
Expand Down Expand Up @@ -2076,6 +2148,17 @@ export class Clerk implements ClerkInterface {
eventBus.on(events.UserSignOut, () => {
this.#broadcastChannel?.postMessage({ type: 'signout' });
});

eventBus.on(events.NewSessionTask, () => {
void this.redirectToTasks();
});

eventBus.on(events.ResolvedSessionTask, () => {
// `redirect_url` gets appended based on the origin (after sign-in vs after sign-up),
// if it gets accidentally deleted, then fallbacks to the sign in URL
const redirectUrl = new URLSearchParams(window.location.search).get('redirect_url') ?? this.buildSignInUrl();
void this.navigate(redirectUrl);
});
};

// TODO: Be more conservative about touches. Throttle, don't touch when only one user, etc
Expand Down
6 changes: 5 additions & 1 deletion packages/clerk-js/src/core/events.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import type { TokenResource } from '@clerk/types';
import type { SignedInSessionResource, TokenResource } from '@clerk/types';

export const events = {
TokenUpdate: 'token:update',
UserSignOut: 'user:signOut',
NewSessionTask: 'sessionTask:new',
ResolvedSessionTask: 'sessionTask:resolve',
} as const;

type ClerkEvent = (typeof events)[keyof typeof events];
Expand All @@ -13,6 +15,8 @@ type TokenUpdatePayload = { token: TokenResource | null };
type EventPayload = {
[events.TokenUpdate]: TokenUpdatePayload;
[events.UserSignOut]: null;
[events.NewSessionTask]: SignedInSessionResource;
[events.ResolvedSessionTask]: SignedInSessionResource;
};

const createEventBus = () => {
Expand Down
4 changes: 4 additions & 0 deletions packages/clerk-js/src/core/resources/Session.ts
Original file line number Diff line number Diff 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];
}
}
2 changes: 2 additions & 0 deletions packages/clerk-js/src/core/warnings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ const warnings = {
'The <SignUp/> component cannot render when a user is already signed in, unless the application allows multiple sessions. Since a user is signed in and this application only allows a single session, Clerk is redirecting to the value set in `afterSignUp` URL instead.',
cannotRenderSignInComponentWhenSessionExists:
'The <SignIn/> component cannot render when a user is already signed in, unless the application allows multiple sessions. Since a user is signed in and this application only allows a single session, Clerk is redirecting to the `afterSignIn` URL instead.',
cannotRenderComponentWithPendingTasks:
'The component cannot render when a user has pending tasks to resolve, unless the application allows multiple sessions. Since a user is signed in and this application only allows a single session, Clerk is redirecting to the `tasksUrl` instead.',
cannotRenderComponentWhenUserDoesNotExist:
'<UserProfile/> cannot render unless a user is signed in. Since no user is signed in, this is no-op.',
cannotRenderComponentWhenOrgDoesNotExist: `<OrganizationProfile/> cannot render unless an organization is active. Since no organization is currently active, this is no-op.`,
Expand Down
12 changes: 12 additions & 0 deletions packages/clerk-js/src/ui/common/tasks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import type { SessionTask } from '@clerk/types';

export const sessionTaskRoutePaths = ['select-organization'] as const;

type SessionTaskRoutePath = (typeof sessionTaskRoutePaths)[number];

/**
* @internal
*/
export const sessionTaskKeyToRoutePaths: Record<SessionTask['key'], SessionTaskRoutePath> = {
org: 'select-organization',
};
26 changes: 22 additions & 4 deletions packages/clerk-js/src/ui/common/withRedirect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import React from 'react';

import { warnings } from '../../core/warnings';
import type { ComponentGuard } from '../../utils';
import { sessionExistsAndSingleSessionModeEnabled } from '../../utils';
import { hasPendingTasksAndSingleSessionModeEnabled, isSignedInAndSingleSessionModeEnabled } from '../../utils';
import { useEnvironment, useOptions, useSignInContext, useSignUpContext } from '../contexts';
import { useRouter } from '../router';
import type { AvailableComponentProps } from '../types';
Expand Down Expand Up @@ -52,6 +52,24 @@ export function withRedirect<P extends AvailableComponentProps>(
return HOC;
}

export const withRedirectToTasks = <P extends AvailableComponentProps>(Component: ComponentType<P>) => {
const displayName = Component.displayName || Component.name || 'Component';
Component.displayName = displayName;

const HOC = (props: P) => {
return withRedirect(
Component,
hasPendingTasksAndSingleSessionModeEnabled,
({ clerk }) => clerk.buildTasksUrl(),
warnings.cannotRenderComponentWithPendingTasks,
)(props);
};

HOC.displayName = `withRedirectToTasks(${displayName})`;

return HOC;
};

export const withRedirectToAfterSignIn = <P extends AvailableComponentProps>(Component: ComponentType<P>) => {
const displayName = Component.displayName || Component.name || 'Component';
Component.displayName = displayName;
Expand All @@ -60,7 +78,7 @@ export const withRedirectToAfterSignIn = <P extends AvailableComponentProps>(Com
const signInCtx = useSignInContext();
return withRedirect(
Component,
sessionExistsAndSingleSessionModeEnabled,
isSignedInAndSingleSessionModeEnabled,
({ clerk }) => signInCtx.afterSignInUrl || clerk.buildAfterSignInUrl(),
warnings.cannotRenderSignInComponentWhenSessionExists,
)(props);
Expand All @@ -79,7 +97,7 @@ export const withRedirectToAfterSignUp = <P extends AvailableComponentProps>(Com
const signUpCtx = useSignUpContext();
return withRedirect(
Component,
sessionExistsAndSingleSessionModeEnabled,
isSignedInAndSingleSessionModeEnabled,
({ clerk }) => signUpCtx.afterSignUpUrl || clerk.buildAfterSignUpUrl(),
warnings.cannotRenderSignUpComponentWhenSessionExists,
)(props);
Expand All @@ -93,7 +111,7 @@ export const withRedirectToAfterSignUp = <P extends AvailableComponentProps>(Com
export const withRedirectToHomeSingleSessionGuard = <P extends AvailableComponentProps>(Component: ComponentType<P>) =>
withRedirect(
Component,
sessionExistsAndSingleSessionModeEnabled,
isSignedInAndSingleSessionModeEnabled,
({ environment }) => environment.displayConfig.homeUrl,
warnings.cannotRenderComponentWhenSessionExists,
);
Loading
Loading