From b9b93494cf5513855ece9dbe2da65ba9c39636d0 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Thu, 24 Sep 2026 13:36:42 -0700 Subject: [PATCH 1/3] fix(navigation): restore organization return from shared workspaces --- .../workspace-header.test.tsx | 58 ++++++++----------- .../workspace-header/workspace-header.tsx | 6 +- .../w/components/sidebar/sidebar.tsx | 4 +- apps/sim/hooks/use-organization-navigation.ts | 8 +++ apps/sim/lib/navigation/paths.ts | 2 +- .../lib/workspaces/organization-navigation.ts | 11 ---- 6 files changed, 36 insertions(+), 53 deletions(-) create mode 100644 apps/sim/hooks/use-organization-navigation.ts delete mode 100644 apps/sim/lib/workspaces/organization-navigation.ts diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.test.tsx index 5d808f3dc3d..64d9230ab08 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.test.tsx @@ -6,18 +6,14 @@ import { createRoot, type Root } from 'react-dom/client' import { renderToString } from 'react-dom/server' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -const { mockNavigateToSettings, mockWorkspacePermissions, hostContext } = vi.hoisted(() => ({ +const { mockNavigateToSettings, mockWorkspacePermissions, organizationList } = vi.hoisted(() => ({ mockNavigateToSettings: vi.fn(), - hostContext: { - hostOrganizationId: null as string | null, - viewer: { isHostOrganizationMember: false }, - features: { organizationSearch: false as boolean | undefined }, - }, + organizationList: { data: [] as { id: string }[] | undefined }, mockWorkspacePermissions: { canAdmin: true, canEdit: true, canRead: true }, })) -vi.mock('@/app/workspace/[workspaceId]/providers/workspace-host-provider', () => ({ - useWorkspaceHostContext: () => hostContext, +vi.mock('@/hooks/queries/organization', () => ({ + useOrganizationList: () => organizationList, })) const onWorkspaceSwitch = vi.fn() @@ -29,7 +25,6 @@ vi.mock('next/navigation', () => ({ useRouter: () => ({ push: vi.fn() }), usePathname: () => '/workspace/ws-emir/home', })) -vi.mock('@/lib/auth/auth-client', () => ({ useActiveOrganization: () => ({ data: null }) })) vi.mock('@/hooks/use-settings-navigation', () => ({ useSettingsNavigation: () => ({ navigateToSettings: mockNavigateToSettings, @@ -170,9 +165,7 @@ function typeInto(input: HTMLInputElement, value: string) { beforeEach(() => { vi.clearAllMocks() - hostContext.hostOrganizationId = null - hostContext.viewer.isHostOrganizationMember = false - hostContext.features.organizationSearch = false + organizationList.data = [] Object.assign(mockWorkspacePermissions, { canAdmin: true, canEdit: true, canRead: true }) // jsdom implements neither; the component scrolls the active row into view. Element.prototype.scrollIntoView = vi.fn() @@ -187,8 +180,7 @@ describe('WorkspaceHeader workspace switcher highlight', () => { it.each([null, 'organization'])( 'keeps access requests out of the workspace switcher (%s)', (organizationId) => { - hostContext.hostOrganizationId = organizationId - render() + render({ workspaces: WORKSPACES.map((workspace) => ({ ...workspace, organizationId })) }) expect(document.body).not.toHaveTextContent('My access requests') expect(document.body).not.toHaveTextContent('Review access requests') } @@ -414,28 +406,24 @@ describe('WorkspaceHeader workspace switcher highlight', () => { }) describe('WorkspaceHeader context navigation', () => { - it('links to the current host organization for enrolled members', () => { - hostContext.hostOrganizationId = 'host-org' - hostContext.viewer.isHostOrganizationMember = true - hostContext.features.organizationSearch = true - render() - expect(document.querySelector('a[href="/o/host-org"]')).toHaveTextContent( - 'Back to organization' - ) - }) + it.each([null, 'another-organization', 'viewer-organization'])( + 'links to the viewer organization landing independently of workspace host %s', + (organizationId) => { + organizationList.data = [{ id: 'viewer-organization' }] + render({ workspaces: WORKSPACES.map((workspace) => ({ ...workspace, organizationId })) }) + expect(document.querySelector('a[href="/o"]')).toHaveTextContent('Back to organization') + expect(document.querySelector('a[href^="/o/"]')).toBeNull() + } + ) - it.each([ - { org: null, member: true, enabled: true }, - { org: 'host-org', member: false, enabled: true }, - { org: 'host-org', member: true, enabled: false }, - { org: 'host-org', member: true, enabled: undefined }, - ])('hides inaccessible organization navigation: %j', ({ org, member, enabled }) => { - hostContext.hostOrganizationId = org - hostContext.viewer.isHostOrganizationMember = member - hostContext.features.organizationSearch = enabled - render() - expect(document.querySelector('a[href^="/o/"]')).toBeNull() - }) + it.each([[], undefined])( + 'hides organization navigation without loaded memberships: %j', + (data) => { + organizationList.data = data + render() + expect(document.body).not.toHaveTextContent('Back to organization') + } + ) it('keeps settings in the profile menu instead of duplicating it in the switcher', () => { render() diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.tsx index fc8c76904b8..907362c3ee1 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.tsx @@ -34,9 +34,7 @@ import { WorkspaceContextMenu } from '@/components/workspaces/workspace-context- import { useDeploymentShape } from '@/lib/core/config/deployment-shape' import { WORKSPACE_SEARCH_THRESHOLD } from '@/lib/workspaces/constants' import { getWorkspaceInitial } from '@/lib/workspaces/initials' -import { getWorkspaceOrganizationHref } from '@/lib/workspaces/organization-navigation' import { InviteModal } from '@/app/workspace/[workspaceId]/components/invite-modal' -import { useWorkspaceHostContext } from '@/app/workspace/[workspaceId]/providers/workspace-host-provider' import { useWorkspacePermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { SidebarRenameRow } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-rename-row' import { DeleteModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/delete-modal/delete-modal' @@ -50,6 +48,7 @@ import { type WorkspaceCreationPolicy, workspaceKeys, } from '@/hooks/queries/workspace' +import { useOrganizationNavigationHref } from '@/hooks/use-organization-navigation' import { usePermissionConfig } from '@/hooks/use-permission-config' import { useSettingsNavigation } from '@/hooks/use-settings-navigation' @@ -273,8 +272,7 @@ function WorkspaceHeaderImpl({ const { navigateToSettings } = useSettingsNavigation() const queryClient = useQueryClient() - const hostContext = useWorkspaceHostContext() - const organizationHref = getWorkspaceOrganizationHref(hostContext) + const organizationHref = useOrganizationNavigationHref() const activeWorkspaceFull = workspaces.find((w) => w.id === workspaceId) || null const isWorkspaceReady = !isWorkspacesLoading && activeWorkspaceFull !== null diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx index f52386e4abb..476b06d374a 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx @@ -49,7 +49,6 @@ import { DOCS_URL, SLACK_COMMUNITY_URL } from '@/lib/help-links' import { SIM_RESOURCES_DRAG_TYPE } from '@/lib/mothership/resource-types' import { captureEvent } from '@/lib/posthog/client' import { LOGO_ACCEPT_ATTRIBUTE } from '@/lib/uploads/client/logo-file' -import { getWorkspaceOrganizationHref } from '@/lib/workspaces/organization-navigation' import { useSidebarChrome } from '@/app/workspace/[workspaceId]/components/workspace-chrome' import { CONNECT_MODE } from '@/app/workspace/[workspaceId]/integrations/connect-route' import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider' @@ -137,6 +136,7 @@ import { useUpdateWorkflow } from '@/hooks/queries/workflows' import type { Workspace } from '@/hooks/queries/workspace' import { useContextMenu } from '@/hooks/use-context-menu' import { useMothershipChatEvents } from '@/hooks/use-mothership-chat-events' +import { useOrganizationNavigationHref } from '@/hooks/use-organization-navigation' import { usePermissionConfig } from '@/hooks/use-permission-config' import { useSettingsNavigation } from '@/hooks/use-settings-navigation' import { useFolderStore } from '@/stores/folders/store' @@ -820,7 +820,7 @@ export const Sidebar = memo(function Sidebar() { onNavigate: () => handleOpenSettings(id), })) - const organizationHref = getWorkspaceOrganizationHref(hostContext) + const organizationHref = useOrganizationNavigationHref() if (organizationHref) { profileNavigationLinks.push({ label: 'Organization', diff --git a/apps/sim/hooks/use-organization-navigation.ts b/apps/sim/hooks/use-organization-navigation.ts new file mode 100644 index 00000000000..ba83b378f98 --- /dev/null +++ b/apps/sim/hooks/use-organization-navigation.ts @@ -0,0 +1,8 @@ +import { ORGANIZATIONS_PATH } from '@/lib/navigation/paths' +import { useOrganizationList } from '@/hooks/queries/organization' + +/** Account navigation follows the viewer's memberships, independently of the workspace host. */ +export function useOrganizationNavigationHref(): string | null { + const { data: organizations } = useOrganizationList() + return organizations?.length ? ORGANIZATIONS_PATH : null +} diff --git a/apps/sim/lib/navigation/paths.ts b/apps/sim/lib/navigation/paths.ts index 6d194cb3548..5c7f67209c8 100644 --- a/apps/sim/lib/navigation/paths.ts +++ b/apps/sim/lib/navigation/paths.ts @@ -23,7 +23,7 @@ export const WORKSPACES_PATH = '/workspace' export const WORKSPACE_SETTINGS_PATH = `${WORKSPACES_PATH}?redirect=settings` /** Root of the organization surface; `/o` alone resolves like {@link APP_ENTRY_PATH}. */ -const ORGANIZATIONS_PATH = '/o' +export const ORGANIZATIONS_PATH = '/o' /** * Every destination under one organization's surface, built from one place so the diff --git a/apps/sim/lib/workspaces/organization-navigation.ts b/apps/sim/lib/workspaces/organization-navigation.ts deleted file mode 100644 index 758bf66f2d1..00000000000 --- a/apps/sim/lib/workspaces/organization-navigation.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { WorkspaceHostContext } from '@/lib/api/contracts/workspaces' -import { organizationRoutes } from '@/lib/navigation/paths' - -/** Returns the workspace's organization destination only when the viewer can enter it. */ -export function getWorkspaceOrganizationHref(hostContext: WorkspaceHostContext): string | null { - return hostContext.hostOrganizationId && - hostContext.viewer.isHostOrganizationMember && - hostContext.features?.organizationSearch - ? organizationRoutes(hostContext.hostOrganizationId).root - : null -} From e9a4fb7dbc26143df85d0df30cc6259829944fcf Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Thu, 24 Sep 2026 13:59:32 -0700 Subject: [PATCH 2/3] fix(navigation): check organization membership and availability on server --- .../app/workspace/[workspaceId]/layout.tsx | 5 +- .../workspace-header.test.tsx | 32 +++++------- .../workspace-header/workspace-header.tsx | 4 +- .../w/components/sidebar/sidebar.tsx | 9 ++-- apps/sim/hooks/use-organization-navigation.ts | 8 --- apps/sim/lib/navigation/paths.ts | 2 +- .../lib/navigation/resolve-app-entry.test.ts | 52 ++++++++++++++++++- apps/sim/lib/navigation/resolve-app-entry.ts | 21 +++++--- 8 files changed, 90 insertions(+), 43 deletions(-) delete mode 100644 apps/sim/hooks/use-organization-navigation.ts diff --git a/apps/sim/app/workspace/[workspaceId]/layout.tsx b/apps/sim/app/workspace/[workspaceId]/layout.tsx index 961bfb81215..d875a37ba6a 100644 --- a/apps/sim/app/workspace/[workspaceId]/layout.tsx +++ b/apps/sim/app/workspace/[workspaceId]/layout.tsx @@ -4,6 +4,7 @@ import { redirect } from 'next/navigation' import { getSession } from '@/lib/auth' import { getActiveOrganizationId } from '@/lib/auth/session-response' import { isMothershipModelSelectorEnabled, isPlanModeEnabled } from '@/lib/mothership/feature-flags' +import { resolveOrganizationEntryPath } from '@/lib/navigation/resolve-app-entry' import { isTableRowTtlEnabled } from '@/lib/table/ttl-availability' import { getQueryClient } from '@/app/_shell/providers/get-query-client' import { ImpersonationBanner } from '@/app/workspace/[workspaceId]/components/impersonation-banner' @@ -56,6 +57,7 @@ export default async function WorkspaceLayout({ tableRowTtlEnabled, modelSelectorEnabled, planModeEnabled, + organizationHref, ] = await Promise.all([ cookies(), hostContext.hostOrganizationId @@ -71,6 +73,7 @@ export default async function WorkspaceLayout({ isTableRowTtlEnabled(), isMothershipModelSelectorEnabled(), isPlanModeEnabled(), + resolveOrganizationEntryPath(session), prefetchWorkspaceAccess(queryClient, workspaceId, { kind: 'session', userId: session.user.id, @@ -106,7 +109,7 @@ export default async function WorkspaceLayout({ } + sidebar={} initialSidebarCollapsed={initialSidebarCollapsed} > {children} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.test.tsx index 64d9230ab08..b1f32465836 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.test.tsx @@ -6,16 +6,11 @@ import { createRoot, type Root } from 'react-dom/client' import { renderToString } from 'react-dom/server' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -const { mockNavigateToSettings, mockWorkspacePermissions, organizationList } = vi.hoisted(() => ({ +const { mockNavigateToSettings, mockWorkspacePermissions } = vi.hoisted(() => ({ mockNavigateToSettings: vi.fn(), - organizationList: { data: [] as { id: string }[] | undefined }, mockWorkspacePermissions: { canAdmin: true, canEdit: true, canRead: true }, })) -vi.mock('@/hooks/queries/organization', () => ({ - useOrganizationList: () => organizationList, -})) - const onWorkspaceSwitch = vi.fn() vi.mock('@tanstack/react-query', () => ({ @@ -107,6 +102,7 @@ function render(overrides: Partial[0]> = {}) function header(overrides: Partial[0]> = {}) { return ( { vi.clearAllMocks() - organizationList.data = [] Object.assign(mockWorkspacePermissions, { canAdmin: true, canEdit: true, canRead: true }) // jsdom implements neither; the component scrolls the active row into view. Element.prototype.scrollIntoView = vi.fn() @@ -409,21 +404,20 @@ describe('WorkspaceHeader context navigation', () => { it.each([null, 'another-organization', 'viewer-organization'])( 'links to the viewer organization landing independently of workspace host %s', (organizationId) => { - organizationList.data = [{ id: 'viewer-organization' }] - render({ workspaces: WORKSPACES.map((workspace) => ({ ...workspace, organizationId })) }) - expect(document.querySelector('a[href="/o"]')).toHaveTextContent('Back to organization') - expect(document.querySelector('a[href^="/o/"]')).toBeNull() + render({ + organizationHref: '/o/viewer-organization/home', + workspaces: WORKSPACES.map((workspace) => ({ ...workspace, organizationId })), + }) + expect(document.querySelector('a[href="/o/viewer-organization/home"]')).toHaveTextContent( + 'Back to organization' + ) } ) - it.each([[], undefined])( - 'hides organization navigation without loaded memberships: %j', - (data) => { - organizationList.data = data - render() - expect(document.body).not.toHaveTextContent('Back to organization') - } - ) + it('hides organization navigation without an eligible destination', () => { + render({ organizationHref: null }) + expect(document.body).not.toHaveTextContent('Back to organization') + }) it('keeps settings in the profile menu instead of duplicating it in the switcher', () => { render() diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.tsx index 907362c3ee1..ad1a19055af 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.tsx @@ -48,7 +48,6 @@ import { type WorkspaceCreationPolicy, workspaceKeys, } from '@/hooks/queries/workspace' -import { useOrganizationNavigationHref } from '@/hooks/use-organization-navigation' import { usePermissionConfig } from '@/hooks/use-permission-config' import { useSettingsNavigation } from '@/hooks/use-settings-navigation' @@ -78,6 +77,7 @@ function DisabledReasonTooltip({ reason, children }: DisabledReasonTooltipProps) } interface WorkspaceHeaderProps { + organizationHref: string | null /** The active workspace object */ activeWorkspace?: { name: string } | null /** Current workspace ID */ @@ -126,6 +126,7 @@ interface WorkspaceHeaderProps { * Workspace header component that displays workspace name and switcher. */ function WorkspaceHeaderImpl({ + organizationHref, activeWorkspace, workspaceId, workspaces, @@ -272,7 +273,6 @@ function WorkspaceHeaderImpl({ const { navigateToSettings } = useSettingsNavigation() const queryClient = useQueryClient() - const organizationHref = useOrganizationNavigationHref() const activeWorkspaceFull = workspaces.find((w) => w.id === workspaceId) || null const isWorkspaceReady = !isWorkspacesLoading && activeWorkspaceFull !== null diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx index 476b06d374a..e23a0f51b70 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx @@ -136,7 +136,6 @@ import { useUpdateWorkflow } from '@/hooks/queries/workflows' import type { Workspace } from '@/hooks/queries/workspace' import { useContextMenu } from '@/hooks/use-context-menu' import { useMothershipChatEvents } from '@/hooks/use-mothership-chat-events' -import { useOrganizationNavigationHref } from '@/hooks/use-organization-navigation' import { usePermissionConfig } from '@/hooks/use-permission-config' import { useSettingsNavigation } from '@/hooks/use-settings-navigation' import { useFolderStore } from '@/stores/folders/store' @@ -333,6 +332,10 @@ const HIDDEN_STYLE = { display: 'none' } as const */ const DRAG_EXEMPT_CLASS = '[-webkit-app-region:no-drag]' +interface SidebarProps { + organizationHref: string | null +} + /** * Sidebar component with resizable width that persists across page refreshes. * @@ -349,7 +352,7 @@ const DRAG_EXEMPT_CLASS = '[-webkit-app-region:no-drag]' * * @returns Sidebar with workflows panel */ -export const Sidebar = memo(function Sidebar() { +export const Sidebar = memo(function Sidebar({ organizationHref }: SidebarProps) { const { isCollapsed: isCollapsedProp, isPeeking } = useSidebarChrome() const isCollapsed = isCollapsedProp && !isPeeking const params = useParams() @@ -820,7 +823,6 @@ export const Sidebar = memo(function Sidebar() { onNavigate: () => handleOpenSettings(id), })) - const organizationHref = useOrganizationNavigationHref() if (organizationHref) { profileNavigationLinks.push({ label: 'Organization', @@ -1305,6 +1307,7 @@ export const Sidebar = memo(function Sidebar() { )} > ({ resolveOrganizationLanding: mockResolveOrganizationLanding, })) -import { resolveAppEntryPath } from '@/lib/navigation/resolve-app-entry' +import { + resolveAppEntryPath, + resolveOrganizationEntryPath, +} from '@/lib/navigation/resolve-app-entry' describe('resolveAppEntryPath', () => { beforeEach(() => { @@ -51,3 +54,50 @@ describe('resolveAppEntryPath', () => { expect(mockSearchAvailable).not.toHaveBeenCalled() }) }) + +describe('resolveOrganizationEntryPath', () => { + beforeEach(() => { + vi.clearAllMocks() + mockSearchAvailable.mockResolvedValue(true) + }) + + it('uses the authenticated viewer membership independently of the workspace host', async () => { + mockResolveOrganizationLanding.mockResolvedValue('viewer-organization') + const session = { + user: { id: 'viewer' }, + session: { activeOrganizationId: 'viewer-organization' }, + } + + await expect(resolveOrganizationEntryPath(session)).resolves.toBe('/o/viewer-organization/home') + expect(mockResolveOrganizationLanding).toHaveBeenCalledWith('viewer', 'viewer-organization') + expect(mockSearchAvailable).toHaveBeenCalledWith({ organizationId: 'viewer-organization' }) + }) + + it('returns no organization destination for a nonmember with a stale active organization', async () => { + mockResolveOrganizationLanding.mockResolvedValue(null) + const session = { + user: { id: 'viewer' }, + session: { activeOrganizationId: 'former-organization' }, + } + + await expect(resolveOrganizationEntryPath(session)).resolves.toBeNull() + expect(mockResolveOrganizationLanding).toHaveBeenCalledWith('viewer', 'former-organization') + expect(mockSearchAvailable).not.toHaveBeenCalled() + }) + + it('returns no organization destination when the member organization has Search disabled', async () => { + mockResolveOrganizationLanding.mockResolvedValue('viewer-organization') + mockSearchAvailable.mockResolvedValue(false) + + await expect(resolveOrganizationEntryPath({ user: { id: 'viewer' } })).resolves.toBeNull() + }) + + it('propagates membership lookup failures', async () => { + mockResolveOrganizationLanding.mockRejectedValue(new Error('Membership lookup failed')) + + await expect(resolveOrganizationEntryPath({ user: { id: 'viewer' } })).rejects.toThrow( + 'Membership lookup failed' + ) + expect(mockSearchAvailable).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/navigation/resolve-app-entry.ts b/apps/sim/lib/navigation/resolve-app-entry.ts index 06c9ddd0dea..28c94429119 100644 --- a/apps/sim/lib/navigation/resolve-app-entry.ts +++ b/apps/sim/lib/navigation/resolve-app-entry.ts @@ -7,6 +7,18 @@ interface EntrySession { user: { id: string } } +/** Returns a destination only after checking the viewer's membership and organization rollout. */ +export async function resolveOrganizationEntryPath(session: EntrySession): Promise { + const organizationId = await resolveOrganizationLanding( + session.user.id, + getActiveOrganizationId(session) + ) + if (!organizationId) return null + return (await isKnowledgeMemberAccessAvailable({ organizationId })) + ? organizationRoutes(organizationId).home + : null +} + /** * Routes organization members to Home when the organization surface is enabled for * them. Everyone else — viewers without an organization, and members whose @@ -16,12 +28,5 @@ interface EntrySession { * settings must not be dropped into them. */ export async function resolveAppEntryPath(session: EntrySession): Promise { - const organizationId = await resolveOrganizationLanding( - session.user.id, - getActiveOrganizationId(session) - ) - if (!organizationId) return WORKSPACES_PATH - return (await isKnowledgeMemberAccessAvailable({ organizationId })) - ? organizationRoutes(organizationId).home - : WORKSPACES_PATH + return (await resolveOrganizationEntryPath(session)) ?? WORKSPACES_PATH } From 90e0a3acac7b449fed0b00ff45373bf2622c557c Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Thu, 24 Sep 2026 14:13:51 -0700 Subject: [PATCH 3/3] fix(navigation): refresh organization destination after creation --- apps/sim/hooks/queries/organization.test.tsx | 70 ++++++++++++++++++-- apps/sim/hooks/queries/organization.ts | 6 +- 2 files changed, 68 insertions(+), 8 deletions(-) diff --git a/apps/sim/hooks/queries/organization.test.tsx b/apps/sim/hooks/queries/organization.test.tsx index 7fc737ad437..4a7f44c9332 100644 --- a/apps/sim/hooks/queries/organization.test.tsx +++ b/apps/sim/hooks/queries/organization.test.tsx @@ -8,13 +8,25 @@ import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { ApiClientError } from '@/lib/api/client/errors' -const { mockGetFullOrganization, mockListOrganizations, mockRequestJson, featureFlags } = - vi.hoisted(() => ({ - mockGetFullOrganization: vi.fn(), - mockListOrganizations: vi.fn(), - mockRequestJson: vi.fn(), - featureFlags: { organizations: true }, - })) +const { + mockGetFullOrganization, + mockListOrganizations, + mockSetActiveOrganization, + mockRefresh, + mockRequestJson, + featureFlags, +} = vi.hoisted(() => ({ + mockGetFullOrganization: vi.fn(), + mockListOrganizations: vi.fn(), + mockSetActiveOrganization: vi.fn(), + mockRefresh: vi.fn(), + mockRequestJson: vi.fn(), + featureFlags: { organizations: true }, +})) + +vi.mock('next/navigation', () => ({ + useRouter: () => ({ refresh: mockRefresh }), +})) vi.mock('@/lib/core/config/env-flags', () => ({ get isOrganizationsEnabled() { @@ -31,6 +43,7 @@ vi.mock('@/lib/auth/auth-client', () => ({ organization: { getFullOrganization: mockGetFullOrganization, list: mockListOrganizations, + setActive: mockSetActiveOrganization, }, subscription: { list: vi.fn(), @@ -49,6 +62,7 @@ import { } from '@/lib/api/contracts/subscription' import { organizationKeys, + useCreateOrganization, useOrganization, useOrganizationBilling, useOrganizationList, @@ -199,6 +213,48 @@ describe('organization identity transitions', () => { expect(signal).toBeInstanceOf(AbortSignal) }) + it.each([true, false])( + 'refreshes the server layout after organization activation settles (success=%s)', + async (success) => { + mockRequestJson.mockResolvedValue({ organizationId: 'new-organization' }) + const activation = createDeferred<{ error: { message: string } | null }>() + mockSetActiveOrganization.mockReturnValue(activation.promise) + let mutation: ReturnType + function CreationProbe() { + mutation = useCreateOrganization() + return null + } + + await act(async () => { + root.render( + + + + ) + }) + let pending: Promise + await act(async () => { + pending = mutation.mutateAsync({ name: 'New organization' }) + }) + expect(mockSetActiveOrganization).toHaveBeenCalledWith({ + organizationId: 'new-organization', + }) + expect(mockRefresh).not.toHaveBeenCalled() + + await act(async () => { + if (success) { + activation.resolve({ error: null }) + await pending + } else { + const rejection = expect(pending).rejects.toThrow('Activation failed') + activation.resolve({ error: { message: 'Activation failed' } }) + await rejection + } + }) + expect(mockRefresh).toHaveBeenCalledOnce() + } + ) + it('does not call the organization plugin when organizations are disabled', async () => { featureFlags.organizations = false await act(async () => diff --git a/apps/sim/hooks/queries/organization.ts b/apps/sim/hooks/queries/organization.ts index e7792d61923..74e10d73b34 100644 --- a/apps/sim/hooks/queries/organization.ts +++ b/apps/sim/hooks/queries/organization.ts @@ -7,6 +7,7 @@ import { useQuery, useQueryClient, } from '@tanstack/react-query' +import { useRouter } from 'next/navigation' import { ApiClientError } from '@/lib/api/client/errors' import { requestJson } from '@/lib/api/client/request' import type { ContractBodyInput } from '@/lib/api/contracts' @@ -605,6 +606,7 @@ type CreateOrganizationParams = Pick< export function useCreateOrganization() { const queryClient = useQueryClient() + const router = useRouter() return useMutation({ mutationFn: async ({ name, slug }: CreateOrganizationParams) => { @@ -615,15 +617,17 @@ export function useCreateOrganization() { }, }) - await client.organization.setActive({ + const { error } = await client.organization.setActive({ organizationId: data.organizationId, }) + if (error) throw new Error(error.message || 'Failed to activate organization') return data }, onSettled: () => { queryClient.invalidateQueries({ queryKey: organizationKeys.lists() }) queryClient.invalidateQueries({ queryKey: workspaceKeys.lists() }) + router.refresh() }, }) }