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']