mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
Hide demo organization chrome via presentation policy
This commit is contained in:
@@ -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`,
|
||||
|
||||
@@ -375,6 +375,7 @@ function App() {
|
||||
organizations={runtime.organizations}
|
||||
activeOrgID={runtime.activeOrgID}
|
||||
orgsLoading={runtime.orgsLoading}
|
||||
showOrgSwitcher={runtime.showOrgSwitcher}
|
||||
onSwitchOrg={runtime.handleOrgSwitch}
|
||||
>
|
||||
{props.children}
|
||||
|
||||
@@ -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}');
|
||||
|
||||
+31
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user