mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-25 05:00:07 +00:00
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:
@@ -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 }));
|
||||
}
|
||||
|
||||
+4
-4
@@ -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]
|
||||
|
||||
+8
-5
@@ -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.
|
||||
|
||||
+3
-3
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
+11
-1
@@ -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>
|
||||
);
|
||||
}
|
||||
+2
@@ -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 />
|
||||
|
||||
+12
@@ -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 />
|
||||
|
||||
+11
@@ -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>
|
||||
);
|
||||
|
||||
+5
-2
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user