Skip to content

Commit f12677b

Browse files
fix(navigation): restore organization return from shared workspaces (#8268)
* fix(navigation): restore organization return from shared workspaces * fix(navigation): check organization membership and availability on server * fix(navigation): refresh organization destination after creation
1 parent df3b413 commit f12677b

9 files changed

Lines changed: 162 additions & 72 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: 18 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -6,20 +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, hostContext } = vi.hoisted(() => ({
9+
const { mockNavigateToSettings, mockWorkspacePermissions } = vi.hoisted(() => ({
1010
mockNavigateToSettings: vi.fn(),
11-
hostContext: {
12-
hostOrganizationId: null as string | null,
13-
viewer: { isHostOrganizationMember: false },
14-
features: { organizationSearch: false as boolean | undefined },
15-
},
1611
mockWorkspacePermissions: { canAdmin: true, canEdit: true, canRead: true },
1712
}))
1813

19-
vi.mock('@/app/workspace/[workspaceId]/providers/workspace-host-provider', () => ({
20-
useWorkspaceHostContext: () => hostContext,
21-
}))
22-
2314
const onWorkspaceSwitch = vi.fn()
2415

2516
vi.mock('@tanstack/react-query', () => ({
@@ -29,7 +20,6 @@ vi.mock('next/navigation', () => ({
2920
useRouter: () => ({ push: vi.fn() }),
3021
usePathname: () => '/workspace/ws-emir/home',
3122
}))
32-
vi.mock('@/lib/auth/auth-client', () => ({ useActiveOrganization: () => ({ data: null }) }))
3323
vi.mock('@/hooks/use-settings-navigation', () => ({
3424
useSettingsNavigation: () => ({
3525
navigateToSettings: mockNavigateToSettings,
@@ -112,6 +102,7 @@ function render(overrides: Partial<Parameters<typeof WorkspaceHeader>[0]> = {})
112102
function header(overrides: Partial<Parameters<typeof WorkspaceHeader>[0]> = {}) {
113103
return (
114104
<WorkspaceHeader
105+
organizationHref={null}
115106
activeWorkspace={{ name: "Emir's Workspace" }}
116107
workspaceId='ws-emir'
117108
workspaces={WORKSPACES}
@@ -170,9 +161,6 @@ function typeInto(input: HTMLInputElement, value: string) {
170161

171162
beforeEach(() => {
172163
vi.clearAllMocks()
173-
hostContext.hostOrganizationId = null
174-
hostContext.viewer.isHostOrganizationMember = false
175-
hostContext.features.organizationSearch = false
176164
Object.assign(mockWorkspacePermissions, { canAdmin: true, canEdit: true, canRead: true })
177165
// jsdom implements neither; the component scrolls the active row into view.
178166
Element.prototype.scrollIntoView = vi.fn()
@@ -187,8 +175,7 @@ describe('WorkspaceHeader workspace switcher highlight', () => {
187175
it.each([null, 'organization'])(
188176
'keeps access requests out of the workspace switcher (%s)',
189177
(organizationId) => {
190-
hostContext.hostOrganizationId = organizationId
191-
render()
178+
render({ workspaces: WORKSPACES.map((workspace) => ({ ...workspace, organizationId })) })
192179
expect(document.body).not.toHaveTextContent('My access requests')
193180
expect(document.body).not.toHaveTextContent('Review access requests')
194181
}
@@ -414,27 +401,22 @@ describe('WorkspaceHeader workspace switcher highlight', () => {
414401
})
415402

416403
describe('WorkspaceHeader context navigation', () => {
417-
it('links to the current host organization for enrolled members', () => {
418-
hostContext.hostOrganizationId = 'host-org'
419-
hostContext.viewer.isHostOrganizationMember = true
420-
hostContext.features.organizationSearch = true
421-
render()
422-
expect(document.querySelector('a[href="/o/host-org"]')).toHaveTextContent(
423-
'Back to organization'
424-
)
425-
})
404+
it.each([null, 'another-organization', 'viewer-organization'])(
405+
'links to the viewer organization landing independently of workspace host %s',
406+
(organizationId) => {
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+
)
414+
}
415+
)
426416

427-
it.each([
428-
{ org: null, member: true, enabled: true },
429-
{ org: 'host-org', member: false, enabled: true },
430-
{ org: 'host-org', member: true, enabled: false },
431-
{ org: 'host-org', member: true, enabled: undefined },
432-
])('hides inaccessible organization navigation: %j', ({ org, member, enabled }) => {
433-
hostContext.hostOrganizationId = org
434-
hostContext.viewer.isHostOrganizationMember = member
435-
hostContext.features.organizationSearch = enabled
436-
render()
437-
expect(document.querySelector('a[href^="/o/"]')).toBeNull()
417+
it('hides organization navigation without an eligible destination', () => {
418+
render({ organizationHref: null })
419+
expect(document.body).not.toHaveTextContent('Back to organization')
438420
})
439421

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

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

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,9 +34,7 @@ import { WorkspaceContextMenu } from '@/components/workspaces/workspace-context-
3434
import { useDeploymentShape } from '@/lib/core/config/deployment-shape'
3535
import { WORKSPACE_SEARCH_THRESHOLD } from '@/lib/workspaces/constants'
3636
import { getWorkspaceInitial } from '@/lib/workspaces/initials'
37-
import { getWorkspaceOrganizationHref } from '@/lib/workspaces/organization-navigation'
3837
import { InviteModal } from '@/app/workspace/[workspaceId]/components/invite-modal'
39-
import { useWorkspaceHostContext } from '@/app/workspace/[workspaceId]/providers/workspace-host-provider'
4038
import { useWorkspacePermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
4139
import { SidebarRenameRow } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-rename-row'
4240
import { DeleteModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/delete-modal/delete-modal'
@@ -79,6 +77,7 @@ function DisabledReasonTooltip({ reason, children }: DisabledReasonTooltipProps)
7977
}
8078

8179
interface WorkspaceHeaderProps {
80+
organizationHref: string | null
8281
/** The active workspace object */
8382
activeWorkspace?: { name: string } | null
8483
/** Current workspace ID */
@@ -127,6 +126,7 @@ interface WorkspaceHeaderProps {
127126
* Workspace header component that displays workspace name and switcher.
128127
*/
129128
function WorkspaceHeaderImpl({
129+
organizationHref,
130130
activeWorkspace,
131131
workspaceId,
132132
workspaces,
@@ -273,8 +273,6 @@ function WorkspaceHeaderImpl({
273273

274274
const { navigateToSettings } = useSettingsNavigation()
275275
const queryClient = useQueryClient()
276-
const hostContext = useWorkspaceHostContext()
277-
const organizationHref = getWorkspaceOrganizationHref(hostContext)
278276

279277
const activeWorkspaceFull = workspaces.find((w) => w.id === workspaceId) || null
280278
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
@@ -51,7 +51,6 @@ import { DOCS_URL, SLACK_COMMUNITY_URL } from '@/lib/help-links'
5151
import { SIM_RESOURCES_DRAG_TYPE } from '@/lib/mothership/resource-types'
5252
import { captureEvent } from '@/lib/posthog/client'
5353
import { LOGO_ACCEPT_ATTRIBUTE } from '@/lib/uploads/client/logo-file'
54-
import { getWorkspaceOrganizationHref } from '@/lib/workspaces/organization-navigation'
5554
import { useSidebarChrome } from '@/app/workspace/[workspaceId]/components/workspace-chrome'
5655
import { CONNECT_MODE } from '@/app/workspace/[workspaceId]/integrations/connect-route'
5756
import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
@@ -334,6 +333,10 @@ const HIDDEN_STYLE = { display: 'none' } as const
334333
*/
335334
const DRAG_EXEMPT_CLASS = '[-webkit-app-region:no-drag]'
336335

336+
interface SidebarProps {
337+
organizationHref: string | null
338+
}
339+
337340
/**
338341
* Sidebar component with resizable width that persists across page refreshes.
339342
*
@@ -350,7 +353,7 @@ const DRAG_EXEMPT_CLASS = '[-webkit-app-region:no-drag]'
350353
*
351354
* @returns Sidebar with workflows panel
352355
*/
353-
export const Sidebar = memo(function Sidebar() {
356+
export const Sidebar = memo(function Sidebar({ organizationHref }: SidebarProps) {
354357
const { isCollapsed: isCollapsedProp, isPeeking } = useSidebarChrome()
355358
const isCollapsed = isCollapsedProp && !isPeeking
356359
const params = useParams()
@@ -821,7 +824,6 @@ export const Sidebar = memo(function Sidebar() {
821824
onNavigate: () => handleOpenSettings(id),
822825
}))
823826

824-
const organizationHref = getWorkspaceOrganizationHref(hostContext)
825827
if (organizationHref) {
826828
profileNavigationLinks.push({
827829
label: 'Organization',
@@ -1306,6 +1308,7 @@ export const Sidebar = memo(function Sidebar() {
13061308
)}
13071309
>
13081310
<WorkspaceHeader
1311+
organizationHref={organizationHref}
13091312
activeWorkspace={activeWorkspace ?? routeWorkspace}
13101313
workspaceId={workspaceId}
13111314
workspaces={workspaces}

‎apps/sim/hooks/queries/organization.test.tsx‎

Lines changed: 63 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,25 @@ import { createRoot, type Root } from 'react-dom/client'
88
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
99
import { ApiClientError } from '@/lib/api/client/errors'
1010

11-
const { mockGetFullOrganization, mockListOrganizations, mockRequestJson, featureFlags } =
12-
vi.hoisted(() => ({
13-
mockGetFullOrganization: vi.fn(),
14-
mockListOrganizations: vi.fn(),
15-
mockRequestJson: vi.fn(),
16-
featureFlags: { organizations: true },
17-
}))
11+
const {
12+
mockGetFullOrganization,
13+
mockListOrganizations,
14+
mockSetActiveOrganization,
15+
mockRefresh,
16+
mockRequestJson,
17+
featureFlags,
18+
} = vi.hoisted(() => ({
19+
mockGetFullOrganization: vi.fn(),
20+
mockListOrganizations: vi.fn(),
21+
mockSetActiveOrganization: vi.fn(),
22+
mockRefresh: vi.fn(),
23+
mockRequestJson: vi.fn(),
24+
featureFlags: { organizations: true },
25+
}))
26+
27+
vi.mock('next/navigation', () => ({
28+
useRouter: () => ({ refresh: mockRefresh }),
29+
}))
1830

1931
vi.mock('@/lib/core/config/env-flags', () => ({
2032
get isOrganizationsEnabled() {
@@ -31,6 +43,7 @@ vi.mock('@/lib/auth/auth-client', () => ({
3143
organization: {
3244
getFullOrganization: mockGetFullOrganization,
3345
list: mockListOrganizations,
46+
setActive: mockSetActiveOrganization,
3447
},
3548
subscription: {
3649
list: vi.fn(),
@@ -49,6 +62,7 @@ import {
4962
} from '@/lib/api/contracts/subscription'
5063
import {
5164
organizationKeys,
65+
useCreateOrganization,
5266
useOrganization,
5367
useOrganizationBilling,
5468
useOrganizationList,
@@ -199,6 +213,48 @@ describe('organization identity transitions', () => {
199213
expect(signal).toBeInstanceOf(AbortSignal)
200214
})
201215

216+
it.each([true, false])(
217+
'refreshes the server layout after organization activation settles (success=%s)',
218+
async (success) => {
219+
mockRequestJson.mockResolvedValue({ organizationId: 'new-organization' })
220+
const activation = createDeferred<{ error: { message: string } | null }>()
221+
mockSetActiveOrganization.mockReturnValue(activation.promise)
222+
let mutation: ReturnType<typeof useCreateOrganization>
223+
function CreationProbe() {
224+
mutation = useCreateOrganization()
225+
return null
226+
}
227+
228+
await act(async () => {
229+
root.render(
230+
<QueryClientProvider client={queryClient}>
231+
<CreationProbe />
232+
</QueryClientProvider>
233+
)
234+
})
235+
let pending: Promise<unknown>
236+
await act(async () => {
237+
pending = mutation.mutateAsync({ name: 'New organization' })
238+
})
239+
expect(mockSetActiveOrganization).toHaveBeenCalledWith({
240+
organizationId: 'new-organization',
241+
})
242+
expect(mockRefresh).not.toHaveBeenCalled()
243+
244+
await act(async () => {
245+
if (success) {
246+
activation.resolve({ error: null })
247+
await pending
248+
} else {
249+
const rejection = expect(pending).rejects.toThrow('Activation failed')
250+
activation.resolve({ error: { message: 'Activation failed' } })
251+
await rejection
252+
}
253+
})
254+
expect(mockRefresh).toHaveBeenCalledOnce()
255+
}
256+
)
257+
202258
it('does not call the organization plugin when organizations are disabled', async () => {
203259
featureFlags.organizations = false
204260
await act(async () =>

‎apps/sim/hooks/queries/organization.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
useQuery,
88
useQueryClient,
99
} from '@tanstack/react-query'
10+
import { useRouter } from 'next/navigation'
1011
import { ApiClientError } from '@/lib/api/client/errors'
1112
import { requestJson } from '@/lib/api/client/request'
1213
import type { ContractBodyInput } from '@/lib/api/contracts'
@@ -605,6 +606,7 @@ type CreateOrganizationParams = Pick<
605606

606607
export function useCreateOrganization() {
607608
const queryClient = useQueryClient()
609+
const router = useRouter()
608610

609611
return useMutation({
610612
mutationFn: async ({ name, slug }: CreateOrganizationParams) => {
@@ -615,15 +617,17 @@ export function useCreateOrganization() {
615617
},
616618
})
617619

618-
await client.organization.setActive({
620+
const { error } = await client.organization.setActive({
619621
organizationId: data.organizationId,
620622
})
623+
if (error) throw new Error(error.message || 'Failed to activate organization')
621624

622625
return data
623626
},
624627
onSettled: () => {
625628
queryClient.invalidateQueries({ queryKey: organizationKeys.lists() })
626629
queryClient.invalidateQueries({ queryKey: workspaceKeys.lists() })
630+
router.refresh()
627631
},
628632
})
629633
}

0 commit comments

Comments
 (0)