diff --git a/.changeset/old-cherries-laugh.md b/.changeset/old-cherries-laugh.md new file mode 100644 index 00000000000..44ed51ac371 --- /dev/null +++ b/.changeset/old-cherries-laugh.md @@ -0,0 +1,6 @@ +--- +'@clerk/clerk-js': patch +'@clerk/types': patch +--- + +Navigate to session tasks on after sign-in/sign-up diff --git a/integration/.keys.json.sample b/integration/.keys.json.sample index caa70922c39..1aeddba9b72 100644 --- a/integration/.keys.json.sample +++ b/integration/.keys.json.sample @@ -46,5 +46,9 @@ "with-waitlist-mode": { "pk": "", "sk": "" + }, + "with-session-tasks": { + "pk": "", + "sk": "" } } diff --git a/integration/presets/envs.ts b/integration/presets/envs.ts index 4fbf4d2bb22..77bf8f93507 100644 --- a/integration/presets/envs.ts +++ b/integration/presets/envs.ts @@ -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'); @@ -157,4 +164,5 @@ export const envs = { withSignInOrUpFlow, withSignInOrUpEmailLinksFlow, withSignInOrUpwithRestrictedModeFlow, + withSessionTasks, } as const; diff --git a/integration/presets/longRunningApps.ts b/integration/presets/longRunningApps.ts index d5573f015e0..f595591a3c3 100644 --- a/integration/presets/longRunningApps.ts +++ b/integration/presets/longRunningApps.ts @@ -37,6 +37,11 @@ export const createLongRunningApps = () => { config: next.appRouter, env: envs.withSignInOrUpEmailLinksFlow, }, + { + id: 'next.appRouter.withSessionTasks', + config: next.appRouter, + env: envs.withSessionTasks, + }, { id: 'quickstart.next.appRouter', config: next.appRouterQuickstart, env: envs.withEmailCodesQuickstart }, { id: 'elements.next.appRouter', config: elements.nextAppRouter, env: envs.withEmailCodes }, { id: 'astro.node.withCustomRoles', config: astro.node, env: envs.withCustomRoles }, diff --git a/integration/tests/session-tasks-multi-session.test.ts b/integration/tests/session-tasks-multi-session.test.ts new file mode 100644 index 00000000000..4b6ccccc84f --- /dev/null +++ b/integration/tests/session-tasks-multi-session.test.ts @@ -0,0 +1 @@ +// TODO - add tests diff --git a/integration/tests/session-tasks-sign-in.test.ts b/integration/tests/session-tasks-sign-in.test.ts new file mode 100644 index 00000000000..fcb1b4524fd --- /dev/null +++ b/integration/tests/session-tasks-sign-in.test.ts @@ -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'); + }); +}); diff --git a/integration/tests/session-tasks-sign-up.test.ts b/integration/tests/session-tasks-sign-up.test.ts new file mode 100644 index 00000000000..f438f37f057 --- /dev/null +++ b/integration/tests/session-tasks-sign-up.test.ts @@ -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'); + }); +}); diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index 7470b340788..fdc5b8a079f 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -23,6 +23,7 @@ import type { CreateOrganizationParams, CreateOrganizationProps, CredentialReturn, + CustomNavigation, DomainOrProxyUrl, EnvironmentJSON, EnvironmentJSONSnapshot, @@ -45,6 +46,7 @@ import type { RedirectOptions, Resources, SDKMetadata, + SessionTask, SetActiveParams, SignedInSessionResource, SignInProps, @@ -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 { @@ -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(); @@ -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); @@ -2260,4 +2268,33 @@ export class Clerk implements ClerkInterface { return allowedProtocols; } + + maybeNavigateToTaskResolution(customNavigate?: (to: string) => Promise) { + 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 = { + org: 'add-organization', + }; + + const routePath = taskKeyToRoutePaths[this.session.currentTask.key]; + + void customNavigate?.(routePath); + } } diff --git a/packages/clerk-js/src/core/resources/Session.ts b/packages/clerk-js/src/core/resources/Session.ts index d10845636f2..4ac8f4ad4a6 100644 --- a/packages/clerk-js/src/core/resources/Session.ts +++ b/packages/clerk-js/src/core/resources/Session.ts @@ -303,4 +303,8 @@ export class Session extends BaseResource implements SessionResource { return token.getRawString() || null; }); } + + get currentTask(): SessionTask | undefined { + return (this.tasks ?? [])[0]; + } } diff --git a/packages/clerk-js/src/ui/common/TaskNavigation.tsx b/packages/clerk-js/src/ui/common/TaskNavigation.tsx new file mode 100644 index 00000000000..28b3cca241d --- /dev/null +++ b/packages/clerk-js/src/ui/common/TaskNavigation.tsx @@ -0,0 +1,23 @@ +import { Flow } from '../customizables'; +import { Card, LoadingCardContainer, withCardStateProvider } from '../elements'; + +export const TaskNavigation = withCardStateProvider(() => { + return ( + + + + ); +}); + +export const TaskNavigationCard = () => { + return ( + + + + + + + + + ); +}; diff --git a/packages/clerk-js/src/ui/common/index.ts b/packages/clerk-js/src/ui/common/index.ts index bea7cf9e442..2aade4fa1f7 100644 --- a/packages/clerk-js/src/ui/common/index.ts +++ b/packages/clerk-js/src/ui/common/index.ts @@ -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'; diff --git a/packages/clerk-js/src/ui/common/tasks.ts b/packages/clerk-js/src/ui/common/tasks.ts new file mode 100644 index 00000000000..3f64e32a293 --- /dev/null +++ b/packages/clerk-js/src/ui/common/tasks.ts @@ -0,0 +1,3 @@ +export const sessionTaskRoutePaths = ['add-organization'] as const; + +export type SessionTaskRoutePath = (typeof sessionTaskRoutePaths)[number]; diff --git a/packages/clerk-js/src/ui/common/withRedirect.tsx b/packages/clerk-js/src/ui/common/withRedirect.tsx index 43ec9b95172..a46a2eeb65f 100644 --- a/packages/clerk-js/src/ui/common/withRedirect.tsx +++ b/packages/clerk-js/src/ui/common/withRedirect.tsx @@ -28,8 +28,16 @@ export function withRedirect

