mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 04:32:32 +00:00
settings: admit Operators and the GlobalReader read-only (user decision)
The /settings layout now admits any own-tenant role or platform scope via hasOwnTenantOrPlatformRole — the same set the sidebar's Configuration visibility uses, closing the "sidebar shows it, guard bounces it" mismatch the nav matrix test had ratcheted. Read-only stays enforced by the data layer: MemberRead serves non-admins redacted config, canEditConfig is false, and every write path (PUT + field PATCH) is TenantAdminOrGA. This also realigns the code with the customer docs, which already documented Operators/Viewers as read-only settings viewers. The guard-consistency ratchet is now empty and may only stay empty. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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 (
|
||||
<ProtectedRoute>
|
||||
|
||||
@@ -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<string>();
|
||||
|
||||
const guardFor = (href: string) =>
|
||||
ROUTE_GUARDS.filter((g) => href === g.prefix || href.startsWith(g.prefix + "/"))
|
||||
|
||||
Reference in New Issue
Block a user