diff --git a/src/Web/autopilot-monitor-web/app/settings/layout.tsx b/src/Web/autopilot-monitor-web/app/settings/layout.tsx index 99e5ec49..93bd90bd 100644 --- a/src/Web/autopilot-monitor-web/app/settings/layout.tsx +++ b/src/Web/autopilot-monitor-web/app/settings/layout.tsx @@ -4,33 +4,31 @@ import { useRouter } from "next/navigation"; import { useEffect } from "react"; import { ProtectedRoute } from "../../components/ProtectedRoute"; import { useAuth } from "../../contexts/AuthContext"; +import { hasOwnTenantOrPlatformRole } from "@/lib/tenantScope"; import { TenantConfigProvider } from "./TenantConfigContext"; export default function SettingsLayout({ children }: { children: React.ReactNode }) { const { user } = useAuth(); const router = useRouter(); + // Admission mirrors the sidebar's Configuration visibility (user decision 2026-08-13): + // any own-tenant role (Admin, Operator, Viewer) or platform scope (GlobalAdmin, + // read-only GlobalReader) may VIEW settings. Read-only is enforced by the data layer, + // not by this gate — the backend serves non-admins redacted config via the MemberRead + // tier, canEditConfig stays false for them, and every write path (PUT + field PATCH) + // is TenantAdminOrGA server-side. Delegated (MSP) callers without an own-tenant role + // stay excluded: settings operates on the caller's OWN tenant, which they don't have. + const admitted = hasOwnTenantOrPlatformRole(user); + useEffect(() => { if (!user) return; - - // Regular users (no tenant role) → redirect to progress portal. Viewers are admitted - // read-only: the backend serves them redacted config via the MemberRead tier and - // canEditConfig stays false, so the sections render without mutation affordances. - if (!user.isTenantAdmin && !user.isGlobalAdmin && user.role !== "Operator" && user.role !== "Viewer") { + if (!admitted) { router.replace("/progress"); - return; } - - // Operator without bootstrap permission → no settings access - if (user.role === "Operator" && !user.isTenantAdmin && !user.isGlobalAdmin && !user.canManageBootstrapTokens) { - router.replace("/dashboard"); - } - }, [user, router]); + }, [user, admitted, router]); // Don't render until we know user is allowed - if (!user) return null; - if (!user.isTenantAdmin && !user.isGlobalAdmin && user.role !== "Operator" && user.role !== "Viewer") return null; - if (user.role === "Operator" && !user.isTenantAdmin && !user.isGlobalAdmin && !user.canManageBootstrapTokens) return null; + if (!user || !admitted) return null; return ( diff --git a/src/Web/autopilot-monitor-web/lib/__tests__/navVisibility.test.ts b/src/Web/autopilot-monitor-web/lib/__tests__/navVisibility.test.ts index 56d1b68e..854fc989 100644 --- a/src/Web/autopilot-monitor-web/lib/__tests__/navVisibility.test.ts +++ b/src/Web/autopilot-monitor-web/lib/__tests__/navVisibility.test.ts @@ -234,8 +234,9 @@ describe("nav ↔ route-guard consistency", () => { * layouts (settings/ops/backups/customs-archive/software) → requireGlobalAdmin, * - app/sessions/inspector/layout.tsx → requireGlobalAdmin, * - app/fleet/layout.tsx → requireFleetScope, - * - app/settings/layout.tsx → rule A (admin/GA/Operator/Viewer) minus rule B - * (Operator without admin standing needs canManageBootstrapTokens). + * - app/settings/layout.tsx → any own-tenant role or platform scope + * (hasOwnTenantOrPlatformRole; user decision 2026-08-13 — mirrors the sidebar's + * Configuration visibility, read-only enforced by the data layer). * Longest prefix wins. Routes without a declared guard have no layout gate. */ const ROUTE_GUARDS: Array<{ prefix: string; admits: (input: NavVisibilityInput, flags: NavFlags) => boolean }> = [ @@ -249,25 +250,16 @@ describe("nav ↔ route-guard consistency", () => { { prefix: "/fleet", admits: (_i, f) => f.hasFleetScope }, { prefix: "/settings", - admits: (i, f) => { - const role = i.user?.role ?? null; - const admitted = f.isAdminLike || role === "Operator" || role === "Viewer"; - const operatorBounced = role === "Operator" && !f.isAdminLike && !(i.user?.canManageBootstrapTokens ?? false); - return admitted && !operatorBounced; - }, + admits: (_i, f) => f.isTenantMember || f.hasGlobalScope, }, ]; /** - * Ratchet of KNOWN sidebar-shows-it/guard-bounces-it mismatches (pre-existing, user - * decision pending — see tasks/todo.md P7.4): a plain Operator and a read-only - * GlobalReader both see Configuration entries whose /settings layout bounces them. - * Fixing a guard requires deleting its entry here; any NEW mismatch fails the test. + * Ratchet of KNOWN sidebar-shows-it/guard-bounces-it mismatches. Empty since the + * 2026-08-13 user decision opened /settings to Operators and the GlobalReader + * (read-only). Any NEW mismatch fails the test; entries may only ever be removed. */ - const KNOWN_MISMATCHES = new Set([ - "Operator × /settings", - "GlobalReader+adminModeOn × /settings", - ]); + const KNOWN_MISMATCHES = new Set(); const guardFor = (href: string) => ROUTE_GUARDS.filter((g) => href === g.prefix || href.startsWith(g.prefix + "/"))