( 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); @@ -38,7 +46,7 @@ export function withRedirect

( // eslint-disable-next-line @typescript-eslint/no-floating-promises navigate(redirectUrl({ clerk, environment, options })); } - }, []); + }, [hasTasksAndSingleSessionMode]); if (shouldRedirect) { return null; diff --git a/packages/clerk-js/src/ui/components/SignIn/SignIn.tsx b/packages/clerk-js/src/ui/components/SignIn/SignIn.tsx index a767b6dbf1e..89493cc0049 100644 --- a/packages/clerk-js/src/ui/components/SignIn/SignIn.tsx +++ b/packages/clerk-js/src/ui/components/SignIn/SignIn.tsx @@ -2,6 +2,7 @@ import { useClerk } from '@clerk/shared/react'; import type { SignInModalProps, SignInProps } from '@clerk/types'; import React from 'react'; +import { sessionTaskRoutePaths } from '../../../ui/common/tasks'; import { normalizeRoutingOptions } from '../../../utils/normalizeRoutingOptions'; import { SignInEmailLinkFlowComplete, SignUpEmailLinkFlowComplete } from '../../common/EmailLinkCompleteFlowCard'; import type { SignUpContextType } from '../../contexts'; @@ -19,6 +20,7 @@ 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'; @@ -26,6 +28,7 @@ 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(); @@ -132,6 +135,17 @@ function SignInRoutes(): JSX.Element { )} + + + + {sessionTaskRoutePaths.map(path => ( + + + + ))} diff --git a/packages/clerk-js/src/ui/components/SignIn/SignInTaskNavigation.tsx b/packages/clerk-js/src/ui/components/SignIn/SignInTaskNavigation.tsx new file mode 100644 index 00000000000..495f0d3fd1d --- /dev/null +++ b/packages/clerk-js/src/ui/components/SignIn/SignInTaskNavigation.tsx @@ -0,0 +1,3 @@ +import { TaskNavigation, withRedirectToAfterSignIn } from '../../common'; + +export const SignInTaskNavigation = withRedirectToAfterSignIn(TaskNavigation); diff --git a/packages/clerk-js/src/ui/components/SignUp/SignUp.tsx b/packages/clerk-js/src/ui/components/SignUp/SignUp.tsx index 430ae2f53a9..24bcbbe41c1 100644 --- a/packages/clerk-js/src/ui/components/SignUp/SignUp.tsx +++ b/packages/clerk-js/src/ui/components/SignUp/SignUp.tsx @@ -2,10 +2,12 @@ import { useClerk } from '@clerk/shared/react'; import type { SignUpModalProps, SignUpProps } from '@clerk/types'; import React from 'react'; +import { sessionTaskRoutePaths } from '../../../ui/common/tasks'; import { SignUpEmailLinkFlowComplete } from '../../common/EmailLinkCompleteFlowCard'; import { SignUpContext, useSignUpContext, withCoreSessionSwitchGuard } from '../../contexts'; import { Flow } from '../../customizables'; import { Route, Switch, VIRTUAL_ROUTER_BASE_PATH } from '../../router'; +import { Task } from '../Task'; import { SignUpContinue } from './SignUpContinue'; import { SignUpSSOCallback } from './SignUpSSOCallback'; import { SignUpStart } from './SignUpStart'; @@ -74,6 +76,15 @@ function SignUpRoutes(): JSX.Element { + {sessionTaskRoutePaths.map(path => ( + !!clerk.session?.currentTask} + > + + + ))} diff --git a/packages/clerk-js/src/ui/components/Task/Task.tsx b/packages/clerk-js/src/ui/components/Task/Task.tsx new file mode 100644 index 00000000000..75d5c89ed81 --- /dev/null +++ b/packages/clerk-js/src/ui/components/Task/Task.tsx @@ -0,0 +1,29 @@ +import { useSessionContext } from '@clerk/shared/react/index'; +import type { SessionTask } from '@clerk/types'; +import { type ComponentType } from 'react'; + +import { OrganizationListContext } from '../../contexts'; +import { OrganizationList } from '../OrganizationList'; + +const TaskRegistry: Record = { + org: () => ( + + + + ), +}; + +/** + * @internal + */ +export function Task(): React.ReactNode { + const session = useSessionContext(); + + if (!session?.currentTask) { + return null; + } + + const Content = TaskRegistry[session.currentTask.key]; + + return Content ? : null; +} diff --git a/packages/clerk-js/src/ui/components/Task/__tests__/Task.test.tsx b/packages/clerk-js/src/ui/components/Task/__tests__/Task.test.tsx new file mode 100644 index 00000000000..7c20395b8c8 --- /dev/null +++ b/packages/clerk-js/src/ui/components/Task/__tests__/Task.test.tsx @@ -0,0 +1,137 @@ +import { render, waitFor } from '../../../../testUtils'; +import { createFakeUserOrganizationMembership } from '../../../../ui/components/OrganizationSwitcher/__tests__/utlis'; +import { bindCreateFixtures } from '../../../utils/test/createFixtures'; +import { Task } from '../Task'; + +describe('Task', () => { + describe.each(['SignIn', 'SignUp'] satisfies Array[0]>)( + 'after %s flow', + flow => { + const { createFixtures } = bindCreateFixtures(flow); + + describe('with task', () => { + it('renders the component', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withOrganizations(); + f.withUser({ + email_addresses: ['test@clerk.com'], + create_organization_enabled: true, + session: { + status: 'pending', + tasks: [{ key: 'org' }], + }, + }); + }); + + fixtures.clerk.user?.getOrganizationMemberships.mockReturnValueOnce( + Promise.resolve({ + data: [ + createFakeUserOrganizationMembership({ + id: '1', + organization: { + id: '1', + name: 'Org1', + slug: 'org1', + membersCount: 1, + adminDeleteEnabled: false, + maxAllowedMemberships: 1, + pendingInvitationsCount: 1, + }, + }), + ], + total_count: 1, + }), + ); + + const { queryByRole } = render(, { wrapper }); + await waitFor(() => { + expect(queryByRole('heading', { name: /choose an organization/i })).toBeInTheDocument(); + }); + }); + }); + + describe('without task', () => { + it('does not render the component', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withOrganizations(); + f.withUser({ + email_addresses: ['test@clerk.com'], + create_organization_enabled: true, + session: { + status: 'active', + }, + }); + }); + + fixtures.clerk.user?.getOrganizationMemberships.mockReturnValueOnce( + Promise.resolve({ + data: [ + createFakeUserOrganizationMembership({ + id: '1', + organization: { + id: '1', + name: 'Org1', + slug: 'org1', + membersCount: 1, + adminDeleteEnabled: false, + maxAllowedMemberships: 1, + pendingInvitationsCount: 1, + }, + }), + ], + total_count: 1, + }), + ); + + const { queryByRole } = render(, { wrapper }); + await waitFor(() => { + expect(queryByRole('heading', { name: /choose an organization/i })).not.toBeInTheDocument(); + }); + }); + }); + + describe('with invalid key', () => { + it('does not render the component', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withOrganizations(); + f.withUser({ + email_addresses: ['test@clerk.com'], + create_organization_enabled: true, + session: { + status: 'pending', + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + tasks: [{ key: 'invalid' }], + }, + }); + }); + + fixtures.clerk.user?.getOrganizationMemberships.mockReturnValueOnce( + Promise.resolve({ + data: [ + createFakeUserOrganizationMembership({ + id: '1', + organization: { + id: '1', + name: 'Org1', + slug: 'org1', + membersCount: 1, + adminDeleteEnabled: false, + maxAllowedMemberships: 1, + pendingInvitationsCount: 1, + }, + }), + ], + total_count: 1, + }), + ); + + const { queryByRole } = render(, { wrapper }); + await waitFor(() => { + expect(queryByRole('heading', { name: /choose an organization/i })).not.toBeInTheDocument(); + }); + }); + }); + }, + ); +}); diff --git a/packages/clerk-js/src/ui/components/Task/index.ts b/packages/clerk-js/src/ui/components/Task/index.ts new file mode 100644 index 00000000000..e6118bd890e --- /dev/null +++ b/packages/clerk-js/src/ui/components/Task/index.ts @@ -0,0 +1 @@ +export { Task } from './Task'; diff --git a/packages/clerk-js/src/ui/elements/contexts/FlowMetadataContext.tsx b/packages/clerk-js/src/ui/elements/contexts/FlowMetadataContext.tsx index a6937d6387a..213c590947f 100644 --- a/packages/clerk-js/src/ui/elements/contexts/FlowMetadataContext.tsx +++ b/packages/clerk-js/src/ui/elements/contexts/FlowMetadataContext.tsx @@ -32,6 +32,7 @@ type FlowMetadata = { | 'passwordPwnedMethods' | 'havingTrouble' | 'ssoCallback' + | 'taskNavigation' | 'popover' | 'complete' | 'accountSwitcher'; diff --git a/packages/clerk-js/src/ui/utils/test/fixtureHelpers.ts b/packages/clerk-js/src/ui/utils/test/fixtureHelpers.ts index 618589791b0..b2bb53695e7 100644 --- a/packages/clerk-js/src/ui/utils/test/fixtureHelpers.ts +++ b/packages/clerk-js/src/ui/utils/test/fixtureHelpers.ts @@ -49,6 +49,7 @@ const createUserFixtureHelpers = (baseClient: ClientJSON) => { external_accounts?: Array>; saml_accounts?: Array>; organization_memberships?: Array; + session?: Partial; }; const createPublicUserData = (params: WithUserParams) => { @@ -62,7 +63,7 @@ const createUserFixtureHelpers = (baseClient: ClientJSON) => { } as PublicUserDataJSON; }; - const withUser = (params: WithUserParams) => { + const withUser = ({ session: sessionOverrides, ...params }: WithUserParams) => { baseClient.sessions = baseClient.sessions || []; // set the first organization as active @@ -87,6 +88,7 @@ const createUserFixtureHelpers = (baseClient: ClientJSON) => { last_active_token: { jwt: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2NzU4NzY3OTAsImRhdGEiOiJmb29iYXIiLCJpYXQiOjE2NzU4NzY3MzB9.Z1BC47lImYvaAtluJlY-kBo0qOoAk42Xb-gNrB2SxJg', }, + ...sessionOverrides, } as SessionJSON; baseClient.sessions.push(session); }; diff --git a/packages/clerk-js/src/utils/componentGuards.ts b/packages/clerk-js/src/utils/componentGuards.ts index f0084a6a47d..2552f347c48 100644 --- a/packages/clerk-js/src/utils/componentGuards.ts +++ b/packages/clerk-js/src/utils/componentGuards.ts @@ -7,7 +7,7 @@ export type ComponentGuard = ( ) => boolean; export const sessionExistsAndSingleSessionModeEnabled: ComponentGuard = (clerk, environment) => { - return !!(clerk.session && environment?.authConfig.singleSessionMode); + return !!(clerk.isSignedIn && environment?.authConfig.singleSessionMode); }; export const noUserExists: ComponentGuard = clerk => { diff --git a/packages/types/src/clerk.ts b/packages/types/src/clerk.ts index 1296685e084..3f6a3ed87d4 100644 --- a/packages/types/src/clerk.ts +++ b/packages/types/src/clerk.ts @@ -560,6 +560,8 @@ export interface Clerk { customNavigate?: (to: string) => Promise, ) => Promise; + maybeNavigateToTaskResolution: (customNavigate?: (to: string) => Promise) => void; + /** * Authenticates user using their Metamask browser extension */ diff --git a/packages/types/src/jwt.ts b/packages/types/src/jwt.ts index 3b078fd2986..fea8008e263 100644 --- a/packages/types/src/jwt.ts +++ b/packages/types/src/jwt.ts @@ -1,3 +1,5 @@ +import type { SessionStatus } from 'session'; + import type { OrganizationCustomRoleKey } from './organizationMembership'; export interface JWT { @@ -46,6 +48,11 @@ export interface ClerkJWTClaims { */ sid: string; + /** + * Session status + */ + sts: SessionStatus; + /** * JWT Not Before - [RFC7519#section-4.1.5](https://tools.ietf.org/html/rfc7519#section-4.1.5). */ diff --git a/packages/types/src/jwtv2.ts b/packages/types/src/jwtv2.ts index 2ce8ed3b323..94d253d2456 100644 --- a/packages/types/src/jwtv2.ts +++ b/packages/types/src/jwtv2.ts @@ -1,4 +1,5 @@ import type { OrganizationCustomPermissionKey, OrganizationCustomRoleKey } from './organizationMembership'; +import type { SessionStatus } from './session'; export interface Jwt { header: JwtHeader; @@ -56,6 +57,11 @@ export interface JwtPayload extends CustomJwtSessionClaims { */ sid: string; + /** + * Session status + */ + sts?: SessionStatus; + /** * JWT Not Before - [RFC7519#section-4.1.5](https://tools.ietf.org/html/rfc7519#section-4.1.5). */ diff --git a/packages/types/src/session.ts b/packages/types/src/session.ts index db4772c3813..ac809ce0d7c 100644 --- a/packages/types/src/session.ts +++ b/packages/types/src/session.ts @@ -104,6 +104,7 @@ export interface SessionResource extends ClerkResource { lastActiveAt: Date; actor: ActJWTClaim | null; tasks: Array | null; + currentTask?: SessionTask; user: UserResource | null; publicUserData: PublicUserData; end: () => Promise; @@ -197,7 +198,7 @@ export interface PublicUserData { } export interface SessionTask { - key: 'orgs'; + key: 'org'; } export type GetTokenOptions = {