diff --git a/apps/sim/app/o/[organizationId]/settings/[section]/page.test.tsx b/apps/sim/app/o/[organizationId]/settings/[section]/page.test.tsx
index ae47a961773..e9906bbaed4 100644
--- a/apps/sim/app/o/[organizationId]/settings/[section]/page.test.tsx
+++ b/apps/sim/app/o/[organizationId]/settings/[section]/page.test.tsx
@@ -1,4 +1,6 @@
+import { user } from '@sim/db/schema'
import { authMockFns } from '@sim/testing/mocks/auth.mock'
+import { queueTableRows, resetDbChainMock } from '@sim/testing/mocks/database.mock'
import { nextNavigationMock } from '@sim/testing/mocks/next-navigation.mock'
import { beforeEach, describe, expect, it, vi } from 'vitest'
@@ -21,8 +23,9 @@ import OrganizationSettingsSectionPage from '@/app/o/[organizationId]/settings/[
const mockGetSession = authMockFns.mockGetSession
-describe('organization request settings routing', () => {
+describe('organization settings access', () => {
beforeEach(() => {
+ resetDbChainMock()
mockGetSession.mockResolvedValue({ user: { id: 'viewer' } })
mocks.authorize.mockResolvedValue(true)
})
@@ -36,4 +39,18 @@ describe('organization request settings routing', () => {
})
).rejects.toThrow('NEXT_NOT_FOUND')
})
+
+ it.each(['admin', 'mothership'])(
+ 'conceals %s from organization admins without platform authority',
+ async (section) => {
+ const props = {
+ params: Promise.resolve({ organizationId: 'organization', section }),
+ }
+ queueTableRows(user, [{ role: 'admin' }])
+ await OrganizationSettingsSectionPage(props)
+
+ queueTableRows(user, [{ role: 'user' }])
+ await expect(OrganizationSettingsSectionPage(props)).rejects.toThrow('NEXT_NOT_FOUND')
+ }
+ )
})
diff --git a/apps/sim/app/o/[organizationId]/settings/[section]/page.tsx b/apps/sim/app/o/[organizationId]/settings/[section]/page.tsx
index 54211800776..06fc89bc220 100644
--- a/apps/sim/app/o/[organizationId]/settings/[section]/page.tsx
+++ b/apps/sim/app/o/[organizationId]/settings/[section]/page.tsx
@@ -11,6 +11,7 @@ import { prefetchStandaloneGeneral } from '@/components/settings/prefetch-standa
import { SettingsSectionProvider } from '@/components/settings/settings-panel'
import { getSession } from '@/lib/auth'
import { organizationRoutes } from '@/lib/navigation/paths'
+import { isPlatformAdmin } from '@/lib/permissions/super-user'
import { authorizeOrganizationSettingsSection } from '@/lib/settings/application/organization-section-access'
import { getQueryClient } from '@/app/_shell/providers/get-query-client'
import { buildAuthCrossLink } from '@/app/(auth)/auth-redirect'
@@ -39,8 +40,8 @@ export async function generateMetadata({
/**
* One settings surface for the organization view. An organization section runs
- * through the organization gate; an account section needs only the sign-in the
- * layout already established, and renders through the account plane's renderer.
+ * through the organization gate; account sections use the account renderer,
+ * with platform administration restricted to platform admins.
*/
export default async function OrganizationSettingsSectionPage({
params,
@@ -81,6 +82,10 @@ export default async function OrganizationSettingsSectionPage({
return
}
+ if (resolved.section === 'admin' || resolved.section === 'mothership') {
+ if (!(await isPlatformAdmin(session.user.id))) notFound()
+ }
+
/**
* No Suspense boundary: a fallback mounted with the section puts React's 300ms reveal floor
* under every switch (see the workspace section layout).
diff --git a/apps/sim/app/o/[organizationId]/settings/navigation.ts b/apps/sim/app/o/[organizationId]/settings/navigation.ts
index 6a0e2ec6ad7..02f09bc926a 100644
--- a/apps/sim/app/o/[organizationId]/settings/navigation.ts
+++ b/apps/sim/app/o/[organizationId]/settings/navigation.ts
@@ -27,11 +27,11 @@ export type OrganizationSurfaceSettingsSection =
* The account sections the organization surface hosts, rendered by the account
* plane's own renderer. General is the one that belongs to the person alone: the
* personal Subscription gives way to the organization's, which sits beside
- * General in the Account group, and the Desktop, Browser, and Terminal sections
- * are bound to the workspace they are opened from.
+ * General in the Account group. Platform administration keeps the account
+ * plane's access rules; Desktop, Browser, and Terminal are workspace-bound.
*/
export const ORGANIZATION_SURFACE_ACCOUNT_ITEMS: SettingsNavigationItem[] =
- ACCOUNT_SETTINGS_ITEMS.filter((item) => item.id === 'general')
+ ACCOUNT_SETTINGS_ITEMS.filter((item) => item.id === 'general' || item.group === 'platform')
export function resolveOrganizationSettingsSection(
path: string
@@ -108,10 +108,13 @@ export function organizationSettingsNavigation(
export function organizationSurfaceSettingsNavigation(
isAdmin: boolean,
features: OrganizationSettingsFeatures,
- availability: { connectedAccounts: boolean; search: boolean; knowledgeGraphs?: boolean }
+ availability: { connectedAccounts: boolean; search: boolean; knowledgeGraphs?: boolean },
+ isPlatformAdmin: boolean
): SettingsNavigationItem[] {
return [
- ...ORGANIZATION_SURFACE_ACCOUNT_ITEMS,
+ ...ORGANIZATION_SURFACE_ACCOUNT_ITEMS.filter(
+ (item) => item.group !== 'platform' || isPlatformAdmin
+ ),
...organizationSettingsNavigation(isAdmin, features, availability),
]
}
diff --git a/apps/sim/app/o/[organizationId]/settings/organization-settings-sidebar.tsx b/apps/sim/app/o/[organizationId]/settings/organization-settings-sidebar.tsx
index f8ed767eee6..c1fa39d7b1f 100644
--- a/apps/sim/app/o/[organizationId]/settings/organization-settings-sidebar.tsx
+++ b/apps/sim/app/o/[organizationId]/settings/organization-settings-sidebar.tsx
@@ -5,6 +5,7 @@ import { usePathname } from 'next/navigation'
import { ORGANIZATION_SETTINGS_GROUPS } from '@/components/settings/navigation'
import { SettingsSidebar } from '@/components/settings/settings-sidebar'
import { isApiClientError } from '@/lib/api/client/errors'
+import { useSession } from '@/lib/auth/auth-client'
import { isEnterprise } from '@/lib/billing/plan-helpers'
import {
hasPaidSubscriptionStatus,
@@ -30,6 +31,7 @@ export function OrganizationSettingsSidebar(props: OrganizationSettingsSidebarPr
useOrganizationContext()
const knowledgeGraphs = useFeatureFlag('mothership-memory-spaces')
const pathname = usePathname()
+ const { data: session } = useSession()
const queryClient = useQueryClient()
const refreshPlan = viewer.isAdmin && settingsFeatures.hosted && settingsFeatures.billingEnabled
const { data: summary, error } = useOrganizationBillingSummary(organization.id, {
@@ -67,11 +69,16 @@ export function OrganizationSettingsSidebar(props: OrganizationSettingsSidebarPr
plane='organization'
activeSection={resolveOrganizationSurfaceSection(pathname ?? '')?.section ?? 'general'}
groups={ORGANIZATION_SETTINGS_GROUPS}
- items={organizationSurfaceSettingsNavigation(isAdmin, features, {
- connectedAccounts: connectedAccountsAvailable,
- search: searchAccess.memberScoped,
- knowledgeGraphs,
- })}
+ items={organizationSurfaceSettingsNavigation(
+ isAdmin,
+ features,
+ {
+ connectedAccounts: connectedAccountsAvailable,
+ search: searchAccess.memberScoped,
+ knowledgeGraphs,
+ },
+ session?.user?.role === 'admin'
+ )}
hrefForSection={(section) => routes.settingsSection(section)}
onSectionIntent={(section) =>
warmOrganizationSettingsSectionQuery(
diff --git a/apps/sim/components/settings/navigation.ts b/apps/sim/components/settings/navigation.ts
index b432c85def9..0c51706e1e2 100644
--- a/apps/sim/components/settings/navigation.ts
+++ b/apps/sim/components/settings/navigation.ts
@@ -887,6 +887,7 @@ export const ORGANIZATION_SETTINGS_GROUPS = [
{ key: 'organization', title: 'Organization' },
{ key: 'governance', title: 'Governance' },
{ key: 'sim-search', title: 'Sim Search' },
+ { key: 'platform', title: 'Platform' },
] as const
type OrganizationSettingsGroup = (typeof ORGANIZATION_SETTINGS_GROUPS)[number]['key']