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) <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-07-24 18:40:27 +02:00
parent 9acd10e1dc
commit 9ec8644be2
28 changed files with 314 additions and 73 deletions
@@ -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 }));
}
@@ -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)
@@ -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]
@@ -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
/// <summary>
/// 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.
@@ -10,8 +10,8 @@ using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Functions.Reports
{
/// <summary>
/// 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
/// <see cref="SessionReportMetadata.ReportType"/> = "diagFiles".
/// </summary>
@@ -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;
@@ -66,9 +66,11 @@ public enum EndpointPolicy
/// <summary>
/// Own-tenant Admin, OR a platform role (Global Admin / Global Reader). Tenant-scoped admin-tier
/// READ access (e.g. GET config/{tenantId}). Like <see cref="TenantAdminOrGA"/> 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
/// <see cref="TenantAdminOrGA"/> 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.)
/// </summary>
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),
@@ -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<TenantConfiguration | null>(null);
const [admins, setAdmins] = useState<TenantAdmin[]>([]);
@@ -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<boolean> => {
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 (
<TenantConfigContext.Provider value={{
config, loading, savingSection,
config, loading, canEditConfig, savingSection,
error, setError, successMessage, setSuccessMessage,
// Edition / trial
@@ -6,6 +6,7 @@ import AgentAnalyzersSection from "../../components/AgentAnalyzersSection";
export function SectionAgentAnalyzers() {
const {
canEditConfig,
enableLocalAdminAnalyzer, setEnableLocalAdminAnalyzer,
localAdminAllowedAccounts, setLocalAdminAllowedAccounts,
newAllowedAccount, setNewAllowedAccount,
@@ -41,6 +42,7 @@ export function SectionAgentAnalyzers() {
onSave={handleSaveAgentAnalyzers}
onReset={handleResetAgentAnalyzers}
saving={savingSection === "agentAnalyzers"}
readOnly={!canEditConfig}
/>
</>
);
@@ -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}
/>
</>
);
@@ -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}
/>
</>
);
@@ -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 (
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4 text-sm text-amber-800">
This page is available to tenant administrators only.
</div>
);
}
if (!config?.unrestrictedModeEnabled) {
return (
<div className="bg-white rounded-lg shadow p-8 text-center">
@@ -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> | 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({
</div>
</div>
<div className="p-6 space-y-6">
<ReadOnlyFieldset readOnly={readOnly}>
<div className="space-y-6">
{/* ── Local Admin Analyzer ─────────────────────────── */}
<div>
@@ -337,8 +343,11 @@ export default function AgentAnalyzersSection({
</div>
</div>
</div>
</ReadOnlyFieldset>
{/* Save / Reset */}
<SaveResetBar onSave={onSave} onReset={onReset} saving={saving} />
{!readOnly && <SaveResetBar onSave={onSave} onReset={onReset} saving={saving} />}
</div>
</div>
);
@@ -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> | 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({
</div>
</div>
<div className="p-6 space-y-4">
<ReadOnlyFieldset readOnly={readOnly}>
<div className="space-y-4">
{/* Self-Destruct */}
<div className="flex items-center justify-between p-4 rounded-lg border border-gray-200 hover:border-violet-200 transition-colors">
@@ -291,8 +297,10 @@ export default function AgentSettingsSection({
</div>
</>
)}
</div>
</ReadOnlyFieldset>
<SaveResetBar onSave={onSave} onReset={onReset} saving={saving} />
{!readOnly && <SaveResetBar onSave={onSave} onReset={onReset} saving={saving} />}
</div>
</div>
@@ -310,6 +318,8 @@ export default function AgentSettingsSection({
</div>
</div>
<div className="p-6 space-y-5">
<ReadOnlyFieldset readOnly={readOnly} notice={false}>
<div className="space-y-5">
{/* Performance Collector */}
<div className="flex items-center justify-between p-4 rounded-lg border border-gray-200 hover:border-emerald-200 transition-colors">
<div className="flex-1">
@@ -370,6 +380,8 @@ export default function AgentSettingsSection({
</div>
</div>
</div>
</ReadOnlyFieldset>
</div>
</div>
</>
@@ -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> | 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({
</div>
</div>
<div className="p-6 space-y-6">
<ReadOnlyFieldset readOnly={readOnly}>
<div className="space-y-6">
{/* Data Retention Days */}
<div>
<label className="block">
@@ -106,7 +112,10 @@ export default function DataManagementSection({
</label>
</div>
<SaveResetBar onSave={onSave} onReset={onReset} saving={saving} />
</div>
</ReadOnlyFieldset>
{!readOnly && <SaveResetBar onSave={onSave} onReset={onReset} saving={saving} />}
</div>
</div>
);
@@ -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> | 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({
</div>
</div>
<div className="p-6 space-y-4">
<ReadOnlyFieldset readOnly={readOnly}>
<div className="space-y-4">
{/* 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({
</div>
</div>
<SaveResetBar onSave={onSave} onReset={onReset} saving={saving} />
</div>
</ReadOnlyFieldset>
{!readOnly && <SaveResetBar onSave={onSave} onReset={onReset} saving={saving} />}
</div>
</div>
);
@@ -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 (
<label className={`flex items-center justify-between p-3 rounded-lg border border-gray-200 transition-colors ${hasWebhook ? "cursor-pointer hover:bg-gray-50" : "opacity-60 cursor-not-allowed"}`}>
<label className={`flex items-center justify-between p-3 rounded-lg border border-gray-200 transition-colors ${interactive ? "cursor-pointer hover:bg-gray-50" : "opacity-60 cursor-not-allowed"}`}>
<div className="flex items-center space-x-2">
<svg className="w-4 h-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
@@ -72,7 +77,7 @@ function WebhookToggle({
type="checkbox"
checked={checked && hasWebhook}
onChange={(e) => onChange(e.target.checked)}
disabled={!hasWebhook}
disabled={!interactive}
className="sr-only peer"
/>
<div className="w-9 h-5 bg-gray-200 peer-focus:outline-none peer-focus:ring-2 peer-focus:ring-blue-500 rounded-full peer peer-checked:bg-blue-600 peer-disabled:opacity-50 transition-colors" />
@@ -89,6 +94,7 @@ export default function HardwareRejectionInsights({
webhookNotifyOnHardwareRejection,
onToggleNotification,
hasWebhook,
readOnly = false,
}: HardwareRejectionInsightsProps) {
const [data, setData] = useState<HardwareRejectedResponse | null>(null);
const [loading, setLoading] = useState(true);
@@ -189,6 +195,7 @@ export default function HardwareRejectionInsights({
checked={webhookNotifyOnHardwareRejection}
onChange={onToggleNotification}
hasWebhook={hasWebhook}
disabled={readOnly}
/>
</div>
@@ -2,6 +2,7 @@
import { useState } from "react";
import SaveResetBar from "./SaveResetBar";
import ReadOnlyFieldset from "./ReadOnlyFieldset";
interface HardwareWhitelistSectionProps {
manufacturerWhitelist: string;
@@ -11,6 +12,8 @@ interface HardwareWhitelistSectionProps {
onSave: () => Promise<void> | void;
onReset: () => void;
saving: boolean;
/** Read-only viewer (Operator): inputs disabled, no Save/Reset bar. */
readOnly?: boolean;
}
function parseList(csv: string): string[] {
@@ -130,6 +133,7 @@ export default function HardwareWhitelistSection({
onSave,
onReset,
saving,
readOnly = false,
}: HardwareWhitelistSectionProps) {
const manufacturers = parseList(manufacturerWhitelist);
const models = parseList(modelWhitelist);
@@ -153,6 +157,8 @@ export default function HardwareWhitelistSection({
</div>
</div>
<div className="p-6 space-y-5">
<ReadOnlyFieldset readOnly={readOnly}>
<div className="space-y-5">
{/* Info */}
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3 space-y-2">
<p className="text-sm text-blue-900">
@@ -190,7 +196,10 @@ export default function HardwareWhitelistSection({
setItems={(items) => setModelWhitelist(joinList(items))}
/>
<SaveResetBar onSave={onSave} onReset={onReset} saving={saving} />
</div>
</ReadOnlyFieldset>
{!readOnly && <SaveResetBar onSave={onSave} onReset={onReset} saving={saving} />}
</div>
</div>
);
@@ -2,6 +2,7 @@
import { useEffect, useRef, useState } from "react";
import SaveResetBar from "./SaveResetBar";
import ReadOnlyFieldset from "./ReadOnlyFieldset";
import { MAX_NOTIFICATION_CHANNELS, NotificationChannel } from "../types";
interface NotificationsSectionProps {
@@ -13,6 +14,8 @@ interface NotificationsSectionProps {
onSave: () => Promise<void> | void;
onReset: () => void;
saving: boolean;
/** Read-only viewer (Operator): channels visible but inert, no Save/Reset bar. */
readOnly?: boolean;
}
const GENERIC_PROVIDER = 20;
@@ -330,6 +333,7 @@ export default function NotificationsSection({
onSave,
onReset,
saving,
readOnly = false,
}: NotificationsSectionProps) {
const addChannel = () => {
if (channels.length >= MAX_NOTIFICATION_CHANNELS) return;
@@ -363,6 +367,8 @@ export default function NotificationsSection({
</div>
</div>
<div className="p-6 space-y-4">
<ReadOnlyFieldset readOnly={readOnly}>
<div className="space-y-4">
{channels.length === 0 && (
<p className="text-sm text-gray-500">
No channels configured yet. Add a channel to receive enrollment, SLA, or rule notifications.
@@ -392,8 +398,10 @@ export default function NotificationsSection({
</svg>
Add channel{channels.length >= MAX_NOTIFICATION_CHANNELS ? ` (max ${MAX_NOTIFICATION_CHANNELS})` : ""}
</button>
</div>
</ReadOnlyFieldset>
<SaveResetBar onSave={onSave} onReset={onReset} saving={saving} />
{!readOnly && <SaveResetBar onSave={onSave} onReset={onReset} saving={saving} />}
</div>
</div>
);
@@ -0,0 +1,31 @@
"use client";
/**
* Wraps a settings section's interactive content for read-only viewers (Operators).
* The native fieldset `disabled` attribute inertly disables every nested input, select,
* textarea and button while values stay visible and copyable; a small notice explains why.
* Sections must ADDITIONALLY not render their SaveResetBar when read-only — a read-only
* viewer gets no save affordance at all, not a disabled one.
*/
export default function ReadOnlyFieldset({
readOnly,
notice = true,
children,
}: {
readOnly: boolean;
/** Set false on follow-up cards of the same page so the notice appears only once. */
notice?: boolean;
children: React.ReactNode;
}) {
if (!readOnly) return <>{children}</>;
return (
<fieldset disabled className="min-w-0">
{notice && (
<p className="mb-4 rounded-md bg-gray-50 border border-gray-200 px-3 py-2 text-sm text-gray-500">
Read-only view — configuration changes require a tenant administrator.
</p>
)}
{children}
</fieldset>
);
}
@@ -6,6 +6,7 @@ import DataManagementSection from "../../components/DataManagementSection";
export function SectionDataManagement() {
const {
canEditConfig,
dataRetentionDays, setDataRetentionDays,
sessionTimeoutHours, setSessionTimeoutHours,
user, editionInfo,
@@ -26,6 +27,7 @@ export function SectionDataManagement() {
onSave={handleSaveDataManagement}
onReset={handleResetDataManagement}
saving={savingSection === "dataManagement"}
readOnly={!canEditConfig}
/>
</>
);
@@ -8,6 +8,7 @@ import OffboardingSection from "../../components/OffboardingSection";
export function SectionOffboarding() {
const {
canEditConfig,
showOffboardDialog, setShowOffboardDialog,
offboardConfirmText, setOffboardConfirmText,
offboarding, offboardError, setOffboardError,
@@ -17,6 +18,15 @@ export function SectionOffboarding() {
const { tenantId } = useTenant();
const { getAccessToken } = useAuth();
// Offboarding deletes the whole tenant — Operators never see the danger zone.
if (!canEditConfig) {
return (
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4 text-sm text-amber-800">
This page is available to tenant administrators only.
</div>
);
}
return (
<>
<TenantNotifications />
@@ -6,6 +6,7 @@ import AdminManagementSection from "../../components/AdminManagementSection";
export function SectionAccessManagement() {
const {
canEditConfig,
admins, loadingAdmins,
newAdminEmail, setNewAdminEmail,
newMemberRole, setNewMemberRole,
@@ -17,6 +18,17 @@ export function SectionAccessManagement() {
handleToggleTenantAdmin, handleUpdatePermissions,
} = useTenantConfig();
// Operators do not manage members, and the member list itself is admin-tier data
// (the backend never serves it to them) — hide the section instead of rendering
// an empty table with dead controls.
if (!canEditConfig) {
return (
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4 text-sm text-amber-800">
This page is available to tenant administrators only.
</div>
);
}
return (
<>
<TenantNotifications />
@@ -8,6 +8,7 @@ import NotRegisteredDevicesInsights from "../../components/NotRegisteredDevicesI
export function SectionAutopilotValidation() {
const {
canEditConfig,
validateAutopilotDevice, setValidateAutopilotDevice,
validateCorporateIdentifier, setValidateCorporateIdentifier,
validateDeviceAssociation,
@@ -17,6 +18,16 @@ export function SectionAutopilotValidation() {
} = useTenantConfig();
const { user, getAccessToken } = useAuth();
// Validation gates + the Entra admin-consent flow are tenant-admin territory —
// Operators do not see this section at all.
if (!canEditConfig) {
return (
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4 text-sm text-amber-800">
This page is available to tenant administrators only.
</div>
);
}
// DevPrep "Device association" is in Microsoft Private Preview — surface only to Global Admins
// until it ships GA. Backend always rejects writes from non-GA callers regardless of UI.
// TODO(devprep-followup): add vitest DOM-render test asserting toggle is hidden for
@@ -2,9 +2,11 @@
import { useTenantConfig } from "../../TenantConfigContext";
import SaveResetBar from "../../components/SaveResetBar";
import ReadOnlyFieldset from "../../components/ReadOnlyFieldset";
export function SectionContact() {
const {
canEditConfig,
contactEmail, setContactEmail,
handleSaveContact, handleResetContact,
savingSection,
@@ -28,6 +30,7 @@ export function SectionContact() {
</div>
<div className="p-6 space-y-4">
<ReadOnlyFieldset readOnly={!canEditConfig}>
<div>
<label htmlFor="contactEmail" className="block text-sm font-medium text-gray-700">
Contact email address
@@ -37,13 +40,14 @@ export function SectionContact() {
type="email"
value={contactEmail}
onChange={(e) => setContactEmail(e.target.value)}
placeholder="it-operations@contoso.com"
className="mt-1 block w-full max-w-md rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
placeholder={canEditConfig ? "it-operations@contoso.com" : "Not configured"}
className="mt-1 block w-full max-w-md rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500 disabled:bg-gray-50 disabled:text-gray-600"
/>
{looksInvalid && (
<p className="mt-1 text-sm text-amber-600">That does not look like an email address.</p>
)}
</div>
</ReadOnlyFieldset>
<div className="rounded-md bg-blue-50 border border-blue-100 p-4">
<p className="text-sm text-blue-900">
@@ -57,12 +61,14 @@ export function SectionContact() {
</p>
</div>
<SaveResetBar
onSave={handleSaveContact}
onReset={handleResetContact}
saving={savingSection === "contact"}
canSave={!looksInvalid}
/>
{canEditConfig && (
<SaveResetBar
onSave={handleSaveContact}
onReset={handleResetContact}
saving={savingSection === "contact"}
canSave={!looksInvalid}
/>
)}
</div>
</div>
);
@@ -18,6 +18,7 @@ function joinList(items: string[]): string {
export function SectionHardwareWhitelist() {
const { getAccessToken } = useAuth();
const {
canEditConfig,
manufacturerWhitelist, setManufacturerWhitelist,
modelWhitelist, setModelWhitelist,
webhookNotifyOnHardwareRejection, setWebhookNotifyOnHardwareRejection,
@@ -53,14 +54,16 @@ export function SectionHardwareWhitelist() {
onSave={handleSaveHardwareWhitelist}
onReset={handleResetHardwareWhitelist}
saving={savingSection === "hardwareWhitelist"}
readOnly={!canEditConfig}
/>
<HardwareRejectionInsights
getAccessToken={getAccessToken}
onAddManufacturer={handleAddManufacturer}
onAddModel={handleAddModel}
onAddManufacturer={canEditConfig ? handleAddManufacturer : undefined}
onAddModel={canEditConfig ? handleAddModel : undefined}
webhookNotifyOnHardwareRejection={webhookNotifyOnHardwareRejection}
onToggleNotification={setWebhookNotifyOnHardwareRejection}
hasWebhook={hasWebhook}
readOnly={!canEditConfig}
/>
</>
);
@@ -6,6 +6,7 @@ import NotificationsSection from "../../components/NotificationsSection";
export function SectionNotifications() {
const {
canEditConfig,
notificationChannels, setNotificationChannels,
handleTestChannel, testingChannelId, testChannelResult,
handleSaveNotifications, handleResetNotifications,
@@ -24,6 +25,7 @@ export function SectionNotifications() {
onSave={handleSaveNotifications}
onReset={handleResetNotifications}
saving={savingSection === "notifications"}
readOnly={!canEditConfig}
/>
</>
);
@@ -2,10 +2,12 @@
import { useTenantConfig } from "../../TenantConfigContext";
import SaveResetBar from "../../components/SaveResetBar";
import ReadOnlyFieldset from "../../components/ReadOnlyFieldset";
import { trackEvent } from "@/lib/appInsights";
export function SectionSlaTargets() {
const {
canEditConfig,
slaTargetSuccessRate, setSlaTargetSuccessRate,
slaTargetMaxDurationMinutes, setSlaTargetMaxDurationMinutes,
slaTargetAppInstallSuccessRate, setSlaTargetAppInstallSuccessRate,
@@ -37,6 +39,8 @@ export function SectionSlaTargets() {
</div>
</div>
<div className="p-6 space-y-8">
<ReadOnlyFieldset readOnly={!canEditConfig}>
<div className="space-y-8">
{/* SLA Targets */}
<div>
<p className="text-sm text-gray-500 mb-4">
@@ -216,21 +220,26 @@ export function SectionSlaTargets() {
</div>
</div>
<SaveResetBar
onSave={() => {
trackEvent('sla_targets_saved', {
hasSuccessRateTarget: slaTargetSuccessRate != null,
hasDurationTarget: slaTargetMaxDurationMinutes != null,
hasAppInstallTarget: slaTargetAppInstallSuccessRate != null,
});
return handleSaveSlaTargets();
}}
onReset={() => {
trackEvent('sla_targets_reset');
return handleResetSlaTargets();
}}
saving={savingSection === "slaTargets"}
/>
</div>
</ReadOnlyFieldset>
{canEditConfig && (
<SaveResetBar
onSave={() => {
trackEvent('sla_targets_saved', {
hasSuccessRateTarget: slaTargetSuccessRate != null,
hasDurationTarget: slaTargetMaxDurationMinutes != null,
hasAppInstallTarget: slaTargetAppInstallSuccessRate != null,
});
return handleSaveSlaTargets();
}}
onReset={() => {
trackEvent('sla_targets_reset');
return handleResetSlaTargets();
}}
saving={savingSection === "slaTargets"}
/>
)}
</div>
</div>
);
@@ -227,6 +227,12 @@ export function GlobalSidebar({ children }: { children: ReactNode }) {
if (sub.id === "cfg-agent-unrestricted") {
return isAdminLike && unrestrictedModeEnabled;
}
// Tenant-admin-only sub-sections: Operators (read-only settings viewers) don't
// see them — matches the in-page "tenant administrators only" gates. A platform
// scope (GA / read-only GlobalReader) keeps the full GA-identical sidebar.
if (sub.id === "cfg-autopilot" || sub.id === "cfg-access-mgmt" || sub.id === "cfg-offboarding") {
return isAdminLike || hasGlobalScope;
}
return true;
});
return { ...item, items: filteredSubs };