From 8a3b4136a6995d43b3b63eb33647b242093c7e46 Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Tue, 1 Sep 2026 20:43:58 +0200 Subject: [PATCH] feat(web): internal annotation lane follows the Global-Admin view The "Internal (Autopilot Monitor)" lane is labelled "never visible to the tenant" and sits on the session page shown in every live demo, but it was gated on the platform identity alone and stayed on screen when the Global-Admin view was switched off. visibleLanes() now takes the view flag as an explicit second argument and combines it with the identity, so the rule also covers the read-only Global Reader. The /annotations overview additionally filters the rendered ROWS to the visible lanes: that list is not lane-scoped by the request, so an internal row would otherwise surface there even with the lane dropped from the filter dropdown. Not a permission change -- the backend filters the lane for tenant callers regardless (GetSessionAnnotationsFunction.FilterLanesForCaller) and owns the write matrix. Also fixes the retention-override banner: the bare warning glyph inherited the base 16px size against 12px text and sat below the first line, with an mt-0.5 hand-nudge that could not hold it. Replaced by a w-4 h-4 SVG whose box matches the text-xs line box exactly, so items-start aligns it with no margin -- and it now matches the icon style of every other alert box. The IME log pattern controls needed no change: canEdit already reads isGlobalAdmin && globalAdminMode, so demo mode reaches them through the effective flag. Co-Authored-By: Claude Opus 5 (1M context) --- docs/log.md | 5 +++++ docs/web/demo-presentation-mode.md | 6 ++++++ .../app/annotations/page.tsx | 13 ++++++++---- .../components/SessionAnnotationsCard.tsx | 6 +++++- .../__tests__/sessionAnnotationLogic.test.ts | 20 ++++++++++++------- .../components/sessionAnnotationLogic.ts | 13 ++++++++++-- .../components/DataManagementSection.tsx | 14 ++++++++++++- 7 files changed, 62 insertions(+), 15 deletions(-) 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