diff --git a/.changeset/common-mice-ring.md b/.changeset/common-mice-ring.md new file mode 100644 index 00000000000..5363e043892 --- /dev/null +++ b/.changeset/common-mice-ring.md @@ -0,0 +1,5 @@ +--- +'@clerk/clerk-js': patch +--- + +Prevent organization list from displaying after creating an organization through the force organization selection flow diff --git a/packages/clerk-js/bundlewatch.config.json b/packages/clerk-js/bundlewatch.config.json index d044e48372b..b6e1054e5d4 100644 --- a/packages/clerk-js/bundlewatch.config.json +++ b/packages/clerk-js/bundlewatch.config.json @@ -1,10 +1,10 @@ { "files": [ - { "path": "./dist/clerk.js", "maxSize": "610.32kB" }, - { "path": "./dist/clerk.browser.js", "maxSize": "70.2KB" }, - { "path": "./dist/clerk.legacy.browser.js", "maxSize": "113KB" }, - { "path": "./dist/clerk.headless*.js", "maxSize": "53.06KB" }, - { "path": "./dist/ui-common*.js", "maxSize": "108.75KB" }, + { "path": "./dist/clerk.js", "maxSize": "612kB" }, + { "path": "./dist/clerk.browser.js", "maxSize": "72.2KB" }, + { "path": "./dist/clerk.legacy.browser.js", "maxSize": "115KB" }, + { "path": "./dist/clerk.headless*.js", "maxSize": "55KB" }, + { "path": "./dist/ui-common*.js", "maxSize": "110KB" }, { "path": "./dist/vendors*.js", "maxSize": "40.2KB" }, { "path": "./dist/coinbase*.js", "maxSize": "38KB" }, { "path": "./dist/createorganization*.js", "maxSize": "5KB" }, @@ -25,6 +25,6 @@ { "path": "./dist/paymentSources*.js", "maxSize": "9.17KB" }, { "path": "./dist/up-billing-page*.js", "maxSize": "3.0KB" }, { "path": "./dist/op-billing-page*.js", "maxSize": "3.0KB" }, - { "path": "./dist/sessionTasks*.js", "maxSize": "1KB" } + { "path": "./dist/sessionTasks*.js", "maxSize": "1.5KB" } ] } diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index d67438da176..7dc496acdd1 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -1283,7 +1283,16 @@ export class Clerk implements ClerkInterface { }; public __experimental_navigateToTask = async ({ redirectUrlComplete }: NextTaskParams = {}): Promise => { - const session = await this.session?.reload(); + /** + * Invalidate previously cache pages with auth state before navigating + */ + const onBeforeSetActive: SetActiveHook = + typeof window !== 'undefined' && typeof window.__unstable__onBeforeSetActive === 'function' + ? window.__unstable__onBeforeSetActive + : noop; + await onBeforeSetActive(); + + const session = this.session; if (!session || !this.environment) { return; } @@ -1301,8 +1310,6 @@ export class Clerk implements ClerkInterface { const tracker = createBeforeUnloadTracker(this.#options.standardBrowser); const defaultRedirectUrlComplete = this.client?.signUp ? this.buildAfterSignUpUrl() : this.buildAfterSignInUrl(); - this.#setTransitiveState(); - await tracker.track(async () => { await this.navigate(redirectUrlComplete ?? defaultRedirectUrlComplete); }); @@ -1313,6 +1320,17 @@ export class Clerk implements ClerkInterface { this.#setAccessors(session); this.#emit(); + + /** + * Invoke the Next.js middleware to synchronize server and client state after resolving a session task. + * This ensures that any server-side logic depending on the session status (like middleware-based + * redirects or protected routes) correctly reflects the updated client authentication state. + */ + const onAfterSetActive: SetActiveHook = + typeof window !== 'undefined' && typeof window.__unstable__onAfterSetActive === 'function' + ? window.__unstable__onAfterSetActive + : noop; + await onAfterSetActive(); }; public addListener = (listener: ListenerCallback): UnsubscribeCallback => { diff --git a/packages/clerk-js/src/ui/components/CreateOrganization/CreateOrganizationForm.tsx b/packages/clerk-js/src/ui/components/CreateOrganization/CreateOrganizationForm.tsx index d23faa00a6b..e832ab82f08 100644 --- a/packages/clerk-js/src/ui/components/CreateOrganization/CreateOrganizationForm.tsx +++ b/packages/clerk-js/src/ui/components/CreateOrganization/CreateOrganizationForm.tsx @@ -1,7 +1,8 @@ import { useOrganization, useOrganizationList } from '@clerk/shared/react'; import type { CreateOrganizationParams, OrganizationResource } from '@clerk/types'; -import React from 'react'; +import React, { useContext } from 'react'; +import { SessionTasksContext } from '@/ui/contexts/components/SessionTasks'; import { useCardState, withCardStateProvider } from '@/ui/elements/contexts'; import { Form } from '@/ui/elements/Form'; import { FormButtonContainer } from '@/ui/elements/FormButtons'; @@ -23,7 +24,7 @@ import { organizationListParams } from '../OrganizationSwitcher/utils'; type CreateOrganizationFormProps = { skipInvitationScreen: boolean; - navigateAfterCreateOrganization: (organization: OrganizationResource) => Promise; + navigateAfterCreateOrganization?: (organization: OrganizationResource) => Promise; onCancel?: () => void; onComplete?: () => void; flow: 'default' | 'organizationList'; @@ -37,6 +38,7 @@ type CreateOrganizationFormProps = { export const CreateOrganizationForm = withCardStateProvider((props: CreateOrganizationFormProps) => { const card = useCardState(); const wizard = useWizard({ onNextStep: () => card.setError(undefined) }); + const sessionTasksContext = useContext(SessionTasksContext); const lastCreatedOrganizationRef = React.useRef(null); const { createOrganization, isLoaded, setActive, userMemberships } = useOrganizationList({ @@ -87,6 +89,11 @@ export const CreateOrganizationForm = withCardStateProvider((props: CreateOrgani void userMemberships.revalidate?.(); + if (sessionTasksContext) { + await sessionTasksContext.nextTask(); + return; + } + if (props.skipInvitationScreen ?? organization.maxAllowedMemberships === 1) { return completeFlow(); } @@ -100,7 +107,7 @@ export const CreateOrganizationForm = withCardStateProvider((props: CreateOrgani const completeFlow = () => { // We are confident that lastCreatedOrganizationRef.current will never be null // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - void props.navigateAfterCreateOrganization(lastCreatedOrganizationRef.current!); + void props.navigateAfterCreateOrganization?.(lastCreatedOrganizationRef.current!); props.onComplete?.(); }; diff --git a/packages/clerk-js/src/ui/components/OrganizationList/OrganizationListPage.tsx b/packages/clerk-js/src/ui/components/OrganizationList/OrganizationListPage.tsx index 87e0958bfe4..6bf1dcc6c1f 100644 --- a/packages/clerk-js/src/ui/components/OrganizationList/OrganizationListPage.tsx +++ b/packages/clerk-js/src/ui/components/OrganizationList/OrganizationListPage.tsx @@ -1,5 +1,5 @@ import { useOrganizationList, useUser } from '@clerk/shared/react'; -import { useContext, useState } from 'react'; +import { useState } from 'react'; import { Action, Actions } from '@/ui/elements/Actions'; import { Card } from '@/ui/elements/Card'; @@ -7,7 +7,6 @@ import { useCardState, withCardStateProvider } from '@/ui/elements/contexts'; import { Header } from '@/ui/elements/Header'; import { useEnvironment, useOrganizationListContext } from '../../contexts'; -import { SessionTasksContext } from '../../contexts/components/SessionTasks'; import { Box, Col, descriptors, Flex, localizationKeys, Spinner } from '../../customizables'; import { useInView } from '../../hooks'; import { Add } from '../../icons'; @@ -116,7 +115,6 @@ export const OrganizationListPage = withCardStateProvider(() => { const OrganizationListFlows = ({ showListInitially }: { showListInitially: boolean }) => { const { navigateAfterCreateOrganization, skipInvitationScreen, hideSlug } = useOrganizationListContext(); const [isCreateOrganizationFlow, setCreateOrganizationFlow] = useState(!showListInitially); - const sessionTasksContext = useContext(SessionTasksContext); return ( <> {!isCreateOrganizationFlow && ( @@ -131,7 +129,6 @@ const OrganizationListFlows = ({ showListInitially }: { showListInitially: boole > @@ -148,7 +145,7 @@ const OrganizationListFlows = ({ showListInitially }: { showListInitially: boole ); }; -const OrganizationListPageList = (props: { onCreateOrganizationClick: () => void }) => { +export const OrganizationListPageList = (props: { onCreateOrganizationClick: () => void }) => { const environment = useEnvironment(); const { ref, userMemberships, userSuggestions, userInvitations } = useOrganizationListInView(); diff --git a/packages/clerk-js/src/ui/components/SessionTasks/index.tsx b/packages/clerk-js/src/ui/components/SessionTasks/index.tsx index 31bc9b41ade..ab7f9191a06 100644 --- a/packages/clerk-js/src/ui/components/SessionTasks/index.tsx +++ b/packages/clerk-js/src/ui/components/SessionTasks/index.tsx @@ -1,22 +1,18 @@ import { useClerk } from '@clerk/shared/react'; import { eventComponentMounted } from '@clerk/shared/telemetry'; -import type { SessionTask } from '@clerk/types'; -import { useCallback, useContext, useEffect } from 'react'; +import { useCallback, useContext, useEffect, useState } from 'react'; import { Card } from '@/ui/elements/Card'; import { withCardStateProvider } from '@/ui/elements/contexts'; import { LoadingCardContainer } from '@/ui/elements/LoadingCard'; import { SESSION_TASK_ROUTE_BY_KEY } from '../../../core/sessionTasks'; -import { OrganizationListContext, SignInContext, SignUpContext } from '../../../ui/contexts'; -import { - SessionTasksContext as SessionTasksContext, - useSessionTasksContext, -} from '../../contexts/components/SessionTasks'; +import { SignInContext, SignUpContext } from '../../../ui/contexts'; +import { SessionTasksContext, useSessionTasksContext } from '../../contexts/components/SessionTasks'; import { Route, Switch, useRouter } from '../../router'; -import { OrganizationList } from '../OrganizationList'; +import { ForceOrganizationSelectionTask } from './tasks/ForceOrganizationSelection'; -const SessionTasksStart = withCardStateProvider(() => { +const SessionTasksStart = () => { const clerk = useClerk(); const { navigate } = useRouter(); const { redirectUrlComplete } = useSessionTasksContext(); @@ -37,20 +33,13 @@ const SessionTasksStart = withCardStateProvider(() => { ); -}); +}; function SessionTaskRoutes(): JSX.Element { return ( - - - + @@ -62,34 +51,55 @@ function SessionTaskRoutes(): JSX.Element { /** * @internal */ -export function SessionTask(): JSX.Element { +export const SessionTask = withCardStateProvider(() => { const clerk = useClerk(); const { navigate } = useRouter(); const signInContext = useContext(SignInContext); const signUpContext = useContext(SignUpContext); + const [isNavigatingToTask, setIsNavigatingToTask] = useState(false); const redirectUrlComplete = signInContext?.afterSignInUrl ?? signUpContext?.afterSignUpUrl ?? clerk?.buildAfterSignInUrl(); + // If there are no pending tasks, navigate away from the tasks flow. + // This handles cases where a user with an active session returns to the tasks URL, + // for example by using browser back navigation. Since there are no pending tasks, + // we redirect them to their intended destination. useEffect(() => { - const task = clerk.session?.currentTask; + if (isNavigatingToTask) { + return; + } - if (!task) { + // Tasks can only exist on pending sessions, but we check both conditions + // here to be defensive and ensure proper redirection + const task = clerk.session?.currentTask; + if (!task || clerk.session?.status === 'active') { void navigate(redirectUrlComplete); return; } clerk.telemetry?.record(eventComponentMounted('SessionTask', { task: task.key })); - }, [clerk, navigate, redirectUrlComplete]); + }, [clerk, navigate, isNavigatingToTask, redirectUrlComplete]); - const nextTask = useCallback( - () => clerk.__experimental_navigateToTask({ redirectUrlComplete }), - [clerk, redirectUrlComplete], - ); + const nextTask = useCallback(() => { + setIsNavigatingToTask(true); + return clerk.__experimental_navigateToTask({ redirectUrlComplete }).finally(() => setIsNavigatingToTask(false)); + }, [clerk, redirectUrlComplete]); + + if (!clerk.session?.currentTask) { + return ( + + + + + + + ); + } return ( ); -} +}); diff --git a/packages/clerk-js/src/ui/components/SessionTasks/tasks/ForceOrganizationSelection.tsx b/packages/clerk-js/src/ui/components/SessionTasks/tasks/ForceOrganizationSelection.tsx new file mode 100644 index 00000000000..a8ff0cfaef7 --- /dev/null +++ b/packages/clerk-js/src/ui/components/SessionTasks/tasks/ForceOrganizationSelection.tsx @@ -0,0 +1,116 @@ +import { useOrganizationList } from '@clerk/shared/react/index'; +import type { PropsWithChildren } from 'react'; +import { useState } from 'react'; + +import { OrganizationListContext } from '@/ui/contexts'; +import { Card } from '@/ui/elements/Card'; +import { useCardState, withCardStateProvider } from '@/ui/elements/contexts'; + +import { Box, descriptors, Flex, localizationKeys, Spinner } from '../../../customizables'; +import { CreateOrganizationForm } from '../../CreateOrganization/CreateOrganizationForm'; +import { OrganizationListPageList } from '../../OrganizationList/OrganizationListPage'; +import { organizationListParams } from '../../OrganizationSwitcher/utils'; + +/** + * @internal + */ +export const ForceOrganizationSelectionTask = withCardStateProvider(() => { + const { userMemberships, userInvitations, userSuggestions } = useOrganizationList(organizationListParams); + const isLoading = userMemberships?.isLoading || userInvitations?.isLoading || userSuggestions?.isLoading; + const hasData = !!(userMemberships?.count || userInvitations?.count || userSuggestions?.count); + + if (isLoading) { + return ( + + + + ); + } + + if (hasData) { + return ; + } + + return ; +}); + +const OrganizationSelectionPage = () => { + const [showCreateOrganizationForm, setShowCreateOrganizationForm] = useState(false); + + return ( + + + {showCreateOrganizationForm ? ( + ({ + padding: `${t.space.$none} ${t.space.$5} ${t.space.$5}`, + })} + > + setShowCreateOrganizationForm(false)} + /> + + ) : ( + setShowCreateOrganizationForm(true)} /> + )} + + + ); +}; + +const CreateOrganizationPage = () => { + return ( + + ({ + padding: `${t.space.$none} ${t.space.$5} ${t.space.$5}`, + })} + > + + + + ); +}; + +const FlowCard = ({ children }: PropsWithChildren) => { + const card = useCardState(); + + return ( + + ({ padding: `${t.space.$8} ${t.space.$none} ${t.space.$none}` })}> + ({ margin: `${t.space.$none} ${t.space.$5}` })}>{card.error} + {children} + + + + ); +}; + +const FlowLoadingState = () => ( + ({ + height: '100%', + minHeight: t.sizes.$60, + })} + > + + +); diff --git a/packages/testing/src/playwright/unstable/page-objects/sessionTask.ts b/packages/testing/src/playwright/unstable/page-objects/sessionTask.ts index c5721982326..6295e6ffbd5 100644 --- a/packages/testing/src/playwright/unstable/page-objects/sessionTask.ts +++ b/packages/testing/src/playwright/unstable/page-objects/sessionTask.ts @@ -12,7 +12,6 @@ export const createSessionTaskComponentPageObject = (testArgs: { page: EnhancedP const createOrganizationButton = page.getByRole('button', { name: /create organization/i }); await expect(createOrganizationButton).toBeVisible(); - expect(page.url()).toContain('add-organization'); await page.locator('input[name=name]').fill(fakeOrganization.name); await page.locator('input[name=slug]').fill(fakeOrganization.slug);