Hide demo organization chrome via presentation policy

This commit is contained in:
rcourtman
2026-04-10 23:43:15 +01:00
parent 016f9191be
commit 0a4c84daaa
30 changed files with 262 additions and 14 deletions
@@ -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
@@ -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
@@ -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
@@ -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.
@@ -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
@@ -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`,
+1
View File
@@ -375,6 +375,7 @@ function App() {
organizations={runtime.organizations}
activeOrgID={runtime.activeOrgID}
orgsLoading={runtime.orgsLoading}
showOrgSwitcher={runtime.showOrgSwitcher}
onSwitchOrg={runtime.handleOrgSwitch}
>
{props.children}
+2 -2
View File
@@ -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) {
>
<Show when={props.hasAuth() && !props.needsAuth()}>
<div class="flex items-center gap-2">
<Show when={isMultiTenantEnabled()}>
<Show when={props.showOrgSwitcher()}>
<OrgSwitcher
orgs={props.organizations()}
selectedOrgId={props.activeOrgID()}
@@ -45,6 +45,7 @@ describe('App architecture', () => {
expect(appSource).not.toContain('AIAPI.getSettings()');
expect(appSource).toContain("if (e.key === 'Escape' && aiChatStore.isOpen) {");
expect(appSource).toContain('<AIChat onClose={() => 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<AppConnectionStatus>(() => {');
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(',
);
@@ -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();
});
@@ -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<OrganizationAccessPanelProps> = (props) => {
const state = useOrganizationAccessPanelState(props);
const showOrganizationSurface = () =>
isMultiTenantEnabled() && !presentationPolicyHidesOrganizationSurfaces();
return (
<Show
when={isMultiTenantEnabled()}
when={showOrganizationSurface()}
fallback={
<div class={ORGANIZATION_SETTINGS_UNAVAILABLE_CLASS}>
{ORGANIZATION_SETTINGS_UNAVAILABLE_MESSAGE}
@@ -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<OrganizationOverviewPanelProps> = (props) => {
const state = useOrganizationOverviewPanelState(props);
const showOrganizationSurface = () =>
isMultiTenantEnabled() && !presentationPolicyHidesOrganizationSurfaces();
return (
<Show
when={isMultiTenantEnabled()}
when={showOrganizationSurface()}
fallback={
<div class={ORGANIZATION_SETTINGS_UNAVAILABLE_CLASS}>
{ORGANIZATION_SETTINGS_UNAVAILABLE_MESSAGE}
@@ -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<OrganizationSharingPanelProps> = (props) => {
const state = useOrganizationSharingPanelState(props);
const showOrganizationSurface = () =>
isMultiTenantEnabled() && !presentationPolicyHidesOrganizationSurfaces();
return (
<Show
when={isMultiTenantEnabled()}
when={showOrganizationSurface()}
fallback={
<div class={ORGANIZATION_SETTINGS_UNAVAILABLE_CLASS}>
{ORGANIZATION_SETTINGS_UNAVAILABLE_MESSAGE}
@@ -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(() => <BillingAdminPanel />);
expect(
screen.getByText('Organization settings are not available on this server.'),
).toBeInTheDocument();
expect(listOrganizationsMock).not.toHaveBeenCalled();
expect(getBillingStateMock).not.toHaveBeenCalled();
});
});
@@ -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(() => <OrganizationBillingPanel nodeUsage={5} guestUsage={2} />);
expect(
screen.getByText('Organization settings are not available on this server.'),
).toBeInTheDocument();
expect(getStatusMock).not.toHaveBeenCalled();
expect(listOrgsMock).not.toHaveBeenCalled();
expect(listMembersMock).not.toHaveBeenCalled();
});
});
@@ -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();
});
});
@@ -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}');
@@ -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', () => {
@@ -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,
@@ -7,6 +7,7 @@ export interface SettingsNavVisibilityContext {
hasFeature: (feature: string) => boolean;
runtimeCapabilitiesLoaded: () => boolean;
presentationPolicyHidesCommercial?: boolean;
presentationPolicyHidesOrganizations?: boolean;
presentationPolicyResolved?: boolean;
hostedModeEnabled?: boolean;
settingsCapabilities?: Partial<SecurityStatusSettingsCapabilities> | 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;
@@ -50,6 +50,7 @@ export interface SettingsNavItem {
hideWhenUnavailable?: boolean;
hostedOnly?: boolean;
hideWhenCommercialHidden?: boolean;
hideWhenOrganizationHidden?: boolean;
requiredCapability?: keyof SecurityStatusSettingsCapabilities;
badge?: string;
features?: string[];
@@ -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<Record<string, boolean>>({});
const [expandedOrgID, setExpandedOrgID] = createSignal<string | null>(null);
const hostedEnabled = createMemo(() => isMultiTenantEnabled() && isHostedModeEnabled());
const hostedEnabled = createMemo(
() =>
isMultiTenantEnabled() &&
isHostedModeEnabled() &&
!presentationPolicyHidesOrganizationSurfaces(),
);
const setBillingLoading = (orgID: string, value: boolean) => {
setBillingLoadingByOrgID((prev) => ({ ...prev, [orgID]: value }));
@@ -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', () => {
@@ -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({
@@ -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', () => {
@@ -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', () => {
@@ -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()) ||
@@ -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;
}
+28 -1
View File
@@ -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,
@@ -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);