Skip to content

Commit e9a4fb7

Browse files
fix(navigation): check organization membership and availability on server
1 parent b9b9349 commit e9a4fb7

8 files changed

Lines changed: 90 additions & 43 deletions

File tree

‎apps/sim/app/workspace/[workspaceId]/layout.tsx‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { redirect } from 'next/navigation'
44
import { getSession } from '@/lib/auth'
55
import { getActiveOrganizationId } from '@/lib/auth/session-response'
66
import { isMothershipModelSelectorEnabled, isPlanModeEnabled } from '@/lib/mothership/feature-flags'
7+
import { resolveOrganizationEntryPath } from '@/lib/navigation/resolve-app-entry'
78
import { isTableRowTtlEnabled } from '@/lib/table/ttl-availability'
89
import { getQueryClient } from '@/app/_shell/providers/get-query-client'
910
import { ImpersonationBanner } from '@/app/workspace/[workspaceId]/components/impersonation-banner'
@@ -56,6 +57,7 @@ export default async function WorkspaceLayout({
5657
tableRowTtlEnabled,
5758
modelSelectorEnabled,
5859
planModeEnabled,
60+
organizationHref,
5961
] = await Promise.all([
6062
cookies(),
6163
hostContext.hostOrganizationId
@@ -71,6 +73,7 @@ export default async function WorkspaceLayout({
7173
isTableRowTtlEnabled(),
7274
isMothershipModelSelectorEnabled(),
7375
isPlanModeEnabled(),
76+
resolveOrganizationEntryPath(session),
7477
prefetchWorkspaceAccess(queryClient, workspaceId, {
7578
kind: 'session',
7679
userId: session.user.id,
@@ -106,7 +109,7 @@ export default async function WorkspaceLayout({
106109
<WorkspacePermissionsProvider>
107110
<WorkspaceScopeSync />
108111
<WorkspaceChrome
109-
sidebar={<Sidebar />}
112+
sidebar={<Sidebar organizationHref={organizationHref} />}
110113
initialSidebarCollapsed={initialSidebarCollapsed}
111114
>
112115
{children}

‎apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.test.tsx‎

Lines changed: 13 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,11 @@ import { createRoot, type Root } from 'react-dom/client'
66
import { renderToString } from 'react-dom/server'
77
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
88

9-
const { mockNavigateToSettings, mockWorkspacePermissions, organizationList } = vi.hoisted(() => ({
9+
const { mockNavigateToSettings, mockWorkspacePermissions } = vi.hoisted(() => ({
1010
mockNavigateToSettings: vi.fn(),
11-
organizationList: { data: [] as { id: string }[] | undefined },
1211
mockWorkspacePermissions: { canAdmin: true, canEdit: true, canRead: true },
1312
}))
1413

15-
vi.mock('@/hooks/queries/organization', () => ({
16-
useOrganizationList: () => organizationList,
17-
}))
18-
1914
const onWorkspaceSwitch = vi.fn()
2015

2116
vi.mock('@tanstack/react-query', () => ({
@@ -107,6 +102,7 @@ function render(overrides: Partial<Parameters<typeof WorkspaceHeader>[0]> = {})
107102
function header(overrides: Partial<Parameters<typeof WorkspaceHeader>[0]> = {}) {
108103
return (
109104
<WorkspaceHeader
105+
organizationHref={null}
110106
activeWorkspace={{ name: "Emir's Workspace" }}
111107
workspaceId='ws-emir'
112108
workspaces={WORKSPACES}
@@ -165,7 +161,6 @@ function typeInto(input: HTMLInputElement, value: string) {
165161

166162
beforeEach(() => {
167163
vi.clearAllMocks()
168-
organizationList.data = []
169164
Object.assign(mockWorkspacePermissions, { canAdmin: true, canEdit: true, canRead: true })
170165
// jsdom implements neither; the component scrolls the active row into view.
171166
Element.prototype.scrollIntoView = vi.fn()
@@ -409,21 +404,20 @@ describe('WorkspaceHeader context navigation', () => {
409404
it.each([null, 'another-organization', 'viewer-organization'])(
410405
'links to the viewer organization landing independently of workspace host %s',
411406
(organizationId) => {
412-
organizationList.data = [{ id: 'viewer-organization' }]
413-
render({ workspaces: WORKSPACES.map((workspace) => ({ ...workspace, organizationId })) })
414-
expect(document.querySelector('a[href="/o"]')).toHaveTextContent('Back to organization')
415-
expect(document.querySelector('a[href^="/o/"]')).toBeNull()
407+
render({
408+
organizationHref: '/o/viewer-organization/home',
409+
workspaces: WORKSPACES.map((workspace) => ({ ...workspace, organizationId })),
410+
})
411+
expect(document.querySelector('a[href="/o/viewer-organization/home"]')).toHaveTextContent(
412+
'Back to organization'
413+
)
416414
}
417415
)
418416

419-
it.each([[], undefined])(
420-
'hides organization navigation without loaded memberships: %j',
421-
(data) => {
422-
organizationList.data = data
423-
render()
424-
expect(document.body).not.toHaveTextContent('Back to organization')
425-
}
426-
)
417+
it('hides organization navigation without an eligible destination', () => {
418+
render({ organizationHref: null })
419+
expect(document.body).not.toHaveTextContent('Back to organization')
420+
})
427421

428422
it('keeps settings in the profile menu instead of duplicating it in the switcher', () => {
429423
render()

‎apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.tsx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,6 @@ import {
4848
type WorkspaceCreationPolicy,
4949
workspaceKeys,
5050
} from '@/hooks/queries/workspace'
51-
import { useOrganizationNavigationHref } from '@/hooks/use-organization-navigation'
5251
import { usePermissionConfig } from '@/hooks/use-permission-config'
5352
import { useSettingsNavigation } from '@/hooks/use-settings-navigation'
5453

@@ -78,6 +77,7 @@ function DisabledReasonTooltip({ reason, children }: DisabledReasonTooltipProps)
7877
}
7978

8079
interface WorkspaceHeaderProps {
80+
organizationHref: string | null
8181
/** The active workspace object */
8282
activeWorkspace?: { name: string } | null
8383
/** Current workspace ID */
@@ -126,6 +126,7 @@ interface WorkspaceHeaderProps {
126126
* Workspace header component that displays workspace name and switcher.
127127
*/
128128
function WorkspaceHeaderImpl({
129+
organizationHref,
129130
activeWorkspace,
130131
workspaceId,
131132
workspaces,
@@ -272,7 +273,6 @@ function WorkspaceHeaderImpl({
272273

273274
const { navigateToSettings } = useSettingsNavigation()
274275
const queryClient = useQueryClient()
275-
const organizationHref = useOrganizationNavigationHref()
276276

277277
const activeWorkspaceFull = workspaces.find((w) => w.id === workspaceId) || null
278278
const isWorkspaceReady = !isWorkspacesLoading && activeWorkspaceFull !== null

‎apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,6 @@ import { useUpdateWorkflow } from '@/hooks/queries/workflows'
136136
import type { Workspace } from '@/hooks/queries/workspace'
137137
import { useContextMenu } from '@/hooks/use-context-menu'
138138
import { useMothershipChatEvents } from '@/hooks/use-mothership-chat-events'
139-
import { useOrganizationNavigationHref } from '@/hooks/use-organization-navigation'
140139
import { usePermissionConfig } from '@/hooks/use-permission-config'
141140
import { useSettingsNavigation } from '@/hooks/use-settings-navigation'
142141
import { useFolderStore } from '@/stores/folders/store'
@@ -333,6 +332,10 @@ const HIDDEN_STYLE = { display: 'none' } as const
333332
*/
334333
const DRAG_EXEMPT_CLASS = '[-webkit-app-region:no-drag]'
335334

335+
interface SidebarProps {
336+
organizationHref: string | null
337+
}
338+
336339
/**
337340
* Sidebar component with resizable width that persists across page refreshes.
338341
*
@@ -349,7 +352,7 @@ const DRAG_EXEMPT_CLASS = '[-webkit-app-region:no-drag]'
349352
*
350353
* @returns Sidebar with workflows panel
351354
*/
352-
export const Sidebar = memo(function Sidebar() {
355+
export const Sidebar = memo(function Sidebar({ organizationHref }: SidebarProps) {
353356
const { isCollapsed: isCollapsedProp, isPeeking } = useSidebarChrome()
354357
const isCollapsed = isCollapsedProp && !isPeeking
355358
const params = useParams()
@@ -820,7 +823,6 @@ export const Sidebar = memo(function Sidebar() {
820823
onNavigate: () => handleOpenSettings(id),
821824
}))
822825

823-
const organizationHref = useOrganizationNavigationHref()
824826
if (organizationHref) {
825827
profileNavigationLinks.push({
826828
label: 'Organization',
@@ -1305,6 +1307,7 @@ export const Sidebar = memo(function Sidebar() {
13051307
)}
13061308
>
13071309
<WorkspaceHeader
1310+
organizationHref={organizationHref}
13081311
activeWorkspace={activeWorkspace ?? routeWorkspace}
13091312
workspaceId={workspaceId}
13101313
workspaces={workspaces}

‎apps/sim/hooks/use-organization-navigation.ts‎

Lines changed: 0 additions & 8 deletions
This file was deleted.

‎apps/sim/lib/navigation/paths.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ export const WORKSPACES_PATH = '/workspace'
2323
export const WORKSPACE_SETTINGS_PATH = `${WORKSPACES_PATH}?redirect=settings`
2424

2525
/** Root of the organization surface; `/o` alone resolves like {@link APP_ENTRY_PATH}. */
26-
export const ORGANIZATIONS_PATH = '/o'
26+
const ORGANIZATIONS_PATH = '/o'
2727

2828
/**
2929
* Every destination under one organization's surface, built from one place so the

‎apps/sim/lib/navigation/resolve-app-entry.test.ts‎

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,10 @@ vi.mock('@/lib/organizations/surface', () => ({
1616
resolveOrganizationLanding: mockResolveOrganizationLanding,
1717
}))
1818

19-
import { resolveAppEntryPath } from '@/lib/navigation/resolve-app-entry'
19+
import {
20+
resolveAppEntryPath,
21+
resolveOrganizationEntryPath,
22+
} from '@/lib/navigation/resolve-app-entry'
2023

2124
describe('resolveAppEntryPath', () => {
2225
beforeEach(() => {
@@ -51,3 +54,50 @@ describe('resolveAppEntryPath', () => {
5154
expect(mockSearchAvailable).not.toHaveBeenCalled()
5255
})
5356
})
57+
58+
describe('resolveOrganizationEntryPath', () => {
59+
beforeEach(() => {
60+
vi.clearAllMocks()
61+
mockSearchAvailable.mockResolvedValue(true)
62+
})
63+
64+
it('uses the authenticated viewer membership independently of the workspace host', async () => {
65+
mockResolveOrganizationLanding.mockResolvedValue('viewer-organization')
66+
const session = {
67+
user: { id: 'viewer' },
68+
session: { activeOrganizationId: 'viewer-organization' },
69+
}
70+
71+
await expect(resolveOrganizationEntryPath(session)).resolves.toBe('/o/viewer-organization/home')
72+
expect(mockResolveOrganizationLanding).toHaveBeenCalledWith('viewer', 'viewer-organization')
73+
expect(mockSearchAvailable).toHaveBeenCalledWith({ organizationId: 'viewer-organization' })
74+
})
75+
76+
it('returns no organization destination for a nonmember with a stale active organization', async () => {
77+
mockResolveOrganizationLanding.mockResolvedValue(null)
78+
const session = {
79+
user: { id: 'viewer' },
80+
session: { activeOrganizationId: 'former-organization' },
81+
}
82+
83+
await expect(resolveOrganizationEntryPath(session)).resolves.toBeNull()
84+
expect(mockResolveOrganizationLanding).toHaveBeenCalledWith('viewer', 'former-organization')
85+
expect(mockSearchAvailable).not.toHaveBeenCalled()
86+
})
87+
88+
it('returns no organization destination when the member organization has Search disabled', async () => {
89+
mockResolveOrganizationLanding.mockResolvedValue('viewer-organization')
90+
mockSearchAvailable.mockResolvedValue(false)
91+
92+
await expect(resolveOrganizationEntryPath({ user: { id: 'viewer' } })).resolves.toBeNull()
93+
})
94+
95+
it('propagates membership lookup failures', async () => {
96+
mockResolveOrganizationLanding.mockRejectedValue(new Error('Membership lookup failed'))
97+
98+
await expect(resolveOrganizationEntryPath({ user: { id: 'viewer' } })).rejects.toThrow(
99+
'Membership lookup failed'
100+
)
101+
expect(mockSearchAvailable).not.toHaveBeenCalled()
102+
})
103+
})

‎apps/sim/lib/navigation/resolve-app-entry.ts‎

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,18 @@ interface EntrySession {
77
user: { id: string }
88
}
99

10+
/** Returns a destination only after checking the viewer's membership and organization rollout. */
11+
export async function resolveOrganizationEntryPath(session: EntrySession): Promise<string | null> {
12+
const organizationId = await resolveOrganizationLanding(
13+
session.user.id,
14+
getActiveOrganizationId(session)
15+
)
16+
if (!organizationId) return null
17+
return (await isKnowledgeMemberAccessAvailable({ organizationId }))
18+
? organizationRoutes(organizationId).home
19+
: null
20+
}
21+
1022
/**
1123
* Routes organization members to Home when the organization surface is enabled for
1224
* them. Everyone else — viewers without an organization, and members whose
@@ -16,12 +28,5 @@ interface EntrySession {
1628
* settings must not be dropped into them.
1729
*/
1830
export async function resolveAppEntryPath(session: EntrySession): Promise<string> {
19-
const organizationId = await resolveOrganizationLanding(
20-
session.user.id,
21-
getActiveOrganizationId(session)
22-
)
23-
if (!organizationId) return WORKSPACES_PATH
24-
return (await isKnowledgeMemberAccessAvailable({ organizationId }))
25-
? organizationRoutes(organizationId).home
26-
: WORKSPACES_PATH
31+
return (await resolveOrganizationEntryPath(session)) ?? WORKSPACES_PATH
2732
}

0 commit comments

Comments
 (0)