Skip to content

Commit 7d18d4f

Browse files
committed
fix(settings): gate organization view with search rollout
1 parent 9f6e66e commit 7d18d4f

13 files changed

Lines changed: 510 additions & 50 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
@@ -99,9 +99,11 @@ describe('organization Search page gates', () => {
9999
expect(mocks.context).not.toHaveBeenCalled()
100100
})
101101

102-
it('keeps the organization entry in organization settings when Search is disabled', async () => {
102+
it('returns the organization entry to workspace settings when Search is disabled', async () => {
103103
mocks.context.mockResolvedValue({ searchAccess: { memberScoped: false } })
104-
await expect(OrganizationPage({ params })).rejects.toThrow('redirect:/o/org-1/settings/members')
104+
await expect(OrganizationPage({ params })).rejects.toThrow(
105+
'redirect:/workspace?redirect=settings'
106+
)
105107
expect(mocks.context).toHaveBeenCalledWith('org-1', 'viewer')
106108
})
107109

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

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ const mockGetSession = authMockFns.mockGetSession
6060
const SURFACE_CONTEXT = {
6161
organization: { id: 'org-1', name: 'Acme', slug: 'acme', logo: null, memberCount: 1 },
6262
viewer: { role: 'member', isAdmin: false },
63+
searchAccess: { memberScoped: true, sourceMirrored: true },
6364
}
6465

6566
describe('OrganizationLayout', () => {
@@ -111,4 +112,23 @@ describe('OrganizationLayout', () => {
111112
expect(html).not.toContain('Secret organization child')
112113
expect(mockWorkspaceChrome).not.toHaveBeenCalled()
113114
})
115+
116+
it.each(['owner', 'admin', 'member'])(
117+
'returns %s viewers outside the rollout to workspace settings before rendering org chrome',
118+
async (role) => {
119+
mockGetOrganizationSurfaceContext.mockResolvedValue({
120+
...SURFACE_CONTEXT,
121+
viewer: { role, isAdmin: role !== 'member' },
122+
searchAccess: { memberScoped: false, sourceMirrored: true },
123+
})
124+
125+
await expect(
126+
OrganizationLayout({
127+
children: <div>Organization settings</div>,
128+
params: Promise.resolve({ organizationId: 'org-1' }),
129+
})
130+
).rejects.toThrow('redirect:/workspace?redirect=settings')
131+
expect(mockWorkspaceChrome).not.toHaveBeenCalled()
132+
}
133+
)
114134
})

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

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { dehydrate, HydrationBoundary } from '@tanstack/react-query'
22
import { cookies } from 'next/headers'
33
import { redirect } from 'next/navigation'
44
import { getSession } from '@/lib/auth'
5-
import { organizationRoutes } from '@/lib/navigation/paths'
5+
import { organizationRoutes, WORKSPACE_SETTINGS_PATH } from '@/lib/navigation/paths'
66
import { getOrganizationSurfaceContext } from '@/lib/organizations/surface'
77
import { prefetchUserProfile } from '@/lib/users/prefetch-user-profile'
88
import { getQueryClient } from '@/app/_shell/providers/get-query-client'
@@ -15,9 +15,9 @@ import { GlobalCommandsProvider } from '@/app/workspace/[workspaceId]/providers/
1515

1616
/**
1717
* The organization surface: the viewer's own view of one organization, outside
18-
* any workspace. Membership in the routed organization is the whole gate — a
19-
* non-member gets an explicit denial rather than a redirect, so a stale link
20-
* never bounces someone into a different organization.
18+
* any workspace. Requires membership and the organization's Search rollout.
19+
* Non-members get an explicit denial; members outside the rollout retain
20+
* workspace settings, including when following a saved organization link.
2121
*/
2222
export default async function OrganizationLayout({
2323
children,
@@ -49,6 +49,7 @@ export default async function OrganizationLayout({
4949
if (!context) {
5050
return <OrganizationAccessDenied />
5151
}
52+
if (!context.searchAccess.memberScoped) redirect(WORKSPACE_SETTINGS_PATH)
5253

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

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/workspace/[workspaceId]/settings/[section]/page.test.tsx

Lines changed: 68 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,22 @@ vi.mock('@/app/workspace/[workspaceId]/settings/navigation', () => ({
4141
resolveSettingsSection: vi.fn((section: string) => {
4242
const aliases: Record<string, string> = { subscription: 'billing' }
4343
const id = aliases[section] ?? section
44-
return ['general', 'billing', 'secrets'].includes(id) ? { id, meta: { title: id } } : null
44+
return [
45+
'general',
46+
'billing',
47+
'secrets',
48+
'organization',
49+
'usage',
50+
'access-control',
51+
'audit-logs',
52+
'sso',
53+
'sessions',
54+
'data-retention',
55+
'data-drains',
56+
'whitelabeling',
57+
].includes(id)
58+
? { id, meta: { title: id } }
59+
: null
4560
}),
4661
}))
4762
vi.mock('@/app/workspace/[workspaceId]/settings/[section]/prefetch', () => ({
@@ -51,6 +66,7 @@ vi.mock('@/app/workspace/[workspaceId]/settings/[section]/settings', () => ({
5166
SettingsPage: vi.fn(() => null),
5267
}))
5368

69+
import { UNIFIED_TO_ORGANIZATION_SECTION } from '@/components/settings/navigation'
5470
import WorkspaceSettingsSectionPage from '@/app/workspace/[workspaceId]/settings/[section]/page'
5571

5672
function pageProps(section: string) {
@@ -79,7 +95,10 @@ describe('WorkspaceSettingsSectionPage', () => {
7995
})
8096

8197
it('preserves legacy organization settings query state on the canonical org destination', async () => {
82-
mockGetHostContext.mockResolvedValue({ hostOrganizationId: 'org-target' })
98+
mockGetHostContext.mockResolvedValue({
99+
hostOrganizationId: 'org-target',
100+
features: { organizationSearch: true },
101+
})
83102
await expect(
84103
WorkspaceSettingsSectionPage({
85104
...pageProps('subscription'),
@@ -91,6 +110,53 @@ describe('WorkspaceSettingsSectionPage', () => {
91110
expect(mockSectionPrefetch).not.toHaveBeenCalled()
92111
})
93112

113+
it.each(Object.entries(UNIFIED_TO_ORGANIZATION_SECTION))(
114+
'keeps %s in the workspace outside the organization rollout',
115+
async (section) => {
116+
mockGetHostContext.mockResolvedValue({
117+
hostOrganizationId: 'org-target',
118+
features: { organizationSearch: false, knowledgeMemberAccess: true },
119+
})
120+
121+
const element = await WorkspaceSettingsSectionPage(pageProps(section))
122+
123+
expect(element).toBeTruthy()
124+
expect(mockRedirect).not.toHaveBeenCalled()
125+
expect(mockAuthorizeSection).toHaveBeenCalledWith({
126+
workspaceId: 'workspace-b',
127+
userId: 'viewer-a',
128+
section,
129+
})
130+
}
131+
)
132+
133+
it.each([undefined, { credentialGroups: true, knowledgeMemberAccess: true }])(
134+
'keeps settings in the workspace when older host context omits the org rollout',
135+
async (features) => {
136+
mockGetHostContext.mockResolvedValue({ hostOrganizationId: 'org-target', features })
137+
138+
await WorkspaceSettingsSectionPage(pageProps('billing'))
139+
140+
expect(mockRedirect).not.toHaveBeenCalled()
141+
expect(mockSectionPrefetch).toHaveBeenCalledTimes(1)
142+
}
143+
)
144+
145+
it.each(Object.entries(UNIFIED_TO_ORGANIZATION_SECTION))(
146+
'routes %s to organization %s only within the organization rollout',
147+
async (section, organizationSection) => {
148+
mockGetHostContext.mockResolvedValue({
149+
hostOrganizationId: 'org-target',
150+
features: { organizationSearch: true, knowledgeMemberAccess: false },
151+
})
152+
153+
await expect(WorkspaceSettingsSectionPage(pageProps(section))).rejects.toThrow(
154+
`NEXT_REDIRECT:/o/org-target/settings/${organizationSection}`
155+
)
156+
expect(mockSectionPrefetch).not.toHaveBeenCalled()
157+
}
158+
)
159+
94160
it('conceals inaccessible workspaces and platform-only sections', async () => {
95161
mockAuthorizeSection.mockResolvedValue({ allowed: false, disposition: 'not-found' })
96162

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]) {

0 commit comments

Comments
 (0)