Skip to content

Commit 470c8d4

Browse files
fix(settings): decouple organization access from Sim Search
1 parent d61006a commit 470c8d4

12 files changed

Lines changed: 125 additions & 133 deletions

File tree

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

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

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

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

Lines changed: 32 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,8 @@ describe('OrganizationLayout', () => {
123123
{},
124124
'org-1',
125125
{ kind: 'session', userId: 'viewer-1', sessionId: 'session-1' },
126-
'active-org'
126+
'active-org',
127+
true
127128
)
128129
expect(html).toContain('Organization child')
129130
expect(mockUseMothershipChatEvents).toHaveBeenCalledWith(
@@ -158,14 +159,15 @@ describe('OrganizationLayout', () => {
158159
{},
159160
'org-1',
160161
{ kind: 'session', userId: 'viewer-1', sessionId: 'session-1' },
161-
null
162+
null,
163+
true
162164
)
163165
expect(html).toContain('Impersonating QA Member (member@example.com)')
164166
expect(html).toContain('Stop impersonating')
165167
expect(html.indexOf('Stop impersonating')).toBeLessThan(html.indexOf('Organization child'))
166168
})
167169

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

178-
await expect(
179-
OrganizationLayout({
180+
const html = renderToStaticMarkup(
181+
await OrganizationLayout({
180182
children: <div>Organization child</div>,
181183
params: Promise.resolve({ organizationId: 'customer-org' }),
182184
})
183-
).rejects.toThrow('redirect:/workspace?redirect=settings')
185+
)
184186
expect(mockGetOrganizationSurfaceContext).toHaveBeenCalledWith(
185187
'customer-org',
186188
'customer-member'
187189
)
188-
expect(mockWorkspaceChrome).not.toHaveBeenCalled()
189-
expect(mockPrefetchOrganizationSidebar).not.toHaveBeenCalled()
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+
)
190198
})
191199

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

207215
it.each(['owner', 'admin', 'member'])(
208-
'returns %s viewers outside the rollout to workspace settings before rendering org chrome',
216+
'renders organization settings for a %s when Search is disabled',
209217
async (role) => {
210218
mockGetOrganizationSurfaceContext.mockResolvedValue({
211219
...SURFACE_CONTEXT,
212220
viewer: { role, isAdmin: role !== 'member' },
213221
searchAccess: { memberScoped: false, sourceMirrored: true },
214222
})
215223

216-
await expect(
217-
OrganizationLayout({
224+
const html = renderToStaticMarkup(
225+
await OrganizationLayout({
218226
children: <div>Organization settings</div>,
219227
params: Promise.resolve({ organizationId: 'org-1' }),
220228
})
221-
).rejects.toThrow('redirect:/workspace?redirect=settings')
222-
expect(mockWorkspaceChrome).not.toHaveBeenCalled()
223-
expect(mockPrefetchOrganizationSidebar).not.toHaveBeenCalled()
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+
)
224242
}
225243
)
226244

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

Lines changed: 5 additions & 7 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, WORKSPACE_SETTINGS_PATH } from '@/lib/navigation/paths'
6+
import { organizationRoutes } 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,9 +18,8 @@ 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 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.
21+
* any workspace. Requires membership; Search availability is checked by the
22+
* individual pages and controls that use it.
2423
*/
2524
export default async function OrganizationLayout({
2625
children,
@@ -48,13 +47,12 @@ export default async function OrganizationLayout({
4847
if (!context) {
4948
return <OrganizationAccessDenied />
5049
}
51-
if (!context.searchAccess.memberScoped) redirect(WORKSPACE_SETTINGS_PATH)
52-
5350
await prefetchOrganizationSidebar(
5451
queryClient,
5552
organizationId,
5653
{ kind: 'session', userId: session.user.id, sessionId: session.session.id },
57-
getActiveOrganizationId(session)
54+
getActiveOrganizationId(session),
55+
context.searchAccess.memberScoped
5856
)
5957

6058
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, WORKSPACE_SETTINGS_PATH } from '@/lib/navigation/paths'
3+
import { organizationRoutes } 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 : WORKSPACE_SETTINGS_PATH)
20+
redirect(context.searchAccess.memberScoped ? routes.home : routes.settingsSection('members'))
2121
}

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

Lines changed: 12 additions & 2 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) {
65-
return prefetchOrganizationSidebar(client, 'route-org', PRINCIPAL, 'active-org')
64+
function prefetch(client: QueryClient, searchAvailable = true) {
65+
return prefetchOrganizationSidebar(client, 'route-org', PRINCIPAL, 'active-org', searchAvailable)
6666
}
6767

6868
describe('organization sidebar hydration', () => {
@@ -132,6 +132,16 @@ 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+
135145
it('caches an empty chat list but leaves empty workspaces for the client creation path', async () => {
136146
mockListOrganizationChats.mockResolvedValue([])
137147
mockListWorkspacesForViewer.mockResolvedValue({ ...WORKSPACES, workspaces: [] })

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

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -18,20 +18,22 @@ export async function prefetchOrganizationSidebar(
1818
queryClient: QueryClient,
1919
organizationId: string,
2020
principal: SessionPrincipal,
21-
activeOrganizationId: string | null
21+
activeOrganizationId: string | null,
22+
searchAvailable: boolean
2223
): Promise<void> {
2324
await Promise.all([
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-
}),
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+
}),
3537
seedWorkspaceList(queryClient, principal.userId, activeOrganizationId),
3638
prefetchUserProfile(queryClient, principal.userId),
3739
])

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-
'keeps %s in the workspace outside the organization rollout',
116-
async (section) => {
115+
'routes %s to organization %s when Search is disabled',
116+
async (section, organizationSection) => {
117117
mockGetHostContext.mockResolvedValue({
118118
hostOrganizationId: 'org-target',
119119
features: { organizationSearch: false, knowledgeMemberAccess: true },
120120
})
121121

122-
const element = await WorkspaceSettingsSectionPage(pageProps(section))
123-
124-
expect(element).toBeTruthy()
125-
expect(mockRedirect).not.toHaveBeenCalled()
122+
await expect(WorkspaceSettingsSectionPage(pageProps(section))).rejects.toThrow(
123+
`NEXT_REDIRECT:/o/org-target/settings/${organizationSection}`
124+
)
125+
expect(mockSectionPrefetch).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-
'keeps settings in the workspace when older host context omits the org rollout',
135+
'routes settings to the organization when older host context omits Search availability',
136136
async (features) => {
137137
mockGetHostContext.mockResolvedValue({ hostOrganizationId: 'org-target', features })
138138

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

146146
it.each(Object.entries(UNIFIED_TO_ORGANIZATION_SECTION))(
147-
'routes %s to organization %s only within the organization rollout',
147+
'routes %s to organization %s when Search is enabled',
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 && hostContext.features?.organizationSearch) {
63+
if (hostContext?.hostOrganizationId) {
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]) {

0 commit comments

Comments
 (0)