diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index 48a9dc1bd..e028cefae 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -223,6 +223,11 @@ other header-adjacent notices must not fork assistant open state, gate on AI runtime fetches, or move assistant availability logic out of `frontend-modern/src/stores/aiChat.ts` and `frontend-modern/src/useAppRuntimeState.ts` just because they share the same authenticated shell. +That same shared shell rule applies when presentation policy suppresses hosted +organization chrome: `frontend-modern/src/App.tsx` and +`frontend-modern/src/AppLayout.tsx` may hide org switchers or demo-only org +labels, but they must not couple assistant visibility, session reset, or +drawer-open behavior to that organization presentation state. `docs/release-control/v6/internal/subsystems/registry.json` must therefore keep `frontend-modern/src/stores/aiRuntimeState.ts` and `frontend-modern/src/components/AI/Chat/` on the explicit AI runtime proof diff --git a/docs/release-control/v6/internal/subsystems/cloud-paid.md b/docs/release-control/v6/internal/subsystems/cloud-paid.md index 7b866e470..0c58bc386 100644 --- a/docs/release-control/v6/internal/subsystems/cloud-paid.md +++ b/docs/release-control/v6/internal/subsystems/cloud-paid.md @@ -184,6 +184,13 @@ That same cloud-paid/browser boundary now also governs public demo posture. surfaces must not reveal self-hosted license metadata, hosted billing state, monitored-system ledgers, upgrade nudges, or activation controls just because the underlying runtime is commercially enabled. +That same resolved presentation policy also governs hosted organization chrome +inside the demo/browser shell: app bootstrap may retain an internal default +org context for hosted API routing, but `frontend-modern/src/useAppRuntimeState.ts`, +`frontend-modern/src/App.tsx`, and `frontend-modern/src/AppLayout.tsx` must +not render visible org switchers, `Default Organization` labels, or +organization-scoped navigation just because a seeded hosted org still exists +behind the session. `internal/api/router_routes_auth_security.go`, `internal/api/security_status_capabilities.go`, `frontend-modern/src/useAppRuntimeState.ts`, and diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 4a84510b6..b55070faa 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -485,6 +485,16 @@ payload, but the browser-owned shared primitive is now the resolved monitored-system warning banners, dashboard upsells, Patrol upgrade CTAs, history-lock paywalls, and other public-demo commercial affordances when the browser is rendering a public demo runtime. +That same shared settings-shell boundary also owns demo-mode organization +suppression. `frontend-modern/src/components/Settings/settingsNavigationModel.ts`, +`frontend-modern/src/components/Settings/settingsNavCatalog.ts`, +`frontend-modern/src/components/Settings/settingsNavVisibility.ts`, +`frontend-modern/src/stores/sessionPresentationPolicy.ts`, and +`frontend-modern/src/useAppRuntimeState.ts` must fail closed on organization +navigation and app-shell org chrome until the resolved presentation policy is +known, then keep org switchers, visible `Default Organization` labels, and +organization-scoped settings groups hidden when the browser is rendering a +public demo runtime. Shared primitives must not perform their own ad hoc `/api/health` polling, response-header inference, hostname heuristics, or per-banner demo branching; the runtime bootstrap, shared presentation-policy store, and shared banner diff --git a/docs/release-control/v6/internal/subsystems/organization-settings.md b/docs/release-control/v6/internal/subsystems/organization-settings.md index 1fa21864c..3b1e8c264 100644 --- a/docs/release-control/v6/internal/subsystems/organization-settings.md +++ b/docs/release-control/v6/internal/subsystems/organization-settings.md @@ -140,6 +140,13 @@ upgrade posture stay outside this subsystem on the dedicated commercial surfaces, and any public-demo suppression must come from the shared resolved `presentationPolicy` rather than settings-local demo checks or billing entitlement probes. +That resolved policy also governs organization settings discoverability and +bootstrap. `frontend-modern/src/components/Settings/useSettingsAccess.ts`, +`frontend-modern/src/components/Settings/settingsNavCatalog.ts`, and the +owned organization overview/access/sharing panel states must fail closed until +presentation policy resolves, then stay hidden in public-demo posture even if +the hosted runtime still carries a seeded default organization for transport +scope. The organization sharing surface now also sources resource quick-pick labels from the shared preferred resource display helper, so governed resources do not fall back to raw names inside share creation. diff --git a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md index 0ec44d9cd..767246b84 100644 --- a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md +++ b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md @@ -199,6 +199,12 @@ regression protection. callback behavior, or reuse those public auth endpoints as a justification for relaxing the protected history payload budgets that belong elsewhere. 30. Keep dashboard summary-chart fetches scope-owned rather than page-churn-owned: `frontend-modern/src/hooks/useDashboardTrends.ts` must hydrate infrastructure and storage summaries once per org/range scope from the canonical summary caches and recompute card presentation locally as the compact dashboard overview changes, rather than refetching the infrastructure-summary transport in `frontend-modern/src/components/Infrastructure/useInfrastructureSummaryState.ts`, the dashboard storage-summary trend transport in `frontend-modern/src/utils/storageSummaryTrendCache.ts`, or the storage-page summary transport in `frontend-modern/src/utils/storageSummaryCache.ts` for every top-resource or card reshuffle on the same dashboard load. That dashboard infrastructure path must also request only the metrics it renders through the canonical infrastructure-summary route owned by `internal/api/router_routes_monitoring.go` and `internal/api/router.go`; the dashboard may not pay for disk or network summary series when it only renders CPU and memory. App-shell prewarm in `frontend-modern/src/useAppRuntimeState.ts` must not front-run that dashboard-specific route while the operator is already on the root dashboard route owned by `frontend-modern/src/App.tsx`. + The same app-shell bootstrap boundary now also governs demo-hidden + organization context. When presentation policy suppresses organization + chrome, `frontend-modern/src/useAppRuntimeState.ts` may retain a hidden + default org scope for route-safe API calls, but it must skip browser org + list hydration and must not turn dashboard landing on `frontend-modern/src/App.tsx` + into another summary-fetch or org-bootstrap hot path. 31. Keep the dashboard overview hot path compact and route-owned. `frontend-modern/src/pages/Dashboard.tsx`, `frontend-modern/src/api/resources.ts`, and `frontend-modern/src/hooks/useDashboardOverview.ts` must hydrate KPI cards, problem-resource rows, and top-infrastructure identities through the compact dashboard-summary API contract owned by the adjacent `api-contracts` and `unified-resources` surfaces, rather than booting the full unfiltered paginated unified-resource list just to derive summary cards. 32. Keep infrastructure summary consumers on the compact dashboard overview rather than reopening the all-resources hook. `frontend-modern/src/hooks/useDashboardTrends.ts`, `frontend-modern/src/components/Infrastructure/useInfrastructureSummaryState.ts`, and adjacent dashboard summary consumers may derive chart identity and storage presence from the overview payload they were already given, but they must not call `useResources()` or mount a second unfiltered unified-resource fetch path inside the dashboard hot path. That rule also applies to globally mounted helpers such as `frontend-modern/src/components/AI/Chat/index.tsx`: closed assistant surfaces must read the live websocket snapshot or existing unified-resource cache rather than forcing the dashboard to pay for `all-resources` just because the shell component is mounted. 33. Keep hidden workload-route selector shells off the hot path. When the diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index 406460a16..6c20eb869 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -207,6 +207,10 @@ querying, and the operator-facing storage health presentation layer. commercial compatibility handoffs like `/pricing` must stay separate thin route exits rather than borrowing storage/recovery preview framing, first-session copy, or page-state assumptions. + Authenticated-shell demo organization suppression on `frontend-modern/src/App.tsx` + may hide top-bar org chrome for public demo posture, but it must not leak + into storage/recovery preview route ownership, first-session recovery copy, + or route-level framing decisions. 36. Keep public self-hosted purchase handoff and activation routes on the adjacent commercial/auth boundary. When `internal/api/router.go`, `internal/api/router_routes_cloud.go`, `internal/api/licensing_handlers.go`, diff --git a/frontend-modern/src/App.tsx b/frontend-modern/src/App.tsx index 8cb516e97..dcc671224 100644 --- a/frontend-modern/src/App.tsx +++ b/frontend-modern/src/App.tsx @@ -375,6 +375,7 @@ function App() { organizations={runtime.organizations} activeOrgID={runtime.activeOrgID} orgsLoading={runtime.orgsLoading} + showOrgSwitcher={runtime.showOrgSwitcher} onSwitchOrg={runtime.handleOrgSwitch} > {props.children} diff --git a/frontend-modern/src/AppLayout.tsx b/frontend-modern/src/AppLayout.tsx index 57902d742..d619c1ce6 100644 --- a/frontend-modern/src/AppLayout.tsx +++ b/frontend-modern/src/AppLayout.tsx @@ -39,7 +39,6 @@ import { buildStorageRecoveryTabSpecs } from '@/routing/platformTabs'; import { getKioskModePreference, setKioskMode } from '@/utils/url'; import { updateStore } from '@/stores/updates'; import { aiChatStore } from '@/stores/aiChat'; -import { isMultiTenantEnabled } from '@/stores/license'; import { isPro } from '@/stores/licenseCommercial'; import { presentationPolicyHidesUpgradePrompts } from '@/stores/sessionPresentationPolicy'; import type { AppConnectionStatus } from '@/useAppRuntimeState'; @@ -85,6 +84,7 @@ export interface AppLayoutProps { organizations: () => Organization[]; activeOrgID: () => string; orgsLoading: () => boolean; + showOrgSwitcher: () => boolean; onSwitchOrg: (orgID: string) => void; children?: JSX.Element; } @@ -552,7 +552,7 @@ export function AppLayout(props: AppLayoutProps) { >
- + { expect(appSource).not.toContain('AIAPI.getSettings()'); expect(appSource).toContain("if (e.key === 'Escape' && aiChatStore.isOpen) {"); expect(appSource).toContain(' aiChatStore.close()} />'); + expect(appSource).toContain('showOrgSwitcher={runtime.showOrgSwitcher}'); }); it('keeps authenticated chrome in AppLayout and hosted bootstrap in useAppRuntimeState', () => { @@ -66,12 +67,13 @@ describe('App architecture', () => { ); expect(appLayoutSource).not.toContain('props.connected()'); expect(appLayoutSource).toContain('const utilityTabs = createMemo(() =>'); - expect(appLayoutSource).toContain("import { isMultiTenantEnabled } from '@/stores/license';"); + expect(appLayoutSource).not.toContain("import { isMultiTenantEnabled } from '@/stores/license';"); expect(appLayoutSource).not.toContain('loadCommercialPosture'); expect(appLayoutSource).not.toContain('buildReleaseNotesUrl'); expect(appLayoutSource).not.toContain('buildV6RcFeedbackUrl'); expect(appLayoutSource).not.toContain('sessionPresentationPolicyResolved'); expect(appLayoutSource).not.toContain('presentationPolicyHidesCommercialSurfaces'); + expect(appLayoutSource).not.toContain('presentationPolicyHidesOrganizationSurfaces'); expect(appLayoutSource).toContain( 'aiChatStore.enabled === true && !aiChatStore.isOpenSignal() && !kioskMode()', ); @@ -84,6 +86,7 @@ describe('App architecture', () => { expect(appRuntimeStateSource).toContain('export const useAppRuntimeState = () =>'); expect(appRuntimeStateSource).toContain("import { aiChatStore } from '@/stores/aiChat';"); expect(appRuntimeStateSource).toContain('const connectionStatus = createMemo(() => {'); + expect(appRuntimeStateSource).toContain('const showOrgSwitcher = createMemo(() => {'); expect(appRuntimeStateSource).toContain('const beginAuthenticatedRuntime = async () =>'); expect(appRuntimeStateSource).toContain("const [backendHealthy, setBackendHealthy] = createSignal(false);"); expect(appRuntimeStateSource).toContain("const checkBackendHealth = async () => {"); @@ -95,6 +98,7 @@ describe('App architecture', () => { expect(appRuntimeStateSource).toContain( "import { loadCommercialPosture } from '@/stores/licenseCommercial';", ); + expect(appRuntimeStateSource).toContain('presentationPolicyHidesOrganizationSurfaces'); expect(appRuntimeStateSource).toContain( 'const [activeOrgID, setActiveOrgID] = createSignal(', ); diff --git a/frontend-modern/src/__tests__/useAppRuntimeState.test.ts b/frontend-modern/src/__tests__/useAppRuntimeState.test.ts index 8531263dd..b8f6d4a13 100644 --- a/frontend-modern/src/__tests__/useAppRuntimeState.test.ts +++ b/frontend-modern/src/__tests__/useAppRuntimeState.test.ts @@ -255,6 +255,7 @@ describe('useAppRuntimeState', () => { { id: 'default', displayName: 'Default Organization' }, ]); expect(hookState.activeOrgID()).toBe('default'); + expect(hookState.showOrgSwitcher()).toBe(false); expect(showToastMock).not.toHaveBeenCalledWith( 'error', 'Failed to load organizations. Using default.', @@ -275,11 +276,13 @@ describe('useAppRuntimeState', () => { expect(setOrgIDMock).toHaveBeenCalledWith('acme'); expect(hookState.organizations()).toEqual([{ id: 'acme', displayName: 'Acme' }]); expect(hookState.activeOrgID()).toBe('acme'); + expect(hookState.showOrgSwitcher()).toBe(true); dispose(); }); it('syncs demo mode from security status session capabilities during bootstrap', async () => { + isMultiTenantEnabledMock.mockReturnValue(true); apiFetchMock.mockImplementation(async (url: string) => { if (url === '/api/security/status') { return new Response( @@ -297,7 +300,7 @@ describe('useAppRuntimeState', () => { }); const demoModeModule = await import('@/stores/demoMode'); - const { dispose } = mountHook(); + const { hookState, dispose } = mountHook(); await flushAsync(); await flushAsync(); @@ -305,6 +308,11 @@ describe('useAppRuntimeState', () => { expect(demoModeModule.demoModeResolved()).toBe(true); expect(demoModeModule.demoModeEnabled()).toBe(true); expect(aiChatSetEnabledMock).toHaveBeenCalledWith(true); + expect(orgsListMock).not.toHaveBeenCalled(); + expect(hookState.organizations()).toEqual([ + { id: 'default', displayName: 'Default Organization' }, + ]); + expect(hookState.showOrgSwitcher()).toBe(false); dispose(); }); diff --git a/frontend-modern/src/components/Settings/OrganizationAccessPanel.tsx b/frontend-modern/src/components/Settings/OrganizationAccessPanel.tsx index 5dc5ec2d1..75547616a 100644 --- a/frontend-modern/src/components/Settings/OrganizationAccessPanel.tsx +++ b/frontend-modern/src/components/Settings/OrganizationAccessPanel.tsx @@ -1,6 +1,7 @@ import { Component, Show } from 'solid-js'; import SettingsPanel from '@/components/shared/SettingsPanel'; import { isMultiTenantEnabled } from '@/stores/license'; +import { presentationPolicyHidesOrganizationSurfaces } from '@/stores/sessionPresentationPolicy'; import { ORGANIZATION_SETTINGS_UNAVAILABLE_CLASS, ORGANIZATION_SETTINGS_UNAVAILABLE_MESSAGE, @@ -17,10 +18,12 @@ export interface OrganizationAccessPanelProps { export const OrganizationAccessPanel: Component = (props) => { const state = useOrganizationAccessPanelState(props); + const showOrganizationSurface = () => + isMultiTenantEnabled() && !presentationPolicyHidesOrganizationSurfaces(); return ( {ORGANIZATION_SETTINGS_UNAVAILABLE_MESSAGE} diff --git a/frontend-modern/src/components/Settings/OrganizationOverviewPanel.tsx b/frontend-modern/src/components/Settings/OrganizationOverviewPanel.tsx index a7352b6cf..c4442add7 100644 --- a/frontend-modern/src/components/Settings/OrganizationOverviewPanel.tsx +++ b/frontend-modern/src/components/Settings/OrganizationOverviewPanel.tsx @@ -1,6 +1,7 @@ import { Component, Show } from 'solid-js'; import SettingsPanel from '@/components/shared/SettingsPanel'; import { isMultiTenantEnabled } from '@/stores/license'; +import { presentationPolicyHidesOrganizationSurfaces } from '@/stores/sessionPresentationPolicy'; import { ORGANIZATION_SETTINGS_UNAVAILABLE_CLASS, ORGANIZATION_SETTINGS_UNAVAILABLE_MESSAGE, @@ -17,10 +18,12 @@ export interface OrganizationOverviewPanelProps { export const OrganizationOverviewPanel: Component = (props) => { const state = useOrganizationOverviewPanelState(props); + const showOrganizationSurface = () => + isMultiTenantEnabled() && !presentationPolicyHidesOrganizationSurfaces(); return ( {ORGANIZATION_SETTINGS_UNAVAILABLE_MESSAGE} diff --git a/frontend-modern/src/components/Settings/OrganizationSharingPanel.tsx b/frontend-modern/src/components/Settings/OrganizationSharingPanel.tsx index 019822072..bb3332308 100644 --- a/frontend-modern/src/components/Settings/OrganizationSharingPanel.tsx +++ b/frontend-modern/src/components/Settings/OrganizationSharingPanel.tsx @@ -1,6 +1,7 @@ import { Component, Show } from 'solid-js'; import SettingsPanel from '@/components/shared/SettingsPanel'; import { isMultiTenantEnabled } from '@/stores/license'; +import { presentationPolicyHidesOrganizationSurfaces } from '@/stores/sessionPresentationPolicy'; import { ORGANIZATION_SETTINGS_UNAVAILABLE_CLASS, ORGANIZATION_SETTINGS_UNAVAILABLE_MESSAGE, @@ -18,10 +19,12 @@ export interface OrganizationSharingPanelProps { export const OrganizationSharingPanel: Component = (props) => { const state = useOrganizationSharingPanelState(props); + const showOrganizationSurface = () => + isMultiTenantEnabled() && !presentationPolicyHidesOrganizationSurfaces(); return ( {ORGANIZATION_SETTINGS_UNAVAILABLE_MESSAGE} diff --git a/frontend-modern/src/components/Settings/__tests__/BillingAdminPanel.test.tsx b/frontend-modern/src/components/Settings/__tests__/BillingAdminPanel.test.tsx index f016bfa9f..69bf8f726 100644 --- a/frontend-modern/src/components/Settings/__tests__/BillingAdminPanel.test.tsx +++ b/frontend-modern/src/components/Settings/__tests__/BillingAdminPanel.test.tsx @@ -11,6 +11,7 @@ const getBillingStateMock = vi.fn(); const putBillingStateMock = vi.fn(); const successMock = vi.fn(); const errorMock = vi.fn(); +const presentationPolicyHidesOrganizationSurfacesMock = vi.fn(); vi.mock('@/api/billingAdmin', () => ({ BillingAdminAPI: { @@ -32,6 +33,11 @@ vi.mock('@/stores/notifications', () => ({ }, })); +vi.mock('@/stores/sessionPresentationPolicy', () => ({ + presentationPolicyHidesOrganizationSurfaces: (...args: unknown[]) => + presentationPolicyHidesOrganizationSurfacesMock(...args), +})); + vi.mock('@/utils/logger', () => ({ logger: { error: vi.fn(), @@ -45,6 +51,8 @@ describe('BillingAdminPanel', () => { putBillingStateMock.mockReset(); successMock.mockReset(); errorMock.mockReset(); + presentationPolicyHidesOrganizationSurfacesMock.mockReset(); + presentationPolicyHidesOrganizationSurfacesMock.mockReturnValue(false); listOrganizationsMock.mockResolvedValue([ { @@ -130,4 +138,16 @@ describe('BillingAdminPanel', () => { expect(billingAdminOrganizationsTableSource).toContain('PulseDataGrid'); expect(billingAdminOrganizationsTableSource).toContain('Billing state JSON'); }); + + it('stays unavailable in demo mode without loading hosted billing admin data', () => { + presentationPolicyHidesOrganizationSurfacesMock.mockReturnValue(true); + + render(() => ); + + expect( + screen.getByText('Organization settings are not available on this server.'), + ).toBeInTheDocument(); + expect(listOrganizationsMock).not.toHaveBeenCalled(); + expect(getBillingStateMock).not.toHaveBeenCalled(); + }); }); diff --git a/frontend-modern/src/components/Settings/__tests__/OrganizationBillingPanel.test.tsx b/frontend-modern/src/components/Settings/__tests__/OrganizationBillingPanel.test.tsx index 987af1ab1..8c1279a95 100644 --- a/frontend-modern/src/components/Settings/__tests__/OrganizationBillingPanel.test.tsx +++ b/frontend-modern/src/components/Settings/__tests__/OrganizationBillingPanel.test.tsx @@ -12,6 +12,7 @@ const listMembersMock = vi.hoisted(() => vi.fn()); const errorMock = vi.hoisted(() => vi.fn()); const eventBusOnMock = vi.hoisted(() => vi.fn()); const eventBusHandlers = vi.hoisted(() => [] as Array<() => void>); +const presentationPolicyHidesOrganizationSurfacesMock = vi.hoisted(() => vi.fn()); vi.mock('@/api/license', () => ({ LicenseAPI: { @@ -46,6 +47,10 @@ vi.mock('@/stores/notifications', () => ({ }, })); +vi.mock('@/stores/sessionPresentationPolicy', () => ({ + presentationPolicyHidesOrganizationSurfaces: presentationPolicyHidesOrganizationSurfacesMock, +})); + vi.mock('@/utils/logger', () => ({ logger: { error: vi.fn(), @@ -59,11 +64,13 @@ describe('OrganizationBillingPanel', () => { listMembersMock.mockReset(); errorMock.mockReset(); eventBusOnMock.mockReset(); + presentationPolicyHidesOrganizationSurfacesMock.mockReset(); eventBusHandlers.length = 0; eventBusOnMock.mockImplementation((_event: string, handler: () => void) => { eventBusHandlers.push(handler); return () => {}; }); + presentationPolicyHidesOrganizationSurfacesMock.mockReturnValue(false); getStatusMock.mockResolvedValue({ valid: true, @@ -127,4 +134,17 @@ describe('OrganizationBillingPanel', () => { expect(organizationBillingStateSource).not.toContain("getOrgID() || 'default'"); expect(organizationBillingLoadingStateSource).toContain('animate-pulse'); }); + + it('stays unavailable in demo mode without loading organization billing data', () => { + presentationPolicyHidesOrganizationSurfacesMock.mockReturnValue(true); + + render(() => ); + + expect( + screen.getByText('Organization settings are not available on this server.'), + ).toBeInTheDocument(); + expect(getStatusMock).not.toHaveBeenCalled(); + expect(listOrgsMock).not.toHaveBeenCalled(); + expect(listMembersMock).not.toHaveBeenCalled(); + }); }); diff --git a/frontend-modern/src/components/Settings/__tests__/OrganizationOverviewPanel.test.tsx b/frontend-modern/src/components/Settings/__tests__/OrganizationOverviewPanel.test.tsx index b748e63eb..edd9475a3 100644 --- a/frontend-modern/src/components/Settings/__tests__/OrganizationOverviewPanel.test.tsx +++ b/frontend-modern/src/components/Settings/__tests__/OrganizationOverviewPanel.test.tsx @@ -7,6 +7,7 @@ const orgGetMock = vi.fn(); const listMembersMock = vi.fn(); const updateOrgMock = vi.fn(); const isMultiTenantEnabledMock = vi.fn(); +const presentationPolicyHidesOrganizationSurfacesMock = vi.fn(); const getOrgIDMock = vi.fn(); const notificationSuccessMock = vi.fn(); const notificationErrorMock = vi.fn(); @@ -25,6 +26,11 @@ vi.mock('@/stores/license', () => ({ isMultiTenantEnabled: (...args: unknown[]) => isMultiTenantEnabledMock(...args), })); +vi.mock('@/stores/sessionPresentationPolicy', () => ({ + presentationPolicyHidesOrganizationSurfaces: (...args: unknown[]) => + presentationPolicyHidesOrganizationSurfacesMock(...args), +})); + vi.mock('@/utils/apiClient', () => ({ getOrgID: (...args: unknown[]) => getOrgIDMock(...args), })); @@ -85,6 +91,7 @@ beforeEach(() => { listMembersMock.mockReset(); updateOrgMock.mockReset(); isMultiTenantEnabledMock.mockReset(); + presentationPolicyHidesOrganizationSurfacesMock.mockReset(); getOrgIDMock.mockReset(); notificationSuccessMock.mockReset(); notificationErrorMock.mockReset(); @@ -92,6 +99,7 @@ beforeEach(() => { loggerErrorMock.mockReset(); isMultiTenantEnabledMock.mockReturnValue(true); + presentationPolicyHidesOrganizationSurfacesMock.mockReturnValue(false); getOrgIDMock.mockReturnValue('org-a'); eventBusOnMock.mockReturnValue(() => undefined); orgGetMock.mockResolvedValue(baseOrg); @@ -124,4 +132,14 @@ describe('OrganizationOverviewPanel', () => { expect(organizationOverviewStateSource).toContain('normalizeOrgScope(getOrgID())'); expect(organizationOverviewStateSource).not.toContain("getOrgID() || 'default'"); }); + + it('stays unavailable in demo mode without loading organization data', () => { + presentationPolicyHidesOrganizationSurfacesMock.mockReturnValue(true); + + renderPanel(); + + expect(screen.getByText('Organization settings are not available on this server.')).toBeInTheDocument(); + expect(orgGetMock).not.toHaveBeenCalled(); + expect(listMembersMock).not.toHaveBeenCalled(); + }); }); diff --git a/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts b/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts index 10372c96d..dd6e324ef 100644 --- a/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts +++ b/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts @@ -1464,6 +1464,19 @@ describe('Settings architecture guardrails', () => { expect(getSettingsNavItem('organization-billing-admin')?.hideWhenCommercialHidden).toBe(true); }); + it('keeps demo-mode organization visibility on the shared settings shell owner', () => { + expect(settingsNavigationModelSource).toContain('hideWhenOrganizationHidden?: boolean;'); + expect(settingsNavVisibilitySource).toContain('if (item.hideWhenOrganizationHidden)'); + expect(settingsNavVisibilitySource).toContain('context.presentationPolicyHidesOrganizations'); + expect(getSettingsNavItem('organization-overview')?.hideWhenOrganizationHidden).toBe(true); + expect(getSettingsNavItem('organization-access')?.hideWhenOrganizationHidden).toBe(true); + expect(getSettingsNavItem('organization-sharing')?.hideWhenOrganizationHidden).toBe(true); + expect(getSettingsNavItem('organization-billing')?.hideWhenOrganizationHidden).toBe(true); + expect(getSettingsNavItem('organization-billing-admin')?.hideWhenOrganizationHidden).toBe( + true, + ); + }); + it('keeps relay shell copy on the shared relay presentation owner', () => { expect(settingsHeaderMetaSource).toContain('RELAY_SETTINGS_DESCRIPTION'); expect(relaySettingsPanelSource).toContain('description={RELAY_SETTINGS_DESCRIPTION}'); diff --git a/frontend-modern/src/components/Settings/__tests__/settingsNavigation.integration.test.tsx b/frontend-modern/src/components/Settings/__tests__/settingsNavigation.integration.test.tsx index b68d5a96b..603340582 100644 --- a/frontend-modern/src/components/Settings/__tests__/settingsNavigation.integration.test.tsx +++ b/frontend-modern/src/components/Settings/__tests__/settingsNavigation.integration.test.tsx @@ -116,6 +116,28 @@ describe('settingsNavigation integration scaffold', () => { ).toBe(true); }); + it('hides organization tabs in demo mode even when multi-tenant is enabled', () => { + expect( + shouldHideSettingsNavItem('organization-overview', { + hasFeature: hasFeatures(['multi_tenant']), + runtimeCapabilitiesLoaded: () => true, + presentationPolicyHidesOrganizations: true, + presentationPolicyResolved: true, + hostedModeEnabled: false, + }), + ).toBe(true); + + expect( + shouldHideSettingsNavItem('organization-sharing', { + hasFeature: hasFeatures(['multi_tenant']), + runtimeCapabilitiesLoaded: () => true, + presentationPolicyHidesOrganizations: true, + presentationPolicyResolved: true, + hostedModeEnabled: false, + }), + ).toBe(true); + }); + it('fails closed for demo-hidden tabs until demo mode is resolved', () => { expect( shouldHideSettingsNavItem('system-billing', { @@ -134,6 +156,15 @@ describe('settingsNavigation integration scaffold', () => { hostedModeEnabled: true, }), ).toBe(true); + + expect( + shouldHideSettingsNavItem('organization-overview', { + hasFeature: hasFeatures(['multi_tenant']), + runtimeCapabilitiesLoaded: () => true, + presentationPolicyResolved: false, + hostedModeEnabled: false, + }), + ).toBe(true); }); it('hides tabs when the backend denies the required capability', () => { diff --git a/frontend-modern/src/components/Settings/settingsNavCatalog.ts b/frontend-modern/src/components/Settings/settingsNavCatalog.ts index a2cb027c3..112f0119c 100644 --- a/frontend-modern/src/components/Settings/settingsNavCatalog.ts +++ b/frontend-modern/src/components/Settings/settingsNavCatalog.ts @@ -47,6 +47,7 @@ export const SETTINGS_NAV_GROUPS: SettingsNavGroup[] = [ icon: Building2, iconProps: { strokeWidth: 2 }, features: ['multi_tenant'], + hideWhenOrganizationHidden: true, hideWhenUnavailable: true, }, { @@ -55,6 +56,7 @@ export const SETTINGS_NAV_GROUPS: SettingsNavGroup[] = [ icon: Users, iconProps: { strokeWidth: 2 }, features: ['multi_tenant'], + hideWhenOrganizationHidden: true, hideWhenUnavailable: true, }, { @@ -63,6 +65,7 @@ export const SETTINGS_NAV_GROUPS: SettingsNavGroup[] = [ icon: Share2, iconProps: { strokeWidth: 2 }, features: ['multi_tenant'], + hideWhenOrganizationHidden: true, hideWhenUnavailable: true, }, { @@ -71,6 +74,7 @@ export const SETTINGS_NAV_GROUPS: SettingsNavGroup[] = [ icon: CreditCard, iconProps: { strokeWidth: 2 }, features: ['multi_tenant'], + hideWhenOrganizationHidden: true, hideWhenUnavailable: true, hideWhenCommercialHidden: true, }, @@ -80,6 +84,7 @@ export const SETTINGS_NAV_GROUPS: SettingsNavGroup[] = [ icon: CreditCard, iconProps: { strokeWidth: 2 }, features: ['multi_tenant'], + hideWhenOrganizationHidden: true, hideWhenUnavailable: true, hostedOnly: true, hideWhenCommercialHidden: true, diff --git a/frontend-modern/src/components/Settings/settingsNavVisibility.ts b/frontend-modern/src/components/Settings/settingsNavVisibility.ts index 9e87bf1a4..696ce0be2 100644 --- a/frontend-modern/src/components/Settings/settingsNavVisibility.ts +++ b/frontend-modern/src/components/Settings/settingsNavVisibility.ts @@ -7,6 +7,7 @@ export interface SettingsNavVisibilityContext { hasFeature: (feature: string) => boolean; runtimeCapabilitiesLoaded: () => boolean; presentationPolicyHidesCommercial?: boolean; + presentationPolicyHidesOrganizations?: boolean; presentationPolicyResolved?: boolean; hostedModeEnabled?: boolean; settingsCapabilities?: Partial | null; @@ -32,6 +33,16 @@ export function shouldHideSettingsNavItem( return true; } + if (item.hideWhenOrganizationHidden) { + if (context.presentationPolicyResolved === false) { + return true; + } + + if (context.presentationPolicyHidesOrganizations) { + return true; + } + } + if (item.hideWhenCommercialHidden) { if (context.presentationPolicyResolved === false) { return true; diff --git a/frontend-modern/src/components/Settings/settingsNavigationModel.ts b/frontend-modern/src/components/Settings/settingsNavigationModel.ts index 608d72603..d08303fd6 100644 --- a/frontend-modern/src/components/Settings/settingsNavigationModel.ts +++ b/frontend-modern/src/components/Settings/settingsNavigationModel.ts @@ -50,6 +50,7 @@ export interface SettingsNavItem { hideWhenUnavailable?: boolean; hostedOnly?: boolean; hideWhenCommercialHidden?: boolean; + hideWhenOrganizationHidden?: boolean; requiredCapability?: keyof SecurityStatusSettingsCapabilities; badge?: string; features?: string[]; diff --git a/frontend-modern/src/components/Settings/useBillingAdminPanelState.ts b/frontend-modern/src/components/Settings/useBillingAdminPanelState.ts index 159aaf71a..1d234611e 100644 --- a/frontend-modern/src/components/Settings/useBillingAdminPanelState.ts +++ b/frontend-modern/src/components/Settings/useBillingAdminPanelState.ts @@ -6,6 +6,7 @@ import { } from '@/api/billingAdmin'; import { isHostedModeEnabled, isMultiTenantEnabled } from '@/stores/license'; import { notificationStore } from '@/stores/notifications'; +import { presentationPolicyHidesOrganizationSurfaces } from '@/stores/sessionPresentationPolicy'; import { logger } from '@/utils/logger'; import { getBillingAdminStateUpdateSuccessMessage, @@ -42,7 +43,12 @@ export function useBillingAdminPanelState() { const [savingByOrgID, setSavingByOrgID] = createSignal>({}); const [expandedOrgID, setExpandedOrgID] = createSignal(null); - const hostedEnabled = createMemo(() => isMultiTenantEnabled() && isHostedModeEnabled()); + const hostedEnabled = createMemo( + () => + isMultiTenantEnabled() && + isHostedModeEnabled() && + !presentationPolicyHidesOrganizationSurfaces(), + ); const setBillingLoading = (orgID: string, value: boolean) => { setBillingLoadingByOrgID((prev) => ({ ...prev, [orgID]: value })); diff --git a/frontend-modern/src/components/Settings/useOrganizationAccessPanelState.ts b/frontend-modern/src/components/Settings/useOrganizationAccessPanelState.ts index 5a8d5b90a..a43e4920d 100644 --- a/frontend-modern/src/components/Settings/useOrganizationAccessPanelState.ts +++ b/frontend-modern/src/components/Settings/useOrganizationAccessPanelState.ts @@ -8,6 +8,7 @@ import { import { eventBus } from '@/stores/events'; import { isMultiTenantEnabled } from '@/stores/license'; import { notificationStore } from '@/stores/notifications'; +import { presentationPolicyHidesOrganizationSurfaces } from '@/stores/sessionPresentationPolicy'; import { getOrgID } from '@/utils/apiClient'; import { logger } from '@/utils/logger'; import { normalizeOrgScope } from '@/utils/orgScope'; @@ -141,7 +142,10 @@ export function useOrganizationAccessPanelState(props: OrganizationAccessPanelPr }; onMount(() => { - if (!isMultiTenantEnabled()) return; + if (!isMultiTenantEnabled() || presentationPolicyHidesOrganizationSurfaces()) { + setLoading(false); + return; + } void loadOrganizationAccess(); const unsubscribe = eventBus.on('org_switched', () => { diff --git a/frontend-modern/src/components/Settings/useOrganizationBillingPanelState.ts b/frontend-modern/src/components/Settings/useOrganizationBillingPanelState.ts index bb811ceb0..e41bb080c 100644 --- a/frontend-modern/src/components/Settings/useOrganizationBillingPanelState.ts +++ b/frontend-modern/src/components/Settings/useOrganizationBillingPanelState.ts @@ -6,6 +6,7 @@ import { normalizeOrgScope } from '@/utils/orgScope'; import { isMultiTenantEnabled } from '@/stores/license'; import { eventBus } from '@/stores/events'; import { notificationStore } from '@/stores/notifications'; +import { presentationPolicyHidesOrganizationSurfaces } from '@/stores/sessionPresentationPolicy'; import { logger } from '@/utils/logger'; import { getLicenseTierLabel, @@ -38,7 +39,8 @@ export function useOrganizationBillingPanelState(props: OrganizationBillingPanel const [memberCount, setMemberCount] = createSignal(0); const activeOrgID = () => normalizeOrgScope(getOrgID()); - const isBillingAvailable = () => isMultiTenantEnabled(); + const isBillingAvailable = () => + isMultiTenantEnabled() && !presentationPolicyHidesOrganizationSurfaces(); const commercialPlanModel = createMemo(() => buildHostedCommercialPlanModel({ diff --git a/frontend-modern/src/components/Settings/useOrganizationOverviewPanelState.ts b/frontend-modern/src/components/Settings/useOrganizationOverviewPanelState.ts index cbe744163..8bec777f1 100644 --- a/frontend-modern/src/components/Settings/useOrganizationOverviewPanelState.ts +++ b/frontend-modern/src/components/Settings/useOrganizationOverviewPanelState.ts @@ -3,6 +3,7 @@ import { OrgsAPI, type Organization, type OrganizationMember } from '@/api/orgs' import { eventBus } from '@/stores/events'; import { isMultiTenantEnabled } from '@/stores/license'; import { notificationStore } from '@/stores/notifications'; +import { presentationPolicyHidesOrganizationSurfaces } from '@/stores/sessionPresentationPolicy'; import { getOrgID } from '@/utils/apiClient'; import { logger } from '@/utils/logger'; import { normalizeOrgScope } from '@/utils/orgScope'; @@ -77,7 +78,10 @@ export function useOrganizationOverviewPanelState(props: OrganizationOverviewPan }; onMount(() => { - if (!isMultiTenantEnabled()) return; + if (!isMultiTenantEnabled() || presentationPolicyHidesOrganizationSurfaces()) { + setLoading(false); + return; + } void loadOrganization(); const unsubscribe = eventBus.on('org_switched', () => { diff --git a/frontend-modern/src/components/Settings/useOrganizationSharingPanelState.ts b/frontend-modern/src/components/Settings/useOrganizationSharingPanelState.ts index ac67d2618..3ffff2a44 100644 --- a/frontend-modern/src/components/Settings/useOrganizationSharingPanelState.ts +++ b/frontend-modern/src/components/Settings/useOrganizationSharingPanelState.ts @@ -9,6 +9,7 @@ import { useResources } from '@/hooks/useResources'; import { eventBus } from '@/stores/events'; import { isMultiTenantEnabled } from '@/stores/license'; import { notificationStore } from '@/stores/notifications'; +import { presentationPolicyHidesOrganizationSurfaces } from '@/stores/sessionPresentationPolicy'; import { getOrgID } from '@/utils/apiClient'; import { INVALID_RESOURCE_TYPE_ERROR, @@ -282,7 +283,10 @@ export function useOrganizationSharingPanelState(props: OrganizationSharingPanel }; onMount(() => { - if (!isMultiTenantEnabled()) return; + if (!isMultiTenantEnabled() || presentationPolicyHidesOrganizationSurfaces()) { + setLoading(false); + return; + } void loadSharingData(); const unsubscribe = eventBus.on('org_switched', () => { diff --git a/frontend-modern/src/components/Settings/useSettingsAccess.ts b/frontend-modern/src/components/Settings/useSettingsAccess.ts index 672e4f150..6fab3e9c2 100644 --- a/frontend-modern/src/components/Settings/useSettingsAccess.ts +++ b/frontend-modern/src/components/Settings/useSettingsAccess.ts @@ -1,6 +1,7 @@ import { Accessor, createEffect, createMemo, createSignal } from 'solid-js'; import { presentationPolicyHidesCommercialSurfaces, + presentationPolicyHidesOrganizationSurfaces, sessionPresentationPolicyResolved, syncSessionPresentationPolicy, } from '@/stores/sessionPresentationPolicy'; @@ -39,6 +40,16 @@ export function useSettingsAccess({ const presentationPolicyResolved = createMemo( () => securityStatus() !== null || sessionPresentationPolicyResolved(), ); + const organizationSurfacesHidden = createMemo(() => { + const resolvedSecurityStatus = securityStatus(); + if (resolvedSecurityStatus) { + return ( + resolvedSecurityStatus.presentationPolicy?.demoMode === true || + resolvedSecurityStatus.sessionCapabilities?.demoMode === true + ); + } + return presentationPolicyHidesOrganizationSurfaces(); + }); const visibleTabGroups = createMemo(() => { const hostedModeEnabled = isHostedModeEnabled(); @@ -54,6 +65,7 @@ export function useSettingsAccess({ hasFeature, runtimeCapabilitiesLoaded, presentationPolicyHidesCommercial: commercialSurfacesHidden(), + presentationPolicyHidesOrganizations: organizationSurfacesHidden(), presentationPolicyResolved: presentationPolicyResolved(), hostedModeEnabled, settingsCapabilities, @@ -91,7 +103,8 @@ export function useSettingsAccess({ const requiresFeatureResolution = Boolean(tabFeatureRequirements[current]?.length); const requiresCapabilityResolution = Boolean(getSettingsNavItem(current)?.requiredCapability); const requiresPresentationPolicyResolution = Boolean( - getSettingsNavItem(current)?.hideWhenCommercialHidden, + getSettingsNavItem(current)?.hideWhenCommercialHidden || + getSettingsNavItem(current)?.hideWhenOrganizationHidden, ); if ( (requiresFeatureResolution && !runtimeCapabilitiesLoaded()) || diff --git a/frontend-modern/src/stores/sessionPresentationPolicy.ts b/frontend-modern/src/stores/sessionPresentationPolicy.ts index b072e8fa4..57096629b 100644 --- a/frontend-modern/src/stores/sessionPresentationPolicy.ts +++ b/frontend-modern/src/stores/sessionPresentationPolicy.ts @@ -58,6 +58,10 @@ export function presentationPolicyHidesCommercialSurfaces(): boolean { return sessionPresentationPolicy().hideCommercial; } +export function presentationPolicyHidesOrganizationSurfaces(): boolean { + return sessionPresentationPolicy().demoMode; +} + export function presentationPolicyHidesUpgradePrompts(): boolean { return sessionPresentationPolicy().hideUpgrade; } diff --git a/frontend-modern/src/useAppRuntimeState.ts b/frontend-modern/src/useAppRuntimeState.ts index 41c8994c8..fd93cc863 100644 --- a/frontend-modern/src/useAppRuntimeState.ts +++ b/frontend-modern/src/useAppRuntimeState.ts @@ -54,7 +54,10 @@ import { } from '@/stores/license'; import { aiChatStore } from '@/stores/aiChat'; import { loadCommercialPosture } from '@/stores/licenseCommercial'; -import { syncSessionPresentationPolicy } from '@/stores/sessionPresentationPolicy'; +import { + presentationPolicyHidesOrganizationSurfaces, + syncSessionPresentationPolicy, +} from '@/stores/sessionPresentationPolicy'; import { layoutStore } from '@/utils/layout'; import { markSystemSettingsLoadedWithDefaults, @@ -244,6 +247,12 @@ export const useAppRuntimeState = () => { tone: 'offline', }; }); + const showOrgSwitcher = createMemo(() => { + if (!isMultiTenantEnabled()) { + return false; + } + return !presentationPolicyHidesOrganizationSurfaces(); + }); const initialThemePreference = getStoredThemePreference(); const [themePreference, setThemePreference] = @@ -315,6 +324,23 @@ export const useAppRuntimeState = () => { await loadRuntimeCapabilities(); } + if (presentationPolicyHidesOrganizationSurfaces()) { + const storedOrgID = getSelectedOrgID(); + const hiddenOrgID = + isHostedModeEnabled() && storedOrgID && storedOrgID !== 'default' + ? storedOrgID + : 'default'; + setOrganizations([ + { + id: hiddenOrgID, + displayName: hiddenOrgID === 'default' ? 'Default Organization' : hiddenOrgID, + }, + ]); + setSelectedOrgID(hiddenOrgID); + setActiveOrgID(hiddenOrgID); + return; + } + if (!isMultiTenantEnabled()) { const storedOrgID = getSelectedOrgID(); const hostedOrgID = @@ -744,6 +770,7 @@ export const useAppRuntimeState = () => { dataUpdated, lastUpdateText, versionInfo, + showOrgSwitcher, themePreference, darkMode, handleThemeChange, diff --git a/tests/integration/tests/53-demo-mode-commercial-boundary.spec.ts b/tests/integration/tests/53-demo-mode-commercial-boundary.spec.ts index 27d3f7014..9c2b3a045 100644 --- a/tests/integration/tests/53-demo-mode-commercial-boundary.spec.ts +++ b/tests/integration/tests/53-demo-mode-commercial-boundary.spec.ts @@ -326,6 +326,8 @@ base.describe('Demo mode commercial boundary', () => { await expect(page.getByRole('heading', { level: 1, name: 'Infrastructure Operations' })).toBeVisible(); const settingsNavigation = page.locator('[aria-label="Settings navigation"]'); await expect(settingsNavigation).toBeVisible(); + await expect(page.getByText('Default Organization', { exact: true })).toHaveCount(0); + await expect(settingsNavigation.getByText('Organization', { exact: true })).toHaveCount(0); await expect( settingsNavigation.getByText('Pulse Pro', { exact: true }), ).toHaveCount(0); @@ -447,6 +449,8 @@ base.describe('Managed demo runtime commercial boundary', () => { await expect(page.getByRole('heading', { level: 1, name: 'Infrastructure Operations' })).toBeVisible(); const settingsNavigation = page.locator('[aria-label="Settings navigation"]'); await expect(settingsNavigation).toBeVisible(); + await expect(page.getByText('Default Organization', { exact: true })).toHaveCount(0); + await expect(settingsNavigation.getByText('Organization', { exact: true })).toHaveCount(0); await expect(settingsNavigation.getByText('Pulse Pro', { exact: true })).toHaveCount(0); await expect(page.getByText('Pro Trial:', { exact: false })).toHaveCount(0); await expect(page.getByText(/Monitored systems:\s*\d+\/\d+/)).toHaveCount(0);