Skip to content

Commit 6187085

Browse files
fix(settings): keep organization settings inline without Sim Search
1 parent 470c8d4 commit 6187085

19 files changed

Lines changed: 300 additions & 159 deletions

File tree

apps/sim/app/o/[organizationId]/home/page.test.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -101,9 +101,11 @@ describe('organization Search page gates', () => {
101101
expect(mocks.context).not.toHaveBeenCalled()
102102
})
103103

104-
it('lands the organization entry on Members settings when Search is disabled', async () => {
104+
it('returns the organization entry to workspace settings when Search is disabled', async () => {
105105
mocks.context.mockResolvedValue({ searchAccess: { memberScoped: false } })
106-
await expect(OrganizationPage({ params })).rejects.toThrow('redirect:/o/org-1/settings/members')
106+
await expect(OrganizationPage({ params })).rejects.toThrow(
107+
'redirect:/workspace?redirect=settings'
108+
)
107109
expect(mocks.context).toHaveBeenCalledWith('org-1', 'viewer')
108110
})
109111

apps/sim/app/o/[organizationId]/layout.test.tsx

Lines changed: 14 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -123,8 +123,7 @@ describe('OrganizationLayout', () => {
123123
{},
124124
'org-1',
125125
{ kind: 'session', userId: 'viewer-1', sessionId: 'session-1' },
126-
'active-org',
127-
true
126+
'active-org'
128127
)
129128
expect(html).toContain('Organization child')
130129
expect(mockUseMothershipChatEvents).toHaveBeenCalledWith(
@@ -159,15 +158,14 @@ describe('OrganizationLayout', () => {
159158
{},
160159
'org-1',
161160
{ kind: 'session', userId: 'viewer-1', sessionId: 'session-1' },
162-
null,
163-
true
161+
null
164162
)
165163
expect(html).toContain('Impersonating QA Member (member@example.com)')
166164
expect(html).toContain('Stop impersonating')
167165
expect(html.indexOf('Stop impersonating')).toBeLessThan(html.indexOf('Organization child'))
168166
})
169167

170-
it('uses the impersonated member to open settings when Search is disabled', async () => {
168+
it('does not use the impersonating admin to enter an organization outside the rollout', async () => {
171169
mockGetSession.mockResolvedValue({
172170
user: { id: 'customer-member' },
173171
session: { id: 'session-1', impersonatedBy: 'platform-admin' },
@@ -177,24 +175,18 @@ describe('OrganizationLayout', () => {
177175
searchAccess: { memberScoped: false, sourceMirrored: false },
178176
})
179177

180-
const html = renderToStaticMarkup(
181-
await OrganizationLayout({
178+
await expect(
179+
OrganizationLayout({
182180
children: <div>Organization child</div>,
183181
params: Promise.resolve({ organizationId: 'customer-org' }),
184182
})
185-
)
183+
).rejects.toThrow('redirect:/workspace?redirect=settings')
186184
expect(mockGetOrganizationSurfaceContext).toHaveBeenCalledWith(
187185
'customer-org',
188186
'customer-member'
189187
)
190-
expect(html).toContain('Organization child')
191-
expect(mockPrefetchOrganizationSidebar).toHaveBeenCalledWith(
192-
{},
193-
'customer-org',
194-
{ kind: 'session', userId: 'customer-member', sessionId: 'session-1' },
195-
null,
196-
false
197-
)
188+
expect(mockWorkspaceChrome).not.toHaveBeenCalled()
189+
expect(mockPrefetchOrganizationSidebar).not.toHaveBeenCalled()
198190
})
199191

200192
it('renders an explicit denial for a non-member without the surface', async () => {
@@ -213,32 +205,22 @@ describe('OrganizationLayout', () => {
213205
})
214206

215207
it.each(['owner', 'admin', 'member'])(
216-
'renders organization settings for a %s when Search is disabled',
208+
'returns %s viewers outside the rollout to workspace settings before rendering org chrome',
217209
async (role) => {
218210
mockGetOrganizationSurfaceContext.mockResolvedValue({
219211
...SURFACE_CONTEXT,
220212
viewer: { role, isAdmin: role !== 'member' },
221213
searchAccess: { memberScoped: false, sourceMirrored: true },
222214
})
223215

224-
const html = renderToStaticMarkup(
225-
await OrganizationLayout({
216+
await expect(
217+
OrganizationLayout({
226218
children: <div>Organization settings</div>,
227219
params: Promise.resolve({ organizationId: 'org-1' }),
228220
})
229-
)
230-
expect(html).toContain('Organization settings')
231-
expect(mockPrefetchOrganizationSidebar).toHaveBeenCalledWith(
232-
{},
233-
'org-1',
234-
{ kind: 'session', userId: 'viewer-1', sessionId: 'session-1' },
235-
'active-org',
236-
false
237-
)
238-
expect(mockUseMothershipChatEvents).toHaveBeenCalledWith(
239-
undefined,
240-
SURFACE_CONTEXT.deployment.chatEnabled
241-
)
221+
).rejects.toThrow('redirect:/workspace?redirect=settings')
222+
expect(mockWorkspaceChrome).not.toHaveBeenCalled()
223+
expect(mockPrefetchOrganizationSidebar).not.toHaveBeenCalled()
242224
}
243225
)
244226

apps/sim/app/o/[organizationId]/layout.tsx

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { cookies } from 'next/headers'
33
import { redirect } from 'next/navigation'
44
import { getSession } from '@/lib/auth'
55
import { getActiveOrganizationId } from '@/lib/auth/session-response'
6-
import { organizationRoutes } from '@/lib/navigation/paths'
6+
import { organizationRoutes, WORKSPACE_SETTINGS_PATH } from '@/lib/navigation/paths'
77
import { getOrganizationSurfaceContext } from '@/lib/organizations/surface'
88
import { getQueryClient } from '@/app/_shell/providers/get-query-client'
99
import { buildAuthCrossLink } from '@/app/(auth)/auth-redirect'
@@ -18,8 +18,9 @@ import { GlobalCommandsProvider } from '@/app/workspace/[workspaceId]/providers/
1818

1919
/**
2020
* The organization surface: the viewer's own view of one organization, outside
21-
* any workspace. Requires membership; Search availability is checked by the
22-
* individual pages and controls that use it.
21+
* any workspace. Requires membership and the organization's Search rollout.
22+
* Non-members get an explicit denial; members outside the rollout retain
23+
* workspace settings, including when following a saved organization link.
2324
*/
2425
export default async function OrganizationLayout({
2526
children,
@@ -47,12 +48,13 @@ export default async function OrganizationLayout({
4748
if (!context) {
4849
return <OrganizationAccessDenied />
4950
}
51+
if (!context.searchAccess.memberScoped) redirect(WORKSPACE_SETTINGS_PATH)
52+
5053
await prefetchOrganizationSidebar(
5154
queryClient,
5255
organizationId,
5356
{ kind: 'session', userId: session.user.id, sessionId: session.session.id },
54-
getActiveOrganizationId(session),
55-
context.searchAccess.memberScoped
57+
getActiveOrganizationId(session)
5658
)
5759

5860
const initialSidebarCollapsed = cookieStore.get('sidebar_collapsed')?.value === '1'

apps/sim/app/o/[organizationId]/page.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { notFound, redirect } from 'next/navigation'
22
import { getSession } from '@/lib/auth'
3-
import { organizationRoutes } from '@/lib/navigation/paths'
3+
import { organizationRoutes, WORKSPACE_SETTINGS_PATH } from '@/lib/navigation/paths'
44
import { getOrganizationSurfaceContext } from '@/lib/organizations/surface'
55
import { buildAuthCrossLink } from '@/app/(auth)/auth-redirect'
66

@@ -17,5 +17,5 @@ export default async function OrganizationPage({
1717
}
1818
const context = await getOrganizationSurfaceContext(organizationId, session.user.id)
1919
if (!context) notFound()
20-
redirect(context.searchAccess.memberScoped ? routes.home : routes.settingsSection('members'))
20+
redirect(context.searchAccess.memberScoped ? routes.home : WORKSPACE_SETTINGS_PATH)
2121
}

apps/sim/app/o/[organizationId]/prefetch.test.ts

Lines changed: 2 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -61,8 +61,8 @@ function makeClient() {
6161
return new QueryClient({ defaultOptions: { queries: { retry: false } } })
6262
}
6363

64-
function prefetch(client: QueryClient, searchAvailable = true) {
65-
return prefetchOrganizationSidebar(client, 'route-org', PRINCIPAL, 'active-org', searchAvailable)
64+
function prefetch(client: QueryClient) {
65+
return prefetchOrganizationSidebar(client, 'route-org', PRINCIPAL, 'active-org')
6666
}
6767

6868
describe('organization sidebar hydration', () => {
@@ -132,16 +132,6 @@ describe('organization sidebar hydration', () => {
132132
expect(dehydrate(client).queries).toHaveLength(3)
133133
})
134134

135-
it('hydrates workspace and profile data without loading Search chats when Search is disabled', async () => {
136-
const client = makeClient()
137-
await prefetch(client, false)
138-
139-
expect(mockListOrganizationChats).not.toHaveBeenCalled()
140-
expect(client.getQueryState(CHAT_KEY)).toBeUndefined()
141-
expect(client.getQueryData(workspaceKeys.list('active'))).toMatchObject(WORKSPACES)
142-
expect(client.getQueryData(userProfileKeys.profile())).toMatchObject({ name: 'Ada' })
143-
})
144-
145135
it('caches an empty chat list but leaves empty workspaces for the client creation path', async () => {
146136
mockListOrganizationChats.mockResolvedValue([])
147137
mockListWorkspacesForViewer.mockResolvedValue({ ...WORKSPACES, workspaces: [] })

apps/sim/app/o/[organizationId]/prefetch.ts

Lines changed: 12 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -18,22 +18,20 @@ export async function prefetchOrganizationSidebar(
1818
queryClient: QueryClient,
1919
organizationId: string,
2020
principal: SessionPrincipal,
21-
activeOrganizationId: string | null,
22-
searchAvailable: boolean
21+
activeOrganizationId: string | null
2322
): Promise<void> {
2423
await Promise.all([
25-
searchAvailable &&
26-
queryClient.prefetchQuery({
27-
queryKey: mothershipChatKeys.organizationList(organizationId, 'active'),
28-
queryFn: async () => {
29-
const chats = await listOrganizationChats.execute({
30-
principal,
31-
input: { organizationId, scope: 'active' },
32-
})
33-
return chats.map(mapChat)
34-
},
35-
staleTime: MOTHERSHIP_CHAT_LIST_STALE_TIME,
36-
}),
24+
queryClient.prefetchQuery({
25+
queryKey: mothershipChatKeys.organizationList(organizationId, 'active'),
26+
queryFn: async () => {
27+
const chats = await listOrganizationChats.execute({
28+
principal,
29+
input: { organizationId, scope: 'active' },
30+
})
31+
return chats.map(mapChat)
32+
},
33+
staleTime: MOTHERSHIP_CHAT_LIST_STALE_TIME,
34+
}),
3735
seedWorkspaceList(queryClient, principal.userId, activeOrganizationId),
3836
prefetchUserProfile(queryClient, principal.userId),
3937
])

apps/sim/app/workspace/[workspaceId]/settings/[section]/page.test.tsx

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -112,17 +112,17 @@ describe('WorkspaceSettingsSectionPage', () => {
112112
})
113113

114114
it.each(Object.entries(UNIFIED_TO_ORGANIZATION_SECTION))(
115-
'routes %s to organization %s when Search is disabled',
116-
async (section, organizationSection) => {
115+
'keeps %s in the workspace outside the organization rollout',
116+
async (section) => {
117117
mockGetHostContext.mockResolvedValue({
118118
hostOrganizationId: 'org-target',
119119
features: { organizationSearch: false, knowledgeMemberAccess: true },
120120
})
121121

122-
await expect(WorkspaceSettingsSectionPage(pageProps(section))).rejects.toThrow(
123-
`NEXT_REDIRECT:/o/org-target/settings/${organizationSection}`
124-
)
125-
expect(mockSectionPrefetch).not.toHaveBeenCalled()
122+
const element = await WorkspaceSettingsSectionPage(pageProps(section))
123+
124+
expect(element).toBeTruthy()
125+
expect(mockRedirect).not.toHaveBeenCalled()
126126
expect(mockAuthorizeSection).toHaveBeenCalledWith({
127127
workspaceId: 'workspace-b',
128128
userId: 'viewer-a',
@@ -132,19 +132,19 @@ describe('WorkspaceSettingsSectionPage', () => {
132132
)
133133

134134
it.each([undefined, { credentialGroups: true, knowledgeMemberAccess: true }])(
135-
'routes settings to the organization when older host context omits Search availability',
135+
'keeps settings in the workspace when older host context omits the org rollout',
136136
async (features) => {
137137
mockGetHostContext.mockResolvedValue({ hostOrganizationId: 'org-target', features })
138138

139-
await expect(WorkspaceSettingsSectionPage(pageProps('billing'))).rejects.toThrow(
140-
'NEXT_REDIRECT:/o/org-target/settings/billing'
141-
)
142-
expect(mockSectionPrefetch).not.toHaveBeenCalled()
139+
await WorkspaceSettingsSectionPage(pageProps('billing'))
140+
141+
expect(mockRedirect).not.toHaveBeenCalled()
142+
expect(mockSectionPrefetch).toHaveBeenCalledTimes(1)
143143
}
144144
)
145145

146146
it.each(Object.entries(UNIFIED_TO_ORGANIZATION_SECTION))(
147-
'routes %s to organization %s when Search is enabled',
147+
'routes %s to organization %s only within the organization rollout',
148148
async (section, organizationSection) => {
149149
mockGetHostContext.mockResolvedValue({
150150
hostOrganizationId: 'org-target',

apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ export default async function WorkspaceSettingsSectionPage({
6060
const organizationSection = UNIFIED_TO_ORGANIZATION_SECTION[parsed]
6161
if (organizationSection) {
6262
const hostContext = await getWorkspaceHostContextForViewer(workspaceId, session.user.id)
63-
if (hostContext?.hostOrganizationId) {
63+
if (hostContext?.hostOrganizationId && hostContext.features?.organizationSearch) {
6464
const query = new URLSearchParams()
6565
for (const [key, value] of Object.entries((await searchParams) ?? {})) {
6666
for (const entry of Array.isArray(value) ? value : value === undefined ? [] : [value]) {
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
/**
2+
* @vitest-environment jsdom
3+
*/
4+
import { act, type ComponentType, lazy, type ReactNode, Suspense } from 'react'
5+
import { createRoot } from 'react-dom/client'
6+
import { expect, it, vi } from 'vitest'
7+
8+
vi.mock('next/dynamic', () => ({
9+
default: (load: () => Promise<ComponentType>) => lazy(async () => ({ default: await load() })),
10+
}))
11+
vi.mock('posthog-js/react', () => ({ usePostHog: () => null }))
12+
vi.mock('@/lib/posthog/client', () => ({ captureEvent: vi.fn() }))
13+
vi.mock('@/lib/auth/auth-client', () => ({
14+
useSession: () => ({ data: { user: { id: 'viewer-1', role: 'user' } }, isPending: false }),
15+
}))
16+
vi.mock('@/lib/core/config/deployment-shape', () => ({
17+
useDeploymentShape: () => ({ billingEnabled: false }),
18+
}))
19+
vi.mock('@/app/workspace/[workspaceId]/providers/workspace-host-provider', () => ({
20+
useWorkspaceHostContext: () => ({
21+
hostOrganizationId: 'organization-1',
22+
workspace: { id: 'workspace-1' },
23+
}),
24+
}))
25+
vi.mock('@/app/workspace/[workspaceId]/settings/components/general/general', () => ({
26+
General: () => <div>General settings</div>,
27+
}))
28+
vi.mock(
29+
'@/app/workspace/[workspaceId]/settings/components/team-management/team-management',
30+
() => ({
31+
TeamManagement: ({ organizationId }: { organizationId: string }) => (
32+
<div>Members of {organizationId}</div>
33+
),
34+
})
35+
)
36+
vi.mock('@/app/workspace/[workspaceId]/settings/components/settings-panel', () => ({
37+
SettingsSectionProvider: ({ children }: { children: ReactNode }) => children,
38+
}))
39+
vi.mock('@/app/workspace/[workspaceId]/settings/navigation', () => ({
40+
getSettingsSectionMeta: () => null,
41+
}))
42+
43+
import { SettingsPage } from '@/app/workspace/[workspaceId]/settings/[section]/settings'
44+
45+
it('renders the inline member roster with billing disabled, while billing stays unavailable', async () => {
46+
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
47+
const container = document.createElement('div')
48+
const root = createRoot(container)
49+
try {
50+
await act(async () => {
51+
root.render(
52+
<Suspense>
53+
<SettingsPage section='organization' />
54+
</Suspense>
55+
)
56+
})
57+
expect(container).toHaveTextContent('Members of organization-1')
58+
expect(container).not.toHaveTextContent('General settings')
59+
60+
await act(async () => root.render(<SettingsPage section='billing' />))
61+
expect(container).toHaveTextContent('General settings')
62+
} finally {
63+
act(() => root.unmount())
64+
}
65+
})

apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,7 @@ export function SettingsPage({ section }: SettingsPageProps) {
138138
const normalizedSection: SettingsSection =
139139
(section as string) === 'subscription' ? 'billing' : section
140140
const effectiveSection =
141-
!billingEnabled && (normalizedSection === 'billing' || normalizedSection === 'organization')
141+
!billingEnabled && normalizedSection === 'billing'
142142
? 'general'
143143
: normalizedSection === 'admin' && !sessionLoading && !isAdminRole
144144
? 'general'
@@ -192,7 +192,7 @@ export function SettingsPage({ section }: SettingsPageProps) {
192192
/>
193193
)}
194194
{effectiveSection === 'teammates' && <Teammates />}
195-
{billingEnabled && effectiveSection === 'organization' && organizationId && (
195+
{effectiveSection === 'organization' && organizationId && (
196196
<TeamManagement
197197
organizationId={organizationId}
198198
billingHref={`/workspace/${hostContext.workspace.id}/settings/billing`}

0 commit comments

Comments
 (0)