From 7323ca72b29460f73613d1da2d4cc2289e5d7ac8 Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Tue, 1 Sep 2026 20:30:54 +0200 Subject: [PATCH] feat(web): demo presentation mode for live product demos (#141) * feat(web): demo presentation mode for live product demos The Global-Admin view toggle only ever reached the nav and the scope hooks. Every GA extra inside a tenant page was gated on the identity and stayed on screen when the toggle was switched off -- including the /health-check build cards, whose commit hashes hyperlink into the private repo, and the Decision Inspector button on the session detail page. New useGlobalAdminUi() (real GA *and* global view on) replaces the bare identity check at the five visible GA surfaces, so switching the toggle off now yields a genuinely tenant-admin-looking view. On top of that a hidden demo mode, armed by a self-consuming ?demo=1 (persisted to localStorage, then stripped from the address bar), forces the global view off for every consumer without touching the stored value, hides the toggle itself and the platform role badges, and silently replaces /admin/* with /dashboard rather than rendering the Access Denied card -- whose text would itself reveal that a platform area exists. ?demo=0 clears it and restores the previous setting. An unrecognised value leaves the stored flag alone, so a typo cannot drop the operator out mid-presentation. Admin Mode stays untouched: a tenant admin has it too, so it belongs in an honest demo. user.isGlobalAdmin is never faked globally -- that would drag canEditConfig along and leave a GA without an own tenant-admin role unable to demonstrate anything. Also hides the settings gear when neither toggle applies, which previously opened an empty panel for Operators and Viewers as well. Presentation only. Every rule mirrored here stays enforced server-side (TenantConfigValidation, GlobalAdminOnly -> 403); no backend file is touched and demo mode is not a security boundary. Co-Authored-By: Claude Opus 5 (1M context) * chore(web): refresh generated page-lastmod Regenerated by the build (scripts/generate-lastmod.js). The committed file had drifted: about/privacy/terms carried stale dates and /buy, /help and /plans were missing entirely, so the sitemap under-reported them. Pre-existing drift, unrelated to the demo-presentation-mode change. Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) --- docs/index.md | 1 + docs/log.md | 4 + docs/web/demo-presentation-mode.md | 65 +++++++++++++++ .../app/health-check/page.tsx | 12 ++- .../app/sessions/page.tsx | 6 +- .../sections/SectionDataManagement.tsx | 10 ++- .../sections/SectionAutopilotValidation.tsx | 12 +-- .../tenant/sections/SectionNotifications.tsx | 8 +- .../components/Navbar.tsx | 37 +++++++-- .../components/ProtectedRoute.tsx | 19 +++++ src/Web/autopilot-monitor-web/hooks/index.ts | 2 + .../hooks/useAdminMode.ts | 75 ++++++++++++++++- .../hooks/useGlobalAdminUi.ts | 24 ++++++ .../lib/__tests__/demoMode.test.ts | 81 +++++++++++++++++++ src/Web/autopilot-monitor-web/lib/demoMode.ts | 79 ++++++++++++++++++ .../utils/page-lastmod.generated.ts | 9 ++- 16 files changed, 414 insertions(+), 30 deletions(-) create mode 100644 docs/web/demo-presentation-mode.md create mode 100644 src/Web/autopilot-monitor-web/hooks/useGlobalAdminUi.ts create mode 100644 src/Web/autopilot-monitor-web/lib/__tests__/demoMode.test.ts create mode 100644 src/Web/autopilot-monitor-web/lib/demoMode.ts diff --git a/docs/index.md b/docs/index.md index 92d84d41..076a174d 100644 --- a/docs/index.md +++ b/docs/index.md @@ -82,6 +82,7 @@ https://docs.autopilotmonitor.com (separate repository). # Web Portal * [Portal Navigation Prefetch & the SWA Runtime Bottleneck](web/portal-navigation-prefetch.md) - Why every navigation Link sets `prefetch={false}`: the App Router's viewport prefetch fans out one RSC request per visible link and the Static Web Apps SSR runtime queues them server-side into a multi-minute tail. Records the follow-up measurement (the Standard plan changed nothing; the prefetch storm was an accidental keep-alive), where browser telemetry actually lives, and why this curl build cannot be trusted for HTTP versions. +* [Demo Presentation Mode](web/demo-presentation-mode.md) - Two levels of hiding operator internals for live demos: the Global-Admin view toggle now also collapses the GA extras inside tenant pages (Telegram provider, cert-device binding, retention escape hatch, the `/health-check` build and commit cards), and a hidden demo mode armed by a self-consuming `?demo=1` additionally removes the toggle, the platform role badge and access to `/admin`. Presentation only - every rule stays enforced server-side. # Security & Identity diff --git a/docs/log.md b/docs/log.md index e5177fed..a40a1a58 100644 --- a/docs/log.md +++ b/docs/log.md @@ -1,5 +1,9 @@ # Log +## 2026-09-01 (5) + +* **New**: `web/demo-presentation-mode.md` - the portal can now be shown live without leaking operator internals. The Global-Admin view toggle was only ever read by the nav and the scope hooks; every GA extra inside a tenant page was gated on the identity and stayed on screen when it was switched off - including the `/health-check` Backend/Portal Build cards with commit hashes hyperlinked into the private repo. New `useGlobalAdminUi()` (real GA **and** global view on) replaces the bare identity check at the four visible GA surfaces, so switching the toggle off yields a genuinely tenant-admin-looking view. On top of that a hidden demo mode, armed by a self-consuming `?demo=1` (persisted to localStorage, then stripped from the address bar via `history.replaceState`; `?demo=0` clears it), forces the global view off for every consumer without touching the stored value, hides the toggle itself and the platform role badges, and silently replaces `/admin/*` with `/dashboard` instead of rendering the Access Denied card - whose text would itself reveal the platform area. Admin Mode is deliberately untouched: a tenant admin has it too. `user.isGlobalAdmin` is never faked globally, because that would drag `canEditConfig` along and leave a GA without an own tenant-admin role unable to demonstrate anything. Both levels are presentation only - `TenantConfigValidation` and the `GlobalAdminOnly` 403s are unchanged. + ## 2026-09-01 (3) * **Update**: `agent/decision-engine.md` - agent_metrics_snapshot gains a signal-pipeline health block: monotonic SignalIngress totals (last ordinal, processed, pending, reentrant, queue peak) + decision_stage / decision_last_applied_ordinal / wg_sealing_level+score from the immutable DecisionState, wired via a read-only decisionStateProbe through IComponentFactory. Motivated by the WG-seal soak (project_wg_seal_postfix_soak_residual): a frozen engine and a deliberate Weak sealing verdict were previously indistinguishable in telemetry. diff --git a/docs/web/demo-presentation-mode.md b/docs/web/demo-presentation-mode.md new file mode 100644 index 00000000..ec1ed207 --- /dev/null +++ b/docs/web/demo-presentation-mode.md @@ -0,0 +1,65 @@ +--- +type: Concept +title: Demo Presentation Mode — Two Levels of Hiding Operator Internals +description: How the portal can be shown live without leaking operator-only detail. The existing Global-Admin view toggle now also collapses the GA extras inside tenant pages, and a hidden demo mode (armed by a self-consuming ?demo=1 URL parameter) additionally removes the toggle itself, the Global-Admin badge and access to the platform area. Presentation only — every real gate stays on the server. +resource: src/Web/autopilot-monitor-web/lib/demoMode.ts +tags: + - web + - presentation + - global-admin + - operator-tooling +timestamp: 2026-09-01T20:30:00+02:00 +--- + +# Problem + +The product is demonstrated live, and the operator doing the demonstration is a platform Global Admin. Two things leaked into those sessions. + +**The Global-Admin view toggle only covered navigation.** `globalAdminMode` is a localStorage view switch read by `lib/navVisibility.ts` and the scope hooks (`hooks/useAggregatedAdminScope.ts`), so switching it off collapsed the sidebar and the cross-tenant data scope. Every GA extra *inside* an otherwise tenant-scoped page was gated on the identity `user.isGlobalAdmin` instead and stayed on screen: the platform-bot Telegram provider, the cert-device-binding toggle, the retention escape hatch, and — most exposed — the Backend Build and Portal Build cards on `/health-check`, which carry version strings and commit hashes hyperlinked into the private repository. + +**Nothing existed for a stage demo.** The Global-Admin toggle and the purple "Global Admin" badge were always on screen, one click away from opening the platform view mid-presentation. + +# Model + +Two levels, both purely presentational. + +**Level 1 — the Global-Admin view toggle.** Switching it off now yields a view indistinguishable from a tenant admin's. New hook `useGlobalAdminUi()` = *real GA* **and** *global view on*; it replaces the bare identity check at every GA-exclusive **visible** surface. For quick product demos between other work. + +**Level 2 — demo mode.** A separate, hidden flag that forces the global view off for every consumer, hides the toggle itself and the platform role badges, and bounces the platform routes. For conference demos, where nothing may happen by accident. + +`useCanMutatePlatform()` deliberately does **not** change: it stays bound to the identity and gates the mutating controls in the `/admin` area, where a view toggle must never decide what may be written. + +`user.isGlobalAdmin` is never faked globally. That would be the shorter lever but it would drag `canEditConfig` (`app/settings/TenantConfigContext.tsx`) along, and a Global Admin without an own tenant-admin role would lose every write affordance — unable to demonstrate the product at all. + +# Activation + +`?demo=1` on any portal URL writes `demoMode=true` to localStorage and the parameter is then **consumed**: `history.replaceState` strips it from the address bar immediately, so it survives no screenshot and needs no devtools on stage. `?demo=0` clears it the same way. `1|true|on|` (bare) arm; `0|false|off` clear; anything unrecognised returns `null` and leaves the stored value alone, so a typo cannot drop the operator out mid-presentation. + +The stored `globalAdminMode` value is never overwritten — demo mode masks it at read time (`effectiveGlobalAdminMode`), so the operator's usual setting returns with `?demo=0`. + +# What each level hides + +| Surface | Level 1 (view toggle off) | Level 2 (demo mode) | +| --- | --- | --- | +| Purple platform section in the sidebar | hidden | hidden | +| Cross-tenant data scope, platform notifications | collapsed to the tenant | collapsed to the tenant | +| Telegram provider, cert-device-binding toggle, retention escape hatch | hidden | hidden | +| `/health-check` build + commit cards | hidden | hidden | +| "Global Admin" / "Global Reader" badge in the user menu | shows "Admin" instead | shows "Admin" instead | +| The Global-Admin toggle itself | still there | hidden | +| `/admin/*` reached by a typed URL or bookmark | Access Denied card | silent replace to `/dashboard` | +| Admin Mode (delete sessions) | unchanged | unchanged — a tenant admin has it too, so it belongs in an honest demo | + +An already-configured Telegram channel stays visible and readable at both levels (`components/notifications/ChannelEditor.tsx`): it is the tenant's own channel, a tenant admin sees it too, and hiding its provider would make the next save destroy it. + +# Not a security boundary + +Both levels are presentation. Every rule they mirror is enforced server-side and unchanged: `TenantConfigValidation.ValidateTelegramChannelGate` rejects a non-GA Telegram write on both the PUT and the MCP field-patch path, the retention cap is validated in `ValidateModel`, and the platform endpoints answer `GlobalAdminOnly` with 403. Demo mode must never be described, documented or relied on as an access control. + +# Citations + +- `src/Web/autopilot-monitor-web/lib/demoMode.ts` — pure logic (parameter read/strip, effective view flag), covered by `lib/__tests__/demoMode.test.ts` +- `src/Web/autopilot-monitor-web/hooks/useAdminMode.ts` — the three modes, URL consumption, effective `globalAdminMode` +- `src/Web/autopilot-monitor-web/hooks/useGlobalAdminUi.ts` — the visible-GA-extras hook +- `src/Web/autopilot-monitor-web/components/ProtectedRoute.tsx` — platform-route bounce while presenting +- [portal-navigation-prefetch.md](portal-navigation-prefetch.md) — the other portal-wide behaviour switch diff --git a/src/Web/autopilot-monitor-web/app/health-check/page.tsx b/src/Web/autopilot-monitor-web/app/health-check/page.tsx index 4aefeda8..e382b8e6 100644 --- a/src/Web/autopilot-monitor-web/app/health-check/page.tsx +++ b/src/Web/autopilot-monitor-web/app/health-check/page.tsx @@ -4,6 +4,7 @@ import { useAuth } from '@/contexts/AuthContext'; import { useNotifications } from '@/contexts/NotificationContext'; import { useSignalR } from '@/contexts/SignalRContext'; import { useTenant } from '@/contexts/TenantContext'; +import { useGlobalAdminUi } from '@/hooks/useGlobalAdminUi'; import { useState, useEffect, useCallback } from 'react'; import * as signalR from '@microsoft/signalr'; import { api } from '@/lib/api'; @@ -12,6 +13,9 @@ import type { DetailedHealthCheckResponse, HealthCheck, McpHealthCheckResponse } export default function HealthCheckPage() { const { user, getAccessToken } = useAuth(); + // Build stamps (version + commit hash, linking into the private repo) are operator-only detail. + // They follow the Global-Admin VIEW, so the page can be shown live without leaking internals. + const showBuildDetails = useGlobalAdminUi(); const { addNotification } = useNotifications(); const { connectionState, isConnected, joinedGroups, joinGroup } = useSignalR(); const { tenantId } = useTenant(); @@ -93,14 +97,14 @@ export default function HealthCheckPage() { }, [getAccessToken]); useEffect(() => { - if (!user?.isGlobalAdmin) return; + if (!showBuildDetails) return; let cancelled = false; fetch("/version.json", { cache: "no-store" }) .then((r) => (r.ok ? r.json() : null)) .then((d) => { if (!cancelled && d?.commit) setWebBuild(d); }) .catch(() => { /* card simply stays hidden */ }); return () => { cancelled = true; }; - }, [user?.isGlobalAdmin]); + }, [showBuildDetails]); // Auto-run health checks on page load. The two probes are fired independently so the // fast backend checks render immediately while the MCP probe (possible cold start) catches up. @@ -235,7 +239,7 @@ export default function HealthCheckPage() { )} {/* Backend Build (Global Admin only) */} - {user?.isGlobalAdmin && healthResult?.version && ( + {showBuildDetails && healthResult?.version && (

Backend Build

@@ -273,7 +277,7 @@ export default function HealthCheckPage() { )} {/* Portal Build (Global Admin only) */} - {user?.isGlobalAdmin && webBuild && ( + {showBuildDetails && webBuild && (

Portal Build

diff --git a/src/Web/autopilot-monitor-web/app/sessions/page.tsx b/src/Web/autopilot-monitor-web/app/sessions/page.tsx index f80b2c8e..49d485d9 100644 --- a/src/Web/autopilot-monitor-web/app/sessions/page.tsx +++ b/src/Web/autopilot-monitor-web/app/sessions/page.tsx @@ -46,6 +46,7 @@ import DeviceDetailsCard from "./components/DeviceDetailsCard"; import { generateUiExport, generateCsvExport, generateSessionCsvExport, generateRuleResultsCsvExport, SessionExportEvent } from "@/utils/sessionExportUtils"; import { trackEvent } from "@/lib/appInsights"; import { useAdminMode } from "@/hooks/useAdminMode"; +import { useGlobalAdminUi } from "@/hooks/useGlobalAdminUi"; export default function SessionDetailPage() { // useSearchParams() in SessionDetailContent requires a Suspense boundary for static prerender. @@ -87,6 +88,9 @@ function SessionDetailContent() { const [collapsedPhases, setCollapsedPhases] = useState>(new Set()); const { adminMode, globalAdminMode } = useAdminMode(); + // The Decision Inspector is operator-only diagnostics on a page shown in every demo, so the link + // follows the Global-Admin VIEW rather than the identity alone. The route itself stays gated. + const showInspectorLink = useGlobalAdminUi(); // Global contexts const { on, off, isConnected, joinGroup, leaveGroup } = useSignalR(); @@ -557,7 +561,7 @@ function SessionDetailContent() { Report Session )} - {user?.isGlobalAdmin && sessionId && ( + {showInspectorLink && sessionId && ( @@ -22,7 +28,7 @@ export function SectionDataManagement() { setDataRetentionDays={setDataRetentionDays} sessionTimeoutHours={sessionTimeoutHours} setSessionTimeoutHours={setSessionTimeoutHours} - isGlobalAdmin={user?.isGlobalAdmin} + isGlobalAdmin={isGlobalAdminView} retentionCapDays={editionInfo.entitlements.retentionCapDays} onSave={handleSaveDataManagement} onReset={handleResetDataManagement} diff --git a/src/Web/autopilot-monitor-web/app/settings/tenant/sections/SectionAutopilotValidation.tsx b/src/Web/autopilot-monitor-web/app/settings/tenant/sections/SectionAutopilotValidation.tsx index fb66b956..e878c764 100644 --- a/src/Web/autopilot-monitor-web/app/settings/tenant/sections/SectionAutopilotValidation.tsx +++ b/src/Web/autopilot-monitor-web/app/settings/tenant/sections/SectionAutopilotValidation.tsx @@ -1,6 +1,7 @@ "use client"; import { useAuth } from "@/contexts/AuthContext"; +import { useGlobalAdminUi } from "@/hooks/useGlobalAdminUi"; import { legacyConfigured, switchAuthApp } from "@/lib/authApp"; import { DOCS_URL } from "@/utils/config"; import { useTenantConfig } from "../../TenantConfigContext"; @@ -25,7 +26,12 @@ export function SectionAutopilotValidation() { appHomingFunnelActive, homingFlipped, } = useTenantConfig(); - const { user, getAccessToken } = useAuth(); + const { getAccessToken } = useAuth(); + // Cert-device binding stays Global-Admin-only: only the operator can turn the binding check + // on while its enrollment-race behaviour is still being measured. Follows the Global-Admin + // VIEW, so switching it off (or presenting in demo mode) yields the real tenant-admin section. + // Read before the early return below — hooks may not sit behind a conditional. + const showIntuneDeviceBindingToggle = useGlobalAdminUi(); // Validation gates + the Entra admin-consent flow are tenant-admin territory — // Operators do not see this section at all. @@ -36,10 +42,6 @@ export function SectionAutopilotValidation() {
); } - // Cert-device binding stays Global-Admin-only: only the operator can turn the binding check - // on while its enrollment-race behaviour is still being measured. - const showIntuneDeviceBindingToggle = user?.isGlobalAdmin === true; - // Dual app-reg window: tenants homed on the previous app registration (homedAppClientId // null/absent) keep working unchanged — this is a purely informational nudge, part of the // incentive-driven re-consent campaign. Never a warning, never an action requirement. diff --git a/src/Web/autopilot-monitor-web/app/settings/tenant/sections/SectionNotifications.tsx b/src/Web/autopilot-monitor-web/app/settings/tenant/sections/SectionNotifications.tsx index bd02a329..beea836e 100644 --- a/src/Web/autopilot-monitor-web/app/settings/tenant/sections/SectionNotifications.tsx +++ b/src/Web/autopilot-monitor-web/app/settings/tenant/sections/SectionNotifications.tsx @@ -1,6 +1,6 @@ "use client"; -import { useAuth } from "@/contexts/AuthContext"; +import { useGlobalAdminUi } from "@/hooks/useGlobalAdminUi"; import { useTenantConfig } from "../../TenantConfigContext"; import { TenantNotifications } from "../../TenantNotifications"; import NotificationsSection from "../../components/NotificationsSection"; @@ -16,9 +16,9 @@ export function SectionNotifications() { // Telegram channels deliver through the platform-owned bot, so only a Global Admin may // configure one. The server enforces the same rule (TenantConfigValidation) — hiding the - // option here is convenience, not the control. - const { user } = useAuth(); - const showTelegramProvider = user?.isGlobalAdmin === true; + // option here is convenience, not the control. Follows the Global-Admin VIEW, so switching it + // off (or presenting in demo mode) yields the real tenant-admin dropdown. + const showTelegramProvider = useGlobalAdminUi(); return ( <> diff --git a/src/Web/autopilot-monitor-web/components/Navbar.tsx b/src/Web/autopilot-monitor-web/components/Navbar.tsx index b05b3d02..aa295031 100644 --- a/src/Web/autopilot-monitor-web/components/Navbar.tsx +++ b/src/Web/autopilot-monitor-web/components/Navbar.tsx @@ -31,7 +31,7 @@ export default function Navbar() { const [showHelp, setShowHelp] = useState(false); const [showOverflow, setShowOverflow] = useState(false); const [overflowSubmenu, setOverflowSubmenu] = useState<'help' | 'settings' | null>(null); - const { adminMode, setAdminMode, globalAdminMode, setGlobalAdminMode } = useAdminMode(); + const { adminMode, setAdminMode, globalAdminMode, setGlobalAdminMode, demoMode } = useAdminMode(); // Community-edition reminder under the wordmark; Pro (incl. trial) gets clean chrome. // null until the feature-flags fetch confirms → no label flash for Pro tenants. const editionInfo = useEditionInfo(); @@ -122,9 +122,21 @@ export default function Navbar() { }; const isTenantAdmin = user?.isTenantAdmin ?? false; + // Whether to PRESENT the caller as a Global Admin. Follows the Global-Admin view toggle (which + // demo mode forces off), so switching it off shows the tenant-admin identity a live demo needs. + // Permission checks keep using user.isGlobalAdmin — this drives the badge only. + const showGlobalAdminIdentity = (user?.isGlobalAdmin ?? false) && globalAdminMode; + // Same rule for the read-only platform role — with the global view off it is just as much an + // internal as the Global Admin badge, and the toggle already reads "Global View" for them. + const showGlobalReaderIdentity = + (user?.isGlobalReader ?? false) && globalAdminMode && !showGlobalAdminIdentity; const isOperator = user?.role === 'Operator'; const isViewer = user?.role === 'Viewer'; const isAdminOrOperator = isTenantAdmin || isOperator; + // Everything the settings menu can hold. With no toggle applicable — an Operator/Viewer, or a + // Global Admin presenting in demo mode without an own tenant-admin role — the menu would open + // onto an empty panel, so the gear (and the mobile "Administration" block) stays hidden. + const showAdminToggles = isTenantAdmin || (hasGlobalScope && !demoMode); // Tenant notification dismiss is currently tenant-shared (clearing for one user clears for // all). Only Admins / Global Admins are permitted to dismiss; Operators and Viewers see the // bell as read-only. See TenantNotificationContext + EndpointAccessPolicyCatalog. @@ -408,6 +420,7 @@ export default function Navbar() {
{/* Settings Menu (Gear) — hidden on + {showAdminToggles && ( + <>

Administration

@@ -661,8 +679,9 @@ export default function Navbar() {
)} - {/* Global scope toggle — Global Admin or read-only Global Reader */} - {hasGlobalScope && ( + {/* Global scope toggle — Global Admin or read-only Global Reader. + Hidden while presenting (demo mode), same as the desktop menu. */} + {hasGlobalScope && !demoMode && (
@@ -677,6 +696,8 @@ export default function Navbar() {
)}
+ + )} )} @@ -746,12 +767,12 @@ export default function Navbar() {

{user?.displayName || 'User'}

{user?.upn}

- {user?.isGlobalAdmin && ( + {showGlobalAdminIdentity && ( Global Admin )} - {user?.isGlobalReader && !user?.isGlobalAdmin && ( + {showGlobalReaderIdentity && ( Global Reader @@ -761,7 +782,7 @@ export default function Navbar() { Operator )} - {isTenantAdmin && !user?.isGlobalAdmin && ( + {isTenantAdmin && !showGlobalAdminIdentity && ( Admin diff --git a/src/Web/autopilot-monitor-web/components/ProtectedRoute.tsx b/src/Web/autopilot-monitor-web/components/ProtectedRoute.tsx index ab489ead..da51d595 100644 --- a/src/Web/autopilot-monitor-web/components/ProtectedRoute.tsx +++ b/src/Web/autopilot-monitor-web/components/ProtectedRoute.tsx @@ -7,6 +7,7 @@ import { savePostLoginReturnUrl } from "../lib/postLoginReturn"; import { isOnPublicHost } from "../lib/hostRouting"; import { consumeLoginDeclined, legacyConfigured, switchAuthApp } from "../lib/authApp"; import { activeAuthApp } from "../lib/msalConfig"; +import { useAdminMode } from "../hooks/useAdminMode"; interface ProtectedRouteProps { children: React.ReactNode; @@ -92,6 +93,19 @@ export function ProtectedRoute({ children, requireGlobalAdmin = false, requireGl } }, [isAuthenticated, isLoading, router, login, loginFailed]); + // Demo ("presentation") mode: a platform route reached by a typed URL or a stale bookmark while + // presenting live. Bounce silently to the tenant dashboard instead of rendering the Access Denied + // card below — its text ("You need Global Admin permissions") would itself reveal that a platform + // area exists. Presentation only: the route opens again the moment demo mode is cleared, and the + // backend gates it either way. See lib/demoMode.ts. + const { demoMode } = useAdminMode(); + const demoBlockedPlatformRoute = demoMode && (requireGlobalAdmin || requireGlobalScope); + useEffect(() => { + if (demoBlockedPlatformRoute && isAuthenticated) { + router.replace("/dashboard"); + } + }, [demoBlockedPlatformRoute, isAuthenticated, router]); + // Sign-in failed (e.g. an interrupted earlier redirect left MSAL in // interaction_in_progress) — stay on this origin and offer a manual retry. if (!isAuthenticated && !isLoading && loginFailed) { @@ -142,6 +156,11 @@ export function ProtectedRoute({ children, requireGlobalAdmin = false, requireGl return null; } + // Render nothing while the demo-mode bounce above navigates away. + if (demoBlockedPlatformRoute) { + return null; + } + // Show nothing if the route's platform requirement isn't met. // requireGlobalAdmin → real Global Admin only; requireGlobalScope → Global Admin OR Global Reader. const platformDenied = diff --git a/src/Web/autopilot-monitor-web/hooks/index.ts b/src/Web/autopilot-monitor-web/hooks/index.ts index f8fa48e7..b0c66fd3 100644 --- a/src/Web/autopilot-monitor-web/hooks/index.ts +++ b/src/Web/autopilot-monitor-web/hooks/index.ts @@ -1,5 +1,7 @@ export { useAdminMode } from "./useAdminMode"; +export { useGlobalAdminUi } from "./useGlobalAdminUi"; + export { useTenantList } from "./useTenantList"; export type { TenantInfo } from "./useTenantList"; diff --git a/src/Web/autopilot-monitor-web/hooks/useAdminMode.ts b/src/Web/autopilot-monitor-web/hooks/useAdminMode.ts index 9bee4584..05a8fd68 100644 --- a/src/Web/autopilot-monitor-web/hooks/useAdminMode.ts +++ b/src/Web/autopilot-monitor-web/hooks/useAdminMode.ts @@ -1,19 +1,52 @@ "use client"; import { useState, useEffect } from "react"; +import { + DEMO_MODE_STORAGE_KEY, + effectiveGlobalAdminMode, + readDemoParam, + stripDemoParam, +} from "@/lib/demoMode"; interface UseAdminModeReturn { adminMode: boolean; setAdminMode: (value: boolean) => void; globalAdminMode: boolean; setGlobalAdminMode: (value: boolean) => void; + /** + * Presentation mode for live demos — hides the Global-Admin toggle, the Global-Admin badge and + * every operator-only surface. Read-only by design: it is armed via `?demo=1` / cleared via + * `?demo=0` (both consumed from the address bar), never by a control inside the portal. + */ + demoMode: boolean; } /** - * Manages adminMode and globalAdminMode state with localStorage persistence. + * Reads the demo-mode intent from the current URL and falls back to the stored flag. Used by the + * lazy initializer so the very first render already knows — otherwise operator-only UI would flash + * on screen for one frame before the effect below settles it. + */ +function resolveInitialDemoMode(): boolean { + const fromUrl = readDemoParam(window.location.search); + if (fromUrl !== null) return fromUrl; + return localStorage.getItem(DEMO_MODE_STORAGE_KEY) === "true"; +} + +/** + * Guards the URL consumption: nine components mount this hook on a single page, and the parameter + * must be persisted and stripped exactly once per page load. + */ +let demoParamConsumed = false; + +/** + * Manages adminMode, globalAdminMode and demoMode state with localStorage persistence. * Changes are persisted to localStorage and broadcast via a 'localStorageChange' * custom event so all components on the same page stay in sync. * Cross-tab synchronization uses the native 'storage' event. + * + * Demo mode forces globalAdminMode off for every consumer (nav, scope hooks, platform + * notifications) while leaving the STORED value untouched, so clearing demo mode restores the + * operator's usual view. See lib/demoMode.ts — presentation only, never a security boundary. */ export function useAdminMode(): UseAdminModeReturn { const [adminMode, setAdminModeState] = useState(() => { @@ -30,6 +63,34 @@ export function useAdminMode(): UseAdminModeReturn { return false; }); + const [demoMode, setDemoModeState] = useState(() => { + if (typeof window !== "undefined") { + return resolveInitialDemoMode(); + } + return false; + }); + + // Consume the ?demo= parameter: persist it, then strip it from the address bar so nothing shows + // in a screenshot and a reload keeps the mode. Runs once per page load across all hook instances. + useEffect(() => { + if (demoParamConsumed) return; + demoParamConsumed = true; + + const fromUrl = readDemoParam(window.location.search); + if (fromUrl === null) return; + + // No setState here: every instance's lazy initializer already read the same parameter, and any + // component mounting after the strip reads the value we just persisted. The broadcast covers + // instances that initialized from a stale stored value. + localStorage.setItem(DEMO_MODE_STORAGE_KEY, fromUrl.toString()); + window.dispatchEvent(new Event("localStorageChange")); + + const cleaned = stripDemoParam( + window.location.pathname + window.location.search + window.location.hash + ); + window.history.replaceState(null, "", cleaned); + }, []); + // Persist to localStorage and notify same-tab listeners on change useEffect(() => { localStorage.setItem("adminMode", adminMode.toString()); @@ -50,15 +111,20 @@ export function useAdminMode(): UseAdminModeReturn { if (e.key === "globalAdminMode" && e.newValue !== null) { setGlobalAdminModeState(e.newValue === "true"); } + if (e.key === DEMO_MODE_STORAGE_KEY && e.newValue !== null) { + setDemoModeState(e.newValue === "true"); + } }; const handleCustomStorageChange = () => { const newAdminMode = localStorage.getItem("adminMode") === "true"; const newGlobalMode = localStorage.getItem("globalAdminMode") === "true"; + const newDemoMode = localStorage.getItem(DEMO_MODE_STORAGE_KEY) === "true"; setAdminModeState((prev) => (prev !== newAdminMode ? newAdminMode : prev)); setGlobalAdminModeState((prev) => prev !== newGlobalMode ? newGlobalMode : prev ); + setDemoModeState((prev) => (prev !== newDemoMode ? newDemoMode : prev)); }; window.addEventListener("storage", handleStorageChange); @@ -75,7 +141,10 @@ export function useAdminMode(): UseAdminModeReturn { return { adminMode, setAdminMode: setAdminModeState, - globalAdminMode, - setGlobalAdminMode: setGlobalAdminModeState, + globalAdminMode: effectiveGlobalAdminMode(globalAdminMode, demoMode), + // No-op while presenting. The toggle is hidden in demo mode anyway; this keeps any other + // caller from re-arming the global view behind the operator's back. + setGlobalAdminMode: demoMode ? () => {} : setGlobalAdminModeState, + demoMode, }; } diff --git a/src/Web/autopilot-monitor-web/hooks/useGlobalAdminUi.ts b/src/Web/autopilot-monitor-web/hooks/useGlobalAdminUi.ts new file mode 100644 index 00000000..31dddeff --- /dev/null +++ b/src/Web/autopilot-monitor-web/hooks/useGlobalAdminUi.ts @@ -0,0 +1,24 @@ +"use client"; + +import { useAuth } from "@/contexts/AuthContext"; +import { useAdminMode } from "./useAdminMode"; + +/** + * True when Global-Admin-EXCLUSIVE UI should be visible: the caller is a real Global Admin AND the + * Global-Admin view is switched on (which demo mode forces off — see lib/demoMode.ts). + * + * Use this for operator-only surfaces that sit INSIDE an otherwise tenant-scoped page — the + * platform-bot Telegram provider, the cert-device-binding toggle, the retention escape hatch, the + * backend/portal build blocks. Turning the Global-Admin view off then yields a view that is + * genuinely indistinguishable from a tenant admin's, which is what live demos need. + * + * NOT a permission check. The server enforces every one of these rules on its own + * (TenantConfigValidation, GlobalAdminOnly → 403), and {@link useCanMutatePlatform} stays bound to + * the identity alone — it gates the mutating controls in the cross-tenant /admin area, where a + * view toggle must never decide what may be written. + */ +export function useGlobalAdminUi(): boolean { + const { user } = useAuth(); + const { globalAdminMode } = useAdminMode(); + return user?.isGlobalAdmin === true && globalAdminMode; +} diff --git a/src/Web/autopilot-monitor-web/lib/__tests__/demoMode.test.ts b/src/Web/autopilot-monitor-web/lib/__tests__/demoMode.test.ts new file mode 100644 index 00000000..fa564735 --- /dev/null +++ b/src/Web/autopilot-monitor-web/lib/__tests__/demoMode.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from "vitest"; +import { effectiveGlobalAdminMode, readDemoParam, stripDemoParam } from "../demoMode"; + +describe("readDemoParam", () => { + it("arms on the documented forms", () => { + expect(readDemoParam("?demo=1")).toBe(true); + expect(readDemoParam("demo=1")).toBe(true); + expect(readDemoParam("?demo=true")).toBe(true); + expect(readDemoParam("?demo=TRUE")).toBe(true); + expect(readDemoParam("?demo=on")).toBe(true); + }); + + it("treats a bare ?demo as arming — that is what the shorthand means", () => { + expect(readDemoParam("?demo")).toBe(true); + expect(readDemoParam("?demo=")).toBe(true); + }); + + it("clears on the documented forms", () => { + expect(readDemoParam("?demo=0")).toBe(false); + expect(readDemoParam("?demo=false")).toBe(false); + expect(readDemoParam("?demo=off")).toBe(false); + }); + + it("returns null when absent, so the stored value is left alone", () => { + expect(readDemoParam("")).toBeNull(); + expect(readDemoParam(null)).toBeNull(); + expect(readDemoParam(undefined)).toBeNull(); + expect(readDemoParam("?tenant=abc&tab=notifications")).toBeNull(); + }); + + it("returns null on an unrecognised value — a typo must never drop the operator out mid-demo", () => { + expect(readDemoParam("?demo=yes")).toBeNull(); + expect(readDemoParam("?demo=nope")).toBeNull(); + }); + + it("reads the parameter regardless of position", () => { + expect(readDemoParam("?tenant=abc&demo=1&tab=x")).toBe(true); + }); +}); + +describe("stripDemoParam", () => { + it("drops the query entirely when demo was the only parameter", () => { + expect(stripDemoParam("/dashboard?demo=1")).toBe("/dashboard"); + }); + + it("keeps the other parameters", () => { + expect(stripDemoParam("/settings/tenant?demo=1&tab=notifications")).toBe( + "/settings/tenant?tab=notifications" + ); + expect(stripDemoParam("/settings/tenant?tab=notifications&demo=0")).toBe( + "/settings/tenant?tab=notifications" + ); + }); + + it("preserves the hash", () => { + expect(stripDemoParam("/dashboard?demo=1#top")).toBe("/dashboard#top"); + expect(stripDemoParam("/dashboard?demo=1&a=b#top")).toBe("/dashboard?a=b#top"); + }); + + it("leaves a URL without the parameter untouched", () => { + expect(stripDemoParam("/dashboard")).toBe("/dashboard"); + expect(stripDemoParam("/dashboard?tab=x")).toBe("/dashboard?tab=x"); + expect(stripDemoParam("/dashboard#top")).toBe("/dashboard#top"); + }); + + it("handles a bare ?demo with no value", () => { + expect(stripDemoParam("/dashboard?demo")).toBe("/dashboard"); + }); +}); + +describe("effectiveGlobalAdminMode", () => { + it("passes the stored value through when not presenting", () => { + expect(effectiveGlobalAdminMode(true, false)).toBe(true); + expect(effectiveGlobalAdminMode(false, false)).toBe(false); + }); + + it("forces the global view off while presenting", () => { + expect(effectiveGlobalAdminMode(true, true)).toBe(false); + expect(effectiveGlobalAdminMode(false, true)).toBe(false); + }); +}); diff --git a/src/Web/autopilot-monitor-web/lib/demoMode.ts b/src/Web/autopilot-monitor-web/lib/demoMode.ts new file mode 100644 index 00000000..ff30e05a --- /dev/null +++ b/src/Web/autopilot-monitor-web/lib/demoMode.ts @@ -0,0 +1,79 @@ +/** + * Demo ("presentation") mode: pure logic, extracted from the hook so the whole matrix is testable + * without a DOM (house pattern — see navVisibility / decideHostBounce). + * + * Demo mode makes the portal LOOK like a plain tenant-admin session so the product can be shown on + * stage without leaking operator internals (build/commit hashes, the Global-Admin toggle, the + * Global-Admin badge, platform-only configuration). It is PRESENTATION ONLY — every real gate stays + * on the server (TenantConfigValidation, GlobalAdminOnly → 403). Never treat it as a security + * boundary and never describe it as one. + * + * It is armed via a URL parameter that consumes itself: `?demo=1` writes the flag to localStorage + * and is stripped from the address bar immediately, so nothing remains visible in a screenshot and + * no devtools are needed on stage. `?demo=0` clears it the same way. + */ + +/** localStorage key holding the demo-mode flag ("true" / "false"). */ +export const DEMO_MODE_STORAGE_KEY = "demoMode"; + +/** Query-string parameter that arms or clears demo mode. */ +export const DEMO_MODE_PARAM = "demo"; + +/** + * Reads the demo-mode intent from a query string. Returns true (arm), false (clear), or null when + * the parameter is absent or unrecognised — null means "leave the stored value alone", so a typo + * (`?demo=yes`) never silently drops the operator out of demo mode mid-presentation. + * + * @param search a location.search value, with or without the leading "?". + */ +export function readDemoParam(search: string | null | undefined): boolean | null { + if (!search) return null; + + let params: URLSearchParams; + try { + params = new URLSearchParams(search.startsWith("?") ? search.slice(1) : search); + } catch { + return null; + } + + const raw = params.get(DEMO_MODE_PARAM); + if (raw === null) return null; + + const value = raw.trim().toLowerCase(); + // A bare "?demo" (no value) reads as an empty string — treat it as "arm", which is what someone + // typing the shorthand means. + if (value === "" || value === "1" || value === "true" || value === "on") return true; + if (value === "0" || value === "false" || value === "off") return false; + return null; +} + +/** + * Removes the demo parameter from a path+query+hash string, leaving everything else untouched. + * Drops the "?" entirely when it was the only parameter, so the address bar shows a clean URL. + * + * @param url a path with optional query and hash, e.g. "/settings/tenant?demo=1&tab=x#top". + */ +export function stripDemoParam(url: string): string { + const hashAt = url.indexOf("#"); + const hash = hashAt >= 0 ? url.slice(hashAt) : ""; + const withoutHash = hashAt >= 0 ? url.slice(0, hashAt) : url; + + const queryAt = withoutHash.indexOf("?"); + if (queryAt < 0) return url; + + const path = withoutHash.slice(0, queryAt); + const params = new URLSearchParams(withoutHash.slice(queryAt + 1)); + if (!params.has(DEMO_MODE_PARAM)) return url; + + params.delete(DEMO_MODE_PARAM); + const rest = params.toString(); + return `${path}${rest ? `?${rest}` : ""}${hash}`; +} + +/** + * The Global-Admin view flag as the UI should see it. Demo mode forces it off WITHOUT touching the + * stored value, so the operator's usual setting comes back the moment demo mode is cleared. + */ +export function effectiveGlobalAdminMode(stored: boolean, demoMode: boolean): boolean { + return stored && !demoMode; +} diff --git a/src/Web/autopilot-monitor-web/utils/page-lastmod.generated.ts b/src/Web/autopilot-monitor-web/utils/page-lastmod.generated.ts index 5a15e935..4fb2c61b 100644 --- a/src/Web/autopilot-monitor-web/utils/page-lastmod.generated.ts +++ b/src/Web/autopilot-monitor-web/utils/page-lastmod.generated.ts @@ -4,11 +4,14 @@ */ export const PAGE_LASTMOD: Record = { "/": "2026-07-30T22:21:18+02:00", - "/about": "2026-08-18T15:47:15+02:00", + "/about": "2026-08-23T02:47:52+02:00", + "/buy": "2026-08-31T18:15:28+02:00", + "/help": "2026-08-22T18:12:09+02:00", + "/plans": "2026-08-22T19:46:44+02:00", "/roadmap": "2026-07-06T18:31:00+02:00", "/changelog": "2026-07-06T18:12:31+02:00", - "/privacy": "2026-08-11T13:30:44+02:00", - "/terms": "2026-07-31T14:30:57+02:00", + "/privacy": "2026-08-29T11:07:31+02:00", + "/terms": "2026-08-29T11:07:31+02:00", "/docs": "2026-07-06T18:12:31+02:00", "/docs/private-preview": "2026-07-06T18:12:31+02:00", "/docs/overview": "2026-07-06T18:12:31+02:00",