diff --git a/docs/log.md b/docs/log.md index a40a1a58..cba4e4a9 100644 --- a/docs/log.md +++ b/docs/log.md @@ -1,5 +1,10 @@ # Log +## 2026-09-01 (6) + +* **Update**: `web/demo-presentation-mode.md` - two more operator-only surfaces follow the Global-Admin view. The "Internal (Autopilot Monitor)" annotation lane was gated on the identity alone: `visibleLanes()` now takes the view flag as an explicit second argument and combines it with the platform identity, so the rule also covers the read-only Global Reader; the `/annotations` overview additionally filters the rendered rows, because that list is not lane-scoped by the request and an internal row would otherwise surface there anyway. The Decision Inspector button on the session detail page moved to `useGlobalAdminUi()` in the same pass. The IME log pattern controls needed no change - `canEdit` already read `isGlobalAdmin && globalAdminMode`, so demo mode reaches them through the effective flag. Also fixed the retention-override banner: the bare warning glyph inherited the base 16px size against 12px text and sat below the first line, replaced by a `w-4 h-4` SVG whose box matches the `text-xs` line box exactly. + + ## 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. diff --git a/docs/web/demo-presentation-mode.md b/docs/web/demo-presentation-mode.md index ec1ed207..88aa8ce6 100644 --- a/docs/web/demo-presentation-mode.md +++ b/docs/web/demo-presentation-mode.md @@ -44,12 +44,17 @@ The stored `globalAdminMode` value is never overwritten — demo mode masks it a | 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 | +| Decision Inspector button on the session detail page | hidden | hidden | +| "Internal (Autopilot Monitor)" annotation lane (session card + `/annotations` list and filter) | hidden | hidden | +| IME log pattern enable/disable switch, "Edit (Global)" button, edit form | already followed the view before this change | read-only, as for a tenant admin | | `/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 | +The annotation lane is the one surface where the rule also covers the read-only **Global Reader**: `visibleLanes()` takes the view flag as an explicit second argument and combines it with the platform identity, so a Reader with the view switched off sees the same two lanes a tenant admin sees. The `/annotations` overview additionally filters the rendered ROWS to the visible lanes — the list is not lane-scoped by the request, so an internal row would otherwise still surface there. + 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 @@ -62,4 +67,5 @@ Both levels are presentation. Every rule they mirror is enforced server-side and - `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 +- `src/Web/autopilot-monitor-web/app/sessions/components/sessionAnnotationLogic.ts` — `visibleLanes(user, globalAdminView)`, covered by its `__tests__` sibling - [portal-navigation-prefetch.md](portal-navigation-prefetch.md) — the other portal-wide behaviour switch diff --git a/src/Web/autopilot-monitor-web/app/annotations/page.tsx b/src/Web/autopilot-monitor-web/app/annotations/page.tsx index 24293a6e..a86a01e5 100644 --- a/src/Web/autopilot-monitor-web/app/annotations/page.tsx +++ b/src/Web/autopilot-monitor-web/app/annotations/page.tsx @@ -8,7 +8,7 @@ import { API_BASE_URL } from "@/utils/config"; import { authenticatedFetch } from "@/lib/authenticatedFetch"; import { ProtectedRoute } from "@/components/ProtectedRoute"; import { useAuth } from "@/contexts/AuthContext"; -import { useAggregatedAdminScope } from "@/hooks"; +import { useAdminMode, useAggregatedAdminScope } from "@/hooks"; import { TenantScopeSelector } from "@/components/TenantScopeSelector"; import { GlobalAdminBanner, globalAdminSubtitle } from "@/components/GlobalAdminBanner"; import { @@ -65,7 +65,12 @@ export default function AnnotationsPage() { const scope = useAggregatedAdminScope({ defaultAggregated: true }); const { isGlobalAdmin: crossTenant, routeGlobal, selectedTenantId, effectiveTenantId, scopeInitialized, tenants } = scope; - const lanes = visibleLanes(user); + // Effective Global-Admin view (demo mode forces it off): the platform-internal lane follows the + // view both in the filter dropdown and in the list, so presenting this page shows exactly the + // rows a tenant admin would see. The backend filters the lane for tenant callers regardless. + const { globalAdminMode } = useAdminMode(); + const lanes = visibleLanes(user, globalAdminMode); + const visibleRows = rows.filter((r) => lanes.includes(r.lane?.toLowerCase() as AnnotationLane)); const tenantLabel = (id: string | null | undefined) => tenants.find((t) => t.tenantId === id)?.domainName ?? id ?? "—"; @@ -166,7 +171,7 @@ export default function AnnotationsPage() { {loadError ? (

{loadError}

- ) : rows.length === 0 && !loading ? ( + ) : visibleRows.length === 0 && !loading ? (

No annotations yet. Open a session and record a verdict in its{" "} Annotations section — judged sessions @@ -187,7 +192,7 @@ export default function AnnotationsPage() { - {rows.map((row) => ( + {visibleRows.map((row) => ( {crossTenant && ( diff --git a/src/Web/autopilot-monitor-web/app/sessions/components/SessionAnnotationsCard.tsx b/src/Web/autopilot-monitor-web/app/sessions/components/SessionAnnotationsCard.tsx index 32932434..361268ff 100644 --- a/src/Web/autopilot-monitor-web/app/sessions/components/SessionAnnotationsCard.tsx +++ b/src/Web/autopilot-monitor-web/app/sessions/components/SessionAnnotationsCard.tsx @@ -3,6 +3,7 @@ import { useCallback, useEffect, useState } from "react"; import { api } from "@/lib/api"; import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch"; +import { useAdminMode } from "@/hooks/useAdminMode"; import { ANNOTATION_MAX_NOTE_LENGTH, ANNOTATION_VERDICTS, @@ -67,7 +68,10 @@ export default function SessionAnnotationsCard({ // (count + verdict pills) still shows at a glance whether a verdict exists. const [expanded, setExpanded] = useState(false); - const lanes = visibleLanes(user); + // Effective Global-Admin view (demo mode forces it off): the internal lane follows the view, so + // presenting the session page shows the same two lanes a tenant admin sees. + const { globalAdminMode } = useAdminMode(); + const lanes = visibleLanes(user, globalAdminMode); useEffect(() => { if (!sessionId) return; diff --git a/src/Web/autopilot-monitor-web/app/sessions/components/__tests__/sessionAnnotationLogic.test.ts b/src/Web/autopilot-monitor-web/app/sessions/components/__tests__/sessionAnnotationLogic.test.ts index 6060704f..d298e17c 100644 --- a/src/Web/autopilot-monitor-web/app/sessions/components/__tests__/sessionAnnotationLogic.test.ts +++ b/src/Web/autopilot-monitor-web/app/sessions/components/__tests__/sessionAnnotationLogic.test.ts @@ -16,20 +16,26 @@ const operator: AnnotationUser = { role: "Operator" }; const viewer: AnnotationUser = { role: "Viewer" }; describe("visibleLanes", () => { - it("shows all three lanes for global scope (GA and Global Reader)", () => { - expect(visibleLanes(ga)).toEqual(["operator", "tenantadmin", "globaladmin"]); - expect(visibleLanes(globalReader)).toEqual(["operator", "tenantadmin", "globaladmin"]); + it("shows all three lanes for global scope (GA and Global Reader) with the global view on", () => { + expect(visibleLanes(ga, true)).toEqual(["operator", "tenantadmin", "globaladmin"]); + expect(visibleLanes(globalReader, true)).toEqual(["operator", "tenantadmin", "globaladmin"]); }); - it("hides the globaladmin lane from tenant members", () => { + it("hides the internal lane when the global view is off (demo mode forces this)", () => { + expect(visibleLanes(ga, false)).toEqual(["operator", "tenantadmin"]); + expect(visibleLanes(globalReader, false)).toEqual(["operator", "tenantadmin"]); + }); + + it("hides the globaladmin lane from tenant members regardless of the view flag", () => { for (const user of [tenantAdmin, operator, viewer]) { - expect(visibleLanes(user)).toEqual(["operator", "tenantadmin"]); + expect(visibleLanes(user, true)).toEqual(["operator", "tenantadmin"]); + expect(visibleLanes(user, false)).toEqual(["operator", "tenantadmin"]); } }); it("treats a missing user as tenant scope", () => { - expect(visibleLanes(null)).toEqual(["operator", "tenantadmin"]); - expect(visibleLanes(undefined)).toEqual(["operator", "tenantadmin"]); + expect(visibleLanes(null, true)).toEqual(["operator", "tenantadmin"]); + expect(visibleLanes(undefined, true)).toEqual(["operator", "tenantadmin"]); }); }); diff --git a/src/Web/autopilot-monitor-web/app/sessions/components/sessionAnnotationLogic.ts b/src/Web/autopilot-monitor-web/app/sessions/components/sessionAnnotationLogic.ts index 53a50275..8444e5ea 100644 --- a/src/Web/autopilot-monitor-web/app/sessions/components/sessionAnnotationLogic.ts +++ b/src/Web/autopilot-monitor-web/app/sessions/components/sessionAnnotationLogic.ts @@ -81,10 +81,19 @@ export const LANE_LABELS: Record = { * Lanes the caller may SEE. The backend already filters the globaladmin lane for * non-global callers — this only decides which (possibly empty) lane sections the * card renders at all. + * + * @param globalAdminView the Global-Admin view toggle (which demo mode forces off — see + * lib/demoMode.ts). The internal lane is labelled "never visible to the tenant" and sits on the + * session page shown in every live demo, so it follows the VIEW, not the identity alone: with the + * global view switched off a platform user sees the same two lanes a tenant admin sees. Not a + * permission check — the backend filters the lane for tenant callers regardless. */ -export function visibleLanes(user: AnnotationUser | null | undefined): AnnotationLane[] { +export function visibleLanes( + user: AnnotationUser | null | undefined, + globalAdminView: boolean, +): AnnotationLane[] { const hasGlobalScope = !!user?.isGlobalAdmin || !!user?.isGlobalReader; - return hasGlobalScope + return hasGlobalScope && globalAdminView ? ["operator", "tenantadmin", "globaladmin"] : ["operator", "tenantadmin"]; } diff --git a/src/Web/autopilot-monitor-web/app/settings/components/DataManagementSection.tsx b/src/Web/autopilot-monitor-web/app/settings/components/DataManagementSection.tsx index bffd075e..0078c539 100644 --- a/src/Web/autopilot-monitor-web/app/settings/components/DataManagementSection.tsx +++ b/src/Web/autopilot-monitor-web/app/settings/components/DataManagementSection.tsx @@ -71,7 +71,19 @@ export default function DataManagementSection({ /> {isRetentionDisabled ? (

- ⚠ + {/* w-4/h-4 matches the text-xs line box exactly, so items-start aligns the icon + with the first line without a hand-tuned margin (the bare glyph inherited the + base 16px size against 12px text and sat too low). */} +

Overridden by administrator —{" "} {dataRetentionDays === 0