From 9ec8644be25b283dc342a4de1b91118783a251ba Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Fri, 24 Jul 2026 18:40:27 +0200 Subject: [PATCH] feat(settings): operator read-only settings view with real values Operators previously saw editable settings forms bound to hardcoded defaults whose Save always 403'd. Now they see the tenant's real configuration read-only, with no save affordances at all. Backend: - GET config/{tenantId}: TenantAdminOrGlobalReader -> MemberRead. Safe because the handler redacts by default (CanViewSecrets: only GA or own-tenant Admin see secrets); Operators/Viewers receive RedactedCopyForReader(). - POST diagnostics/files: TenantAdminOrGA -> TenantAdminOrOperator (submitting logs is a troubleshooting action; Viewer and read-only GlobalReader excluded). - Tests: policy pins updated; new middleware tests (own-tenant Operator allowed + redacted, cross-tenant Operator 403); CanViewSecrets theory for Operator/Viewer. Web: - TenantConfigContext loads the full config for all settings users (403 fallback to feature-flags minimal config for deploy-order safety) and exposes canEditConfig; saveConfiguration guards on it. - New ReadOnlyFieldset wraps section content (native fieldset disable, values stay copyable); SaveResetBar is not rendered at all for read-only viewers. Applied to Contact, SLA Targets, Hardware Whitelist (+ rejection-insights toggle), Notifications, Agent Settings, Agent Analyzers, Diagnostics, Data Management. - Hidden entirely from Operators (in-page admin-only card + sidebar sub-item gate): Autopilot Validation, Access Management, Offboarding, Unrestricted Mode. GlobalReader sidebar parity kept via hasGlobalScope. Tests: backend 3257/3257, web vitest 627/627, tsc clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ConfigRedactionTests.cs | 9 +++ .../EndpointPolicyCatalogCompletenessTests.cs | 8 +-- .../PolicyEnforcementMiddlewareTests.cs | 37 ++++++++++++- .../Config/GetTenantConfigurationFunction.cs | 13 +++-- .../Reports/SubmitDiagFilesReportFunction.cs | 6 +- .../Security/EndpointAccessPolicyCatalog.cs | 20 +++++-- .../app/settings/TenantConfigContext.tsx | 55 ++++++++++++------- .../agent/sections/SectionAgentAnalyzers.tsx | 2 + .../agent/sections/SectionAgentSettings.tsx | 2 + .../agent/sections/SectionDiagnostics.tsx | 2 + .../sections/SectionUnrestrictedMode.tsx | 12 +++- .../components/AgentAnalyzersSection.tsx | 11 +++- .../components/AgentSettingsSection.tsx | 14 ++++- .../components/DataManagementSection.tsx | 11 +++- .../components/DiagnosticsSection.tsx | 11 +++- .../components/HardwareRejectionInsights.tsx | 11 +++- .../components/HardwareWhitelistSection.tsx | 11 +++- .../components/NotificationsSection.tsx | 10 +++- .../settings/components/ReadOnlyFieldset.tsx | 31 +++++++++++ .../sections/SectionDataManagement.tsx | 2 + .../sections/SectionOffboarding.tsx | 10 ++++ .../sections/SectionAccessManagement.tsx | 12 ++++ .../sections/SectionAutopilotValidation.tsx | 11 ++++ .../tenant/sections/SectionContact.tsx | 22 +++++--- .../sections/SectionHardwareWhitelist.tsx | 7 ++- .../tenant/sections/SectionNotifications.tsx | 2 + .../tenant/sections/SectionSlaTargets.tsx | 39 ++++++++----- .../components/GlobalSidebar.tsx | 6 ++ 28 files changed, 314 insertions(+), 73 deletions(-) create mode 100644 src/Web/autopilot-monitor-web/app/settings/components/ReadOnlyFieldset.tsx diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/ConfigRedactionTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/ConfigRedactionTests.cs index e67c4bcb..1a94bade 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/ConfigRedactionTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/ConfigRedactionTests.cs @@ -183,4 +183,13 @@ public class ConfigRedactionTests public void CanViewSecrets_PureDelegatedReader_False() => Assert.False(GetTenantConfigurationFunction.CanViewSecrets( new RequestContext { IsDelegatedReader = true, TenantId = TenantA, TargetTenantId = TenantB })); + + [Theory] + [InlineData(Constants.TenantRoles.Operator)] + [InlineData(Constants.TenantRoles.Viewer)] + public void CanViewSecrets_OwnTenantOperatorOrViewer_False(string role) + // Member-tier read-only Settings view (config GET is MemberRead): an own-tenant + // Operator/Viewer arrives with IsTenantAdmin=false ⇒ always the redacted copy. + => Assert.False(GetTenantConfigurationFunction.CanViewSecrets( + new RequestContext { UserRole = role, TenantId = TenantA, TargetTenantId = TenantA })); } diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/EndpointPolicyCatalogCompletenessTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/EndpointPolicyCatalogCompletenessTests.cs index 9f80249b..0f18913e 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/EndpointPolicyCatalogCompletenessTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/EndpointPolicyCatalogCompletenessTests.cs @@ -160,9 +160,9 @@ public class EndpointPolicyCatalogCompletenessTests Assert.NotNull(getConfig); Assert.NotNull(putConfig); - // GET config read is admin-tier read (own-tenant Admin / GA / read-only GlobalReader); - // PUT config write stays Admin/GA only. - Assert.Equal(EndpointPolicy.TenantAdminOrGlobalReader, getConfig.Policy); + // GET config read is member-tier (Operators/Viewers get the redacted read-only Settings + // view; the handler redacts for every non-admin caller); PUT config write stays Admin/GA only. + Assert.Equal(EndpointPolicy.MemberRead, getConfig.Policy); Assert.Equal(EndpointPolicy.TenantAdminOrGA, putConfig.Policy); var getRules = EndpointAccessPolicyCatalog.FindPolicy("GET", "/api/rules/gather"); @@ -338,7 +338,7 @@ public class EndpointPolicyCatalogCompletenessTests [InlineData("POST", "/api/agent/telemetry", true)] // DeviceOrBootstrapAuth [InlineData("GET", "/api/bootstrap/validate/ABC123", true)] // PublicAnonymous (param route) [InlineData("GET", "/api/sessions", false)] // MemberRead - [InlineData("GET", "/api/config/00000000-0000-0000-0000-000000000001", false)] // TenantAdminOrGlobalReader + [InlineData("GET", "/api/config/00000000-0000-0000-0000-000000000001", false)] // MemberRead [InlineData("GET", "/api/global/sessions", false)] // GlobalReadOrAdmin [InlineData("GET", "/api/nonexistent", false)] // unregistered → fail-closed public void SkipsJwtValidation_MatchesPolicyTier(string method, string path, bool expectedExempt) diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/PolicyEnforcementMiddlewareTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/PolicyEnforcementMiddlewareTests.cs index c7c22c78..2644f2f5 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/PolicyEnforcementMiddlewareTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/PolicyEnforcementMiddlewareTests.cs @@ -1,4 +1,5 @@ using System.Security.Claims; +using AutopilotMonitor.Functions.Functions.Config; using AutopilotMonitor.Functions.Middleware; using AutopilotMonitor.Functions.Security; using AutopilotMonitor.Functions.Services; @@ -298,8 +299,8 @@ public class PolicyEnforcementMiddlewareTests Assert.Equal(403, result.StatusCode); } - // ── config GET (TenantAdminOrGlobalReader): own-tenant admin view vs reader view ── - // GetTenantConfigurationFunction redacts only when IsGlobalReader && !(IsTenantAdmin && Target==Tenant). + // ── config GET (MemberRead): own-tenant admin view vs redacted reader/member view ── + // GetTenantConfigurationFunction redacts unless GA or own-tenant Admin (CanViewSecrets). // These lock in the RequestContext that drives that decision. [Fact] @@ -336,6 +337,38 @@ public class PolicyEnforcementMiddlewareTests Assert.Equal(TenantB, rc.TargetTenantId); } + [Fact] + public async Task ConfigGet_OwnTenantOperator_IsAllowed_RedactedMemberView() + { + // Read-only Settings view for troubleshooting staff: an own-tenant Operator is admitted + // (MemberRead) but arrives with IsTenantAdmin=false, so CanViewSecrets ⇒ redacted copy. + const string upn = "operator@contoso.com"; + var h = BuildHarness(); + AsTenantMemberRole(h, TenantA, upn, Constants.TenantRoles.Operator); + + var result = await h.Middleware.DecideAsync("GET", $"/api/config/{TenantA}", null, AuthedPrincipal(TenantA, upn)); + + Assert.True(result.Allowed); + var rc = result.Context!; + Assert.False(rc.IsTenantAdmin); + Assert.False(GetTenantConfigurationFunction.CanViewSecrets(rc)); + } + + [Fact] + public async Task ConfigGet_CrossTenantOperator_IsForbidden() + { + // Membership is resolved against the caller's JWT tenant — an Operator of A naming + // tenant B in the route is a cross-tenant read and must stay 403. + const string upn = "operator@contoso.com"; + var h = BuildHarness(); + AsTenantMemberRole(h, TenantA, upn, Constants.TenantRoles.Operator); + + var result = await h.Middleware.DecideAsync("GET", $"/api/config/{TenantB}", null, AuthedPrincipal(TenantA, upn)); + + Assert.False(result.Allowed); + Assert.Equal(403, result.StatusCode); + } + // ── Cross-tenant read: GlobalReader crosses tenants (HasGlobalScope gate) ─────── [Fact] diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetTenantConfigurationFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetTenantConfigurationFunction.cs index 52d54853..e9fb2289 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetTenantConfigurationFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetTenantConfigurationFunction.cs @@ -29,7 +29,8 @@ namespace AutopilotMonitor.Functions.Functions.Config { try { - // Authentication + TenantAdminOrGA authorization enforced by PolicyEnforcementMiddleware + // Authentication + MemberRead authorization enforced by PolicyEnforcementMiddleware — + // Operators/Viewers are admitted for a READ-ONLY view (secrets redacted below). var requestCtx = req.GetRequestContext(); var userIdentifier = requestCtx.UserPrincipalName; @@ -39,8 +40,9 @@ namespace AutopilotMonitor.Functions.Functions.Config // Only a caller with WRITE authority over THIS target tenant sees the full config including // DiagnosticsBlobSasUrl / webhook URLs / custom headers (they manage those in the Settings - // UI). Everyone else admitted to this read tier — a cross-tenant Global Reader, or a - // delegated reader viewing one of their managed tenants — gets the secrets redacted (returns + // UI). Everyone else admitted to this read tier — an own-tenant Operator/Viewer, a + // cross-tenant Global Reader, or a delegated reader viewing one of their managed + // tenants — gets the secrets redacted (returns // a copy; the cached instance is never mutated). Redact-by-default: only Global Admin (any // tenant) or the tenant's OWN admin clears secrets. The own-tenant check (target == JWT // tenant) is what keeps a delegated reader — whose target is a DIFFERENT tenant — redacted @@ -66,8 +68,9 @@ namespace AutopilotMonitor.Functions.Functions.Config /// /// Redact-by-default secret clearance for the config GET. Only a caller with WRITE authority over /// THIS target tenant sees the unredacted secrets (SAS URLs, webhook URLs, custom headers): a Global - /// Admin (any tenant), or the tenant's OWN admin. Everyone else admitted to this read tier — a - /// cross-tenant Global Reader, or a delegated reader viewing one of their managed tenants — is + /// Admin (any tenant), or the tenant's OWN admin. Everyone else admitted to this read tier — an + /// own-tenant Operator/Viewer (read-only Settings view), a cross-tenant Global Reader, or a + /// delegated reader viewing one of their managed tenants — is /// redacted. The own-tenant check (target == JWT tenant) is what keeps a delegated reader redacted /// even when they are an admin of their own home tenant, since their target is a DIFFERENT tenant. /// Extracted as a pure static so the security decision is unit-testable without an HttpRequestData mock. diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Reports/SubmitDiagFilesReportFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Reports/SubmitDiagFilesReportFunction.cs index c190be15..b7f179e3 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Reports/SubmitDiagFilesReportFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Reports/SubmitDiagFilesReportFunction.cs @@ -10,8 +10,8 @@ using Microsoft.Extensions.Logging; namespace AutopilotMonitor.Functions.Functions.Reports { /// - /// Tenant-admin entry point for submitting diagnostic files (logs, JSON state files, - /// screenshots) without a session context. Lands in the same SessionReports table + + /// Tenant-admin/-operator entry point for submitting diagnostic files (logs, JSON state + /// files, screenshots) without a session context. Lands in the same SessionReports table + /// session-reports blob container as session reports, distinguished by /// = "diagFiles". /// @@ -45,7 +45,7 @@ namespace AutopilotMonitor.Functions.Functions.Reports try { - // Authentication + TenantAdminOrGA authorization enforced by PolicyEnforcementMiddleware + // Authentication + TenantAdminOrOperator authorization enforced by PolicyEnforcementMiddleware var requestCtx = req.GetRequestContext(); var tenantId = requestCtx.TenantId; var userIdentifier = requestCtx.UserPrincipalName; diff --git a/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs b/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs index 415d3aec..90df5f3c 100644 --- a/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs +++ b/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs @@ -66,9 +66,11 @@ public enum EndpointPolicy /// /// Own-tenant Admin, OR a platform role (Global Admin / Global Reader). Tenant-scoped admin-tier - /// READ access (e.g. GET config/{tenantId}). Like but additionally - /// admits the read-only Global Reader — used only on GET routes so the reader gets visibility - /// without write power, while tenant Operators/Viewers still cannot read admin-tier config. + /// READ access (e.g. GET tenants/{tenantId}/graph-permissions/status). Like + /// but additionally admits the read-only Global Reader — used only + /// on GET routes so the reader gets visibility without write power, while tenant + /// Operators/Viewers stay excluded. (GET config/{tenantId} moved OFF this tier to MemberRead: + /// its handler redacts secrets for every non-admin caller, so member-tier read is safe there.) /// TenantAdminOrGlobalReader, @@ -247,7 +249,12 @@ public static class EndpointAccessPolicyCatalog new("GET", "rules/gather", EndpointPolicy.MemberRead), new("GET", "rules/analyze", EndpointPolicy.MemberRead), new("GET", "rules/ime-log-patterns", EndpointPolicy.MemberRead), - new("GET", "config/{tenantId}", EndpointPolicy.TenantAdminOrGlobalReader, TenantScoping.RouteParam), + // Member tier so Operators/Viewers can see their tenant's configuration read-only in the + // Settings UI. Safe because the handler redacts by default: only a Global Admin or the + // tenant's OWN admin gets the unredacted secrets — every other admitted caller (Operator, + // Viewer, read-only Global Reader, delegated reader) receives RedactedCopyForReader() + // (see GetTenantConfigurationFunction.CanViewSecrets). Writes stay TenantAdminOrGA below. + new("GET", "config/{tenantId}", EndpointPolicy.MemberRead, TenantScoping.RouteParam), new("GET", "config/{tenantId}/feature-flags", EndpointPolicy.MemberRead, TenantScoping.RouteParam), // ── TenantAdminOrGA ───────────────────────────────────────────── @@ -268,7 +275,10 @@ public static class EndpointAccessPolicyCatalog // rotate_config) on RequestContext.IsTenantAdmin || IsGlobalAdmin. new("POST", "sessions/{sessionId}/actions", EndpointPolicy.TenantAdminOrOperator), new("POST", "sessions/{sessionId}/report", EndpointPolicy.TenantAdminOrGA), - new("POST", "diagnostics/files", EndpointPolicy.TenantAdminOrGA), + // Operator tier: submitting diagnostic files to the Autopilot Monitor team is a + // troubleshooting action, not a config change — a support-driven Operator may use it. + // Viewer and the read-only Global Reader stay excluded (the tier admits no Viewer/Reader). + new("POST", "diagnostics/files", EndpointPolicy.TenantAdminOrOperator), new("DELETE", "sessions/{sessionId}", EndpointPolicy.TenantAdminOrGA, TenantScoping.QueryParam), new("GET", "tenants/{tenantId}/admins", EndpointPolicy.TenantAdminOrGA, TenantScoping.RouteParam), new("POST", "tenants/{tenantId}/admins", EndpointPolicy.TenantAdminOrGA, TenantScoping.RouteParam), diff --git a/src/Web/autopilot-monitor-web/app/settings/TenantConfigContext.tsx b/src/Web/autopilot-monitor-web/app/settings/TenantConfigContext.tsx index ad7490a9..8c1ca4e5 100644 --- a/src/Web/autopilot-monitor-web/app/settings/TenantConfigContext.tsx +++ b/src/Web/autopilot-monitor-web/app/settings/TenantConfigContext.tsx @@ -95,6 +95,13 @@ interface TenantConfigContextValue { // Core config: TenantConfiguration | null; loading: boolean; + /** + * True when the current user may CHANGE tenant configuration (Tenant Admin or Global Admin). + * Operators reach the settings area read-only: sections must render no Save/Reset bar and + * disable their inputs when this is false. Mirrors the backend write gate (PUT config is + * TenantAdminOrGA), so hiding the affordances is UX — the server enforces regardless. + */ + canEditConfig: boolean; savingSection: string | null; error: string | null; setError: (e: string | null) => void; @@ -320,6 +327,11 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode } const { getAccessToken, user, logout } = useAuth(); const { addNotification } = useNotifications(); + // Operators reach the settings area read-only — only a Tenant Admin or Global Admin may + // change configuration. Drives section rendering (no Save/Reset bar, disabled inputs) + // and the defensive guard in saveConfiguration; the backend enforces the same gate. + const canEditConfig = user?.isTenantAdmin === true || user?.isGlobalAdmin === true; + // --- State (mirrors old page.tsx lines 35-112) --- const [config, setConfig] = useState(null); const [admins, setAdmins] = useState([]); @@ -429,7 +441,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode } useEffect(() => { if (!tenantId) return; - // Operators (non-admin) only need feature flags, not the full config + // Operators reach the settings area read-only (no Save affordances); admins may edit. const isAdminOrGA = user?.isTenantAdmin || user?.isGlobalAdmin; const fetchConfiguration = async () => { @@ -437,33 +449,34 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode } setLoading(true); setError(null); - if (!isAdminOrGA) { - // Operator with canManageBootstrapTokens — load only feature flags - const response = await authenticatedFetch(api.config.featureFlags(tenantId), getAccessToken); - if (!response.ok) { - throw new Error(`Failed to load feature flags: ${response.statusText}`); - } - const flags = await response.json(); - // Create a minimal config object with just the feature flag - setConfig({ bootstrapTokenEnabled: flags.bootstrapTokenEnabled } as TenantConfiguration); - setEditionInfo(parseEditionInfo(flags)); - return; - } - - // Admin path: full config + feature flags in parallel — the edition surface is - // resolved SERVER-side (trial expiry math), so it comes from flags, not the raw config. + // Everyone entitled to the settings area loads the full config + feature flags in + // parallel — the edition surface is resolved SERVER-side (trial expiry math), so it + // comes from flags, not the raw config. Non-admins (Operators) receive the + // server-REDACTED copy: the backend clears secrets for every caller without write + // authority over the target tenant, so showing it read-only is safe. const [response, flagsResponse] = await Promise.all([ authenticatedFetch(api.config.tenant(tenantId), getAccessToken), authenticatedFetch(api.config.featureFlags(tenantId), getAccessToken), ]); + let flags: unknown = null; if (flagsResponse.ok) { try { - setEditionInfo(parseEditionInfo(await flagsResponse.json())); + flags = await flagsResponse.json(); + setEditionInfo(parseEditionInfo(flags)); } catch { /* fail-closed: keep Community default */ } } if (!response.ok) { + // Deploy-order safety net: a backend that still gates the config GET admin-tier + // 403s an Operator. Fall back to the feature-flags minimal view (pre-change + // behavior for non-admins) instead of surfacing a load error. + if (response.status === 403 && !isAdminOrGA && flags && typeof flags === "object") { + setConfig({ + bootstrapTokenEnabled: (flags as { bootstrapTokenEnabled?: boolean }).bootstrapTokenEnabled, + } as TenantConfiguration); + return; + } throw new Error(`Failed to load configuration: ${response.statusText}`); } @@ -652,7 +665,9 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode } // Save configuration (shared by all sections) // ----------------------------------------------------------------------- const saveConfiguration = useCallback(async (sectionName: string, overrides?: { validateAutopilotDevice?: boolean; validateCorporateIdentifier?: boolean; validateDeviceAssociation?: boolean; unrestrictedMode?: boolean }): Promise => { - if (!tenantId || !config) return false; + // Read-only viewers (Operators) have no save affordances; this guard covers any path + // that still reaches a save (the backend would 403 the PUT regardless). + if (!tenantId || !config || !canEditConfig) return false; try { setSavingSection(sectionName); @@ -758,7 +773,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode } setSavingSection(null); } }, [ - tenantId, config, getAccessToken, addNotification, + tenantId, config, canEditConfig, getAccessToken, addNotification, manufacturerWhitelist, modelWhitelist, validateAutopilotDevice, validateCorporateIdentifier, validateDeviceAssociation, dataRetentionDays, sessionTimeoutHours, enablePerformanceCollector, performanceCollectorInterval, helloWaitTimeoutSeconds, selfDestructOnComplete, keepLogFile, rebootOnComplete, rebootDelaySeconds, @@ -1494,7 +1509,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode } // ----------------------------------------------------------------------- return ( ); diff --git a/src/Web/autopilot-monitor-web/app/settings/agent/sections/SectionAgentSettings.tsx b/src/Web/autopilot-monitor-web/app/settings/agent/sections/SectionAgentSettings.tsx index 67699a5e..a3297cb9 100644 --- a/src/Web/autopilot-monitor-web/app/settings/agent/sections/SectionAgentSettings.tsx +++ b/src/Web/autopilot-monitor-web/app/settings/agent/sections/SectionAgentSettings.tsx @@ -6,6 +6,7 @@ import AgentSettingsSection from "../../components/AgentSettingsSection"; export function SectionAgentSettings() { const { + canEditConfig, enablePerformanceCollector, setEnablePerformanceCollector, performanceCollectorInterval, setPerformanceCollectorInterval, helloWaitTimeoutSeconds, setHelloWaitTimeoutSeconds, @@ -65,6 +66,7 @@ export function SectionAgentSettings() { onSave={handleSaveAgentSettings} onReset={handleResetAgentSettings} saving={savingSection === "agentSettings"} + readOnly={!canEditConfig} /> ); diff --git a/src/Web/autopilot-monitor-web/app/settings/agent/sections/SectionDiagnostics.tsx b/src/Web/autopilot-monitor-web/app/settings/agent/sections/SectionDiagnostics.tsx index 8fe7d2c0..7028ad6b 100644 --- a/src/Web/autopilot-monitor-web/app/settings/agent/sections/SectionDiagnostics.tsx +++ b/src/Web/autopilot-monitor-web/app/settings/agent/sections/SectionDiagnostics.tsx @@ -6,6 +6,7 @@ import DiagnosticsSection from "../../components/DiagnosticsSection"; export function SectionDiagnostics() { const { + canEditConfig, diagnosticsBlobSasUrl, setDiagnosticsBlobSasUrl, diagnosticsUploadMode, setDiagnosticsUploadMode, diagnosticsUploadDestination, setDiagnosticsUploadDestination, @@ -39,6 +40,7 @@ export function SectionDiagnostics() { onSave={handleSaveDiagnostics} onReset={handleResetDiagnostics} saving={savingSection === "diagnostics"} + readOnly={!canEditConfig} /> ); diff --git a/src/Web/autopilot-monitor-web/app/settings/agent/sections/SectionUnrestrictedMode.tsx b/src/Web/autopilot-monitor-web/app/settings/agent/sections/SectionUnrestrictedMode.tsx index 875db632..89cc59c0 100644 --- a/src/Web/autopilot-monitor-web/app/settings/agent/sections/SectionUnrestrictedMode.tsx +++ b/src/Web/autopilot-monitor-web/app/settings/agent/sections/SectionUnrestrictedMode.tsx @@ -6,12 +6,22 @@ import UnrestrictedModeSection from "../../components/UnrestrictedModeSection"; export function SectionUnrestrictedMode() { const { - config, + canEditConfig, config, unrestrictedMode, setUnrestrictedMode, handleSaveUnrestrictedMode, savingSection, } = useTenantConfig(); + // Security-sensitive toggle — Operators never see it (with the unified config fetch + // they would otherwise see the GA-gate flag and get the section rendered). + if (!canEditConfig) { + return ( +
+ This page is available to tenant administrators only. +
+ ); + } + if (!config?.unrestrictedModeEnabled) { return (
diff --git a/src/Web/autopilot-monitor-web/app/settings/components/AgentAnalyzersSection.tsx b/src/Web/autopilot-monitor-web/app/settings/components/AgentAnalyzersSection.tsx index 693d5563..1722fb72 100644 --- a/src/Web/autopilot-monitor-web/app/settings/components/AgentAnalyzersSection.tsx +++ b/src/Web/autopilot-monitor-web/app/settings/components/AgentAnalyzersSection.tsx @@ -1,6 +1,7 @@ "use client"; import SaveResetBar from "./SaveResetBar"; +import ReadOnlyFieldset from "./ReadOnlyFieldset"; interface AgentAnalyzersSectionProps { enableLocalAdminAnalyzer: boolean; @@ -22,6 +23,8 @@ interface AgentAnalyzersSectionProps { onSave: () => Promise | void; onReset: () => void; saving: boolean; + /** Read-only viewer (Operator): analyzers visible but inert, no Save/Reset bar. */ + readOnly?: boolean; } const BUILTIN_ACCOUNTS = [ @@ -58,6 +61,7 @@ export default function AgentAnalyzersSection({ onSave, onReset, saving, + readOnly = false, }: AgentAnalyzersSectionProps) { const trimmed = newAllowedAccount.trim(); const isDuplicate = @@ -89,6 +93,8 @@ export default function AgentAnalyzersSection({
+ +
{/* ── Local Admin Analyzer ─────────────────────────── */}
@@ -337,8 +343,11 @@ export default function AgentAnalyzersSection({
+
+ + {/* Save / Reset */} - + {!readOnly && } ); diff --git a/src/Web/autopilot-monitor-web/app/settings/components/AgentSettingsSection.tsx b/src/Web/autopilot-monitor-web/app/settings/components/AgentSettingsSection.tsx index a02c82b1..bc8b08b3 100644 --- a/src/Web/autopilot-monitor-web/app/settings/components/AgentSettingsSection.tsx +++ b/src/Web/autopilot-monitor-web/app/settings/components/AgentSettingsSection.tsx @@ -1,6 +1,7 @@ "use client"; import SaveResetBar from "./SaveResetBar"; +import ReadOnlyFieldset from "./ReadOnlyFieldset"; interface AgentSettingsSectionProps { enablePerformanceCollector: boolean; @@ -38,6 +39,8 @@ interface AgentSettingsSectionProps { onSave: () => Promise | void; onReset: () => void; saving: boolean; + /** Read-only viewer (Operator): settings visible but inert, no Save/Reset bar. */ + readOnly?: boolean; } export default function AgentSettingsSection({ @@ -76,6 +79,7 @@ export default function AgentSettingsSection({ onSave, onReset, saving, + readOnly = false, }: AgentSettingsSectionProps) { return ( <> @@ -93,6 +97,8 @@ export default function AgentSettingsSection({
+ +
{/* Self-Destruct */}
@@ -291,8 +297,10 @@ export default function AgentSettingsSection({
)} +
+
- + {!readOnly && }
@@ -310,6 +318,8 @@ export default function AgentSettingsSection({
+ +
{/* Performance Collector */}
@@ -370,6 +380,8 @@ export default function AgentSettingsSection({
+
+
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 1d218b87..5001280f 100644 --- a/src/Web/autopilot-monitor-web/app/settings/components/DataManagementSection.tsx +++ b/src/Web/autopilot-monitor-web/app/settings/components/DataManagementSection.tsx @@ -1,6 +1,7 @@ "use client"; import SaveResetBar from "./SaveResetBar"; +import ReadOnlyFieldset from "./ReadOnlyFieldset"; interface DataManagementSectionProps { dataRetentionDays: number; @@ -13,6 +14,8 @@ interface DataManagementSectionProps { onSave: () => Promise | void; onReset: () => void; saving: boolean; + /** Read-only viewer (Operator): values visible but inert, no Save/Reset bar. */ + readOnly?: boolean; } export default function DataManagementSection({ @@ -25,6 +28,7 @@ export default function DataManagementSection({ onSave, onReset, saving, + readOnly = false, }: DataManagementSectionProps) { const isOverridden = dataRetentionDays === 0 || dataRetentionDays < 7 || dataRetentionDays > retentionCapDays; const isRetentionDisabled = isOverridden && !isGlobalAdmin; @@ -43,6 +47,8 @@ export default function DataManagementSection({
+ +
{/* Data Retention Days */}
- +
+
+ + {!readOnly && }
); diff --git a/src/Web/autopilot-monitor-web/app/settings/components/DiagnosticsSection.tsx b/src/Web/autopilot-monitor-web/app/settings/components/DiagnosticsSection.tsx index c5aea479..a27b43ad 100644 --- a/src/Web/autopilot-monitor-web/app/settings/components/DiagnosticsSection.tsx +++ b/src/Web/autopilot-monitor-web/app/settings/components/DiagnosticsSection.tsx @@ -5,6 +5,7 @@ import { DiagnosticsLogPath } from "../types"; import { validateDiagnosticsPath } from "@/utils/guardValidation"; import { ValidationIndicator } from "@/components/ValidationIndicator"; import SaveResetBar from "./SaveResetBar"; +import ReadOnlyFieldset from "./ReadOnlyFieldset"; import { parseSasExpiry } from "./diagnosticsSasExpiry"; // Re-export for callers that still import from the section. Implementation lives @@ -30,6 +31,8 @@ interface DiagnosticsSectionProps { onSave: () => Promise | void; onReset: () => void; saving: boolean; + /** Read-only viewer (Operator): settings visible but inert, no Save/Reset bar. */ + readOnly?: boolean; } export default function DiagnosticsSection({ @@ -50,6 +53,7 @@ export default function DiagnosticsSection({ onSave, onReset, saving, + readOnly = false, }: DiagnosticsSectionProps) { const isHosted = diagnosticsUploadDestination === "Hosted"; const [newDiagSubfolders, setNewDiagSubfolders] = useState(false); @@ -77,6 +81,8 @@ export default function DiagnosticsSection({
+ +
{/* Destination selector — explicit admin choice between customer-controlled SAS (default, data stays in customer's storage) and hosted (opt-in, data leaves @@ -364,7 +370,10 @@ export default function DiagnosticsSection({
- + + + + {!readOnly && } ); diff --git a/src/Web/autopilot-monitor-web/app/settings/components/HardwareRejectionInsights.tsx b/src/Web/autopilot-monitor-web/app/settings/components/HardwareRejectionInsights.tsx index 2423b64b..d3e7f629 100644 --- a/src/Web/autopilot-monitor-web/app/settings/components/HardwareRejectionInsights.tsx +++ b/src/Web/autopilot-monitor-web/app/settings/components/HardwareRejectionInsights.tsx @@ -28,6 +28,8 @@ interface HardwareRejectionInsightsProps { webhookNotifyOnHardwareRejection: boolean; onToggleNotification: (value: boolean) => void; hasWebhook: boolean; + /** Read-only viewer (Operator): the notify toggle is disabled (insights stay browsable). */ + readOnly?: boolean; } function formatDate(iso: string): string { @@ -47,13 +49,16 @@ function WebhookToggle({ checked, onChange, hasWebhook, + disabled = false, }: { checked: boolean; onChange: (value: boolean) => void; hasWebhook: boolean; + disabled?: boolean; }) { + const interactive = hasWebhook && !disabled; return ( -