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 5d808f3dc3d..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,20 +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, hostContext } = vi.hoisted(() => ({ +const { mockNavigateToSettings, mockWorkspacePermissions } = vi.hoisted(() => ({ mockNavigateToSettings: vi.fn(), - hostContext: { - hostOrganizationId: null as string | null, - viewer: { isHostOrganizationMember: false }, - features: { organizationSearch: false as boolean | undefined }, - }, mockWorkspacePermissions: { canAdmin: true, canEdit: true, canRead: true }, })) -vi.mock('@/app/workspace/[workspaceId]/providers/workspace-host-provider', () => ({ - useWorkspaceHostContext: () => hostContext, -})) - const onWorkspaceSwitch = vi.fn() vi.mock('@tanstack/react-query', () => ({ @@ -29,7 +20,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, @@ -112,6 +102,7 @@ function render(overrides: Partial[0]> = {}) function header(overrides: Partial[0]> = {}) { return ( { vi.clearAllMocks() - hostContext.hostOrganizationId = null - hostContext.viewer.isHostOrganizationMember = false - hostContext.features.organizationSearch = false 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 +175,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,27 +401,22 @@ 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) => { + 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([ - { 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('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', () => { 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..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 @@ -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' @@ -79,6 +77,7 @@ function DisabledReasonTooltip({ reason, children }: DisabledReasonTooltipProps) } interface WorkspaceHeaderProps { + organizationHref: string | null /** The active workspace object */ activeWorkspace?: { name: string } | null /** Current workspace ID */ @@ -127,6 +126,7 @@ interface WorkspaceHeaderProps { * Workspace header component that displays workspace name and switcher. */ function WorkspaceHeaderImpl({ + organizationHref, activeWorkspace, workspaceId, workspaces, @@ -273,8 +273,6 @@ function WorkspaceHeaderImpl({ const { navigateToSettings } = useSettingsNavigation() const queryClient = useQueryClient() - const hostContext = useWorkspaceHostContext() - const organizationHref = getWorkspaceOrganizationHref(hostContext) 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..e23a0f51b70 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' @@ -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 = getWorkspaceOrganizationHref(hostContext) if (organizationHref) { profileNavigationLinks.push({ label: 'Organization', @@ -1305,6 +1307,7 @@ export const Sidebar = memo(function Sidebar() { )} > ({ - 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() }, }) } diff --git a/apps/sim/lib/navigation/resolve-app-entry.test.ts b/apps/sim/lib/navigation/resolve-app-entry.test.ts index 2985484df5a..824fbccf61f 100644 --- a/apps/sim/lib/navigation/resolve-app-entry.test.ts +++ b/apps/sim/lib/navigation/resolve-app-entry.test.ts @@ -16,7 +16,10 @@ vi.mock('@/lib/organizations/surface', () => ({ 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 } 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 -}