Skip to content

Commit b0487cb

Browse files
authored
fix(settings): gate workspace sections on the deployment the same way the sidebar does (#7476)
1 parent 2cbf615 commit b0487cb

4 files changed

Lines changed: 86 additions & 24 deletions

File tree

apps/sim/components/settings/navigation.test.ts

Lines changed: 47 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -50,8 +50,18 @@ const SELF_HOSTED: DeploymentShape = {
5050

5151
const HOSTED: DeploymentShape = { ...SELF_HOSTED, hosted: true, billingEnabled: true }
5252

53+
/** A self-hosted deployment with every feature override on. */
54+
const SELF_HOSTED_ALL_FEATURES: DeploymentShape = {
55+
...SELF_HOSTED,
56+
features: { ...SELF_HOSTED.features, customBlocks: true },
57+
}
58+
59+
/** Every workspace-plane section a self-hosted deployment offers; BYOK is Sim Cloud only. */
60+
const SELF_HOSTED_WORKSPACE_SECTIONS = WORKSPACE_SETTINGS_ITEMS.map(({ id }) => id).filter(
61+
(id) => id !== 'byok'
62+
)
63+
5364
const ALL_ENTITLEMENTS = {
54-
byok: true,
5565
credentialGroups: true,
5666
customBlocks: true,
5767
forks: true,
@@ -148,7 +158,7 @@ describe('settings navigation boundaries', () => {
148158
permission: 'admin',
149159
permissionConfig: {},
150160
entitlements: ALL_ENTITLEMENTS,
151-
hosted: false,
161+
deployment: SELF_HOSTED,
152162
}).map(({ id }) => id)
153163
).toContain('sandboxes')
154164
})
@@ -160,16 +170,43 @@ describe('settings navigation boundaries', () => {
160170
* drop it there.
161171
*/
162172
it('shows the Self-host section only on a self-hosted deployment', () => {
163-
const navigate = (hosted: boolean) =>
173+
const navigate = (deployment: DeploymentShape) =>
164174
resolveWorkspaceNavigation({
165175
permission: 'admin',
166176
permissionConfig: {},
167177
entitlements: ALL_ENTITLEMENTS,
168-
hosted,
178+
deployment,
169179
}).map(({ id }) => id)
170180

171-
expect(navigate(false)).toContain('self-host')
172-
expect(navigate(true)).not.toContain('self-host')
181+
expect(navigate(SELF_HOSTED)).toContain('self-host')
182+
expect(navigate(HOSTED)).not.toContain('self-host')
183+
})
184+
185+
/**
186+
* The route gate and the sidebar must agree on deployment-gated sections: a
187+
* hosted-only section is offered on a self-hosted deployment only when its
188+
* feature override resolves on, so a direct link cannot open what the sidebar
189+
* hides. BYOK has no override and stays Sim Cloud only.
190+
*/
191+
it('offers hosted-only workspace sections on self-hosted only through their override', () => {
192+
const navigate = (deployment: DeploymentShape) =>
193+
resolveWorkspaceNavigation({
194+
permission: 'admin',
195+
permissionConfig: {},
196+
entitlements: ALL_ENTITLEMENTS,
197+
deployment,
198+
}).map(({ id }) => id)
199+
200+
const inboxDisabled: DeploymentShape = {
201+
...SELF_HOSTED,
202+
features: { ...SELF_HOSTED.features, inbox: false },
203+
}
204+
expect(navigate(inboxDisabled)).not.toContain('inbox')
205+
expect(navigate(SELF_HOSTED)).toContain('inbox')
206+
expect(navigate({ ...HOSTED, features: inboxDisabled.features })).toContain('inbox')
207+
208+
expect(navigate(SELF_HOSTED)).not.toContain('byok')
209+
expect(navigate(HOSTED)).toContain('byok')
173210
})
174211

175212
/**
@@ -457,7 +494,6 @@ describe('settings navigation boundaries', () => {
457494
visible: [
458495
'teammates',
459496
'secrets',
460-
'byok',
461497
'sandboxes',
462498
'custom-tools',
463499
'mcp',
@@ -475,7 +511,6 @@ describe('settings navigation boundaries', () => {
475511
visible: [
476512
'teammates',
477513
'secrets',
478-
'byok',
479514
'sandboxes',
480515
'custom-tools',
481516
'mcp',
@@ -490,8 +525,8 @@ describe('settings navigation boundaries', () => {
490525
},
491526
{
492527
permission: 'admin' as const,
493-
visible: WORKSPACE_SETTINGS_ITEMS.map(({ id }) => id),
494-
mutable: WORKSPACE_SETTINGS_ITEMS.map(({ id }) => id),
528+
visible: SELF_HOSTED_WORKSPACE_SECTIONS,
529+
mutable: SELF_HOSTED_WORKSPACE_SECTIONS,
495530
},
496531
])(
497532
'makes workspace $permission navigation and mutation chrome explicit',
@@ -500,7 +535,7 @@ describe('settings navigation boundaries', () => {
500535
permission,
501536
permissionConfig: {},
502537
entitlements: ALL_ENTITLEMENTS,
503-
hosted: false,
538+
deployment: SELF_HOSTED_ALL_FEATURES,
504539
})
505540

506541
expect(items.map(({ id }) => id)).toEqual(visible)
@@ -520,12 +555,11 @@ describe('settings navigation boundaries', () => {
520555
hideSandboxesTab: true,
521556
},
522557
entitlements: ALL_ENTITLEMENTS,
523-
hosted: false,
558+
deployment: SELF_HOSTED_ALL_FEATURES,
524559
})
525560

526561
expect(items.map(({ id }) => id)).toEqual([
527562
'teammates',
528-
'byok',
529563
'credential-groups',
530564
'workflow-mcp-servers',
531565
'recently-deleted',

apps/sim/components/settings/navigation.ts

Lines changed: 36 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -992,7 +992,6 @@ export function workspaceSectionUsesPermissionConfig(section: WorkspaceSettingsS
992992
}
993993

994994
export interface WorkspaceSettingsEntitlements {
995-
byok: boolean
996995
credentialGroups: boolean
997996
customBlocks: boolean
998997
forks: boolean
@@ -1017,8 +1016,40 @@ interface ResolveWorkspaceNavigationOptions {
10171016
permission: PermissionType
10181017
permissionConfig: WorkspacePermissionConfig
10191018
entitlements: WorkspaceSettingsEntitlements
1020-
/** Sim Cloud drops the Self hosting section, which the managed service owns there. */
1021-
hosted: boolean
1019+
/** Resolves the catalog's deployment gates the same way the sidebar does. */
1020+
deployment: DeploymentShape
1021+
}
1022+
1023+
/**
1024+
* Unified projection of each workspace-plane section, so the route gate reads the
1025+
* deployment requirements (`requiresHosted`, `requiresSelfHosted`, `selfHostedOverride`)
1026+
* from the same catalog entry the sidebar filters on. Nav and server then agree: a
1027+
* section is reachable exactly when it is listed.
1028+
*/
1029+
const WORKSPACE_UNIFIED_PROJECTIONS: Readonly<
1030+
Partial<Record<WorkspaceSettingsSection, UnifiedSettingsProjection>>
1031+
> = Object.fromEntries(
1032+
SETTINGS_SECTION_REGISTRY.flatMap((entry) => {
1033+
const workspaceSection = entry.planes?.workspace?.id
1034+
return workspaceSection && entry.unified ? [[workspaceSection, entry.unified] as const] : []
1035+
})
1036+
)
1037+
1038+
/**
1039+
* Whether the deployment itself offers a workspace section, before viewer permission
1040+
* and plan entitlement are considered. Mirrors the sidebar's deployment pass.
1041+
*/
1042+
function isWorkspaceSectionOfferedByDeployment(
1043+
section: WorkspaceSettingsSection,
1044+
deployment: DeploymentShape
1045+
): boolean {
1046+
const unified = WORKSPACE_UNIFIED_PROJECTIONS[section]
1047+
if (!unified) return true
1048+
if (unified.requiresSelfHosted && deployment.hosted) return false
1049+
if (unified.requiresHosted && !deployment.hosted) {
1050+
return isSelfHostedOverrideEnabled(unified.selfHostedOverride, deployment)
1051+
}
1052+
return true
10221053
}
10231054

10241055
export interface ResolvedWorkspaceNavigationItem
@@ -1062,9 +1093,10 @@ export function resolveWorkspaceNavigation({
10621093
permission,
10631094
permissionConfig,
10641095
entitlements,
1065-
hosted,
1096+
deployment,
10661097
}: ResolveWorkspaceNavigationOptions): ResolvedWorkspaceNavigationItem[] {
10671098
return WORKSPACE_SETTINGS_ITEMS.flatMap((item) => {
1099+
if (!isWorkspaceSectionOfferedByDeployment(item.id, deployment)) return []
10681100
const permissionConfigKey = WORKSPACE_PERMISSION_CONFIG_KEYS[item.id]
10691101
if (permissionConfigKey && permissionConfig[permissionConfigKey]) return []
10701102
if (item.id === 'forks' && (permission !== 'admin' || !entitlements.forks)) return []
@@ -1074,10 +1106,7 @@ export function resolveWorkspaceNavigation({
10741106
) {
10751107
return []
10761108
}
1077-
if (item.id === 'byok' && !entitlements.byok) return []
10781109
if (item.id === 'custom-blocks' && !entitlements.customBlocks) return []
1079-
// Absent on Sim Cloud, where the managed service owns these settings.
1080-
if (item.id === 'self-host' && hosted) return []
10811110

10821111
const lockedBy = LOCKABLE_WORKSPACE_SECTIONS[item.id]
10831112
const locked = lockedBy !== undefined && !entitlements[lockedBy]

apps/sim/lib/settings/application/workspace-section-access.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -219,8 +219,8 @@ describe('authorizeWorkspaceSettingsSection', () => {
219219
await authorize('secrets')
220220
expect(mocks.resolveWorkspaceNavigation).toHaveBeenCalledWith(
221221
expect.objectContaining({
222-
hosted: true,
223-
entitlements: expect.objectContaining({ byok: true }),
222+
deployment: mocks.deploymentShape,
223+
entitlements: expect.objectContaining({ inbox: true }),
224224
})
225225
)
226226

apps/sim/lib/settings/application/workspace-section-access.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,9 +66,8 @@ async function canOpenWorkspaceSection(
6666
const navigation = resolveWorkspaceNavigation({
6767
permission,
6868
permissionConfig: accessControl?.config ?? {},
69-
hosted: deployment.hosted,
69+
deployment,
7070
entitlements: {
71-
byok: deployment.hosted,
7271
credentialGroups: credentialGroupsAvailable,
7372
inbox: true,
7473
customBlocks: customBlocksAvailable,

0 commit comments

Comments
 (0)