diff --git a/.claude/commands b/.claude/commands
index dc0ebd69..fbf0ff62 160000
--- a/.claude/commands
+++ b/.claude/commands
@@ -1 +1 @@
-Subproject commit dc0ebd69af810835d0bf8f5de13a641450ffee84
+Subproject commit fbf0ff6214c771c152300d2b424b39278f9d734e
diff --git a/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt b/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt
index fb8477e5..51e227a6 100644
--- a/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt
+++ b/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt
@@ -1 +1 @@
-643
+644
diff --git a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt
index 87e36611..5e9ad0bf 100644
--- a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt
+++ b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt
@@ -1 +1 @@
-1278
+1279
diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/AutopilotDeviceValidationConsentFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/AutopilotDeviceValidationConsentFunction.cs
index a7100932..963766ec 100644
--- a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/AutopilotDeviceValidationConsentFunction.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/AutopilotDeviceValidationConsentFunction.cs
@@ -146,6 +146,67 @@ public class AutopilotDeviceValidationConsentFunction
return response;
}
+ ///
+ /// Known consent triggers — frontend flows that initiate the AAD admin-consent dialog.
+ /// "device-preparation" is reserved for a future DevPrep-specific consent flow; it is
+ /// accepted by the success endpoint so the value can land in ops events when wired up.
+ ///
+ internal static readonly HashSet KnownConsentTriggers = new(StringComparer.OrdinalIgnoreCase)
+ {
+ "autopilot",
+ "corporate",
+ "device-preparation",
+ };
+
+ ///
+ /// Frontend confirms a consent flow succeeded — called from the post-redirect callback
+ /// after consent-status reports isConsented=true. Pairs with
+ /// ConsentFlowStarted / ConsentFlowFailed so ops can see whether
+ /// repeated failures eventually resolved into a success.
+ ///
+ [Function("ReportConsentSuccess")]
+ public async Task ReportConsentSuccess(
+ [HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "config/{tenantId}/autopilot-device-validation/consent-success")] HttpRequestData req,
+ string tenantId)
+ {
+ // Authentication enforced by PolicyEnforcementMiddleware
+ var requestCtx = req.GetRequestContext();
+
+ ConsentSuccessReport? report;
+ try
+ {
+ report = await req.ReadFromJsonAsync();
+ }
+ catch
+ {
+ report = null;
+ }
+
+ var rawTrigger = report?.Trigger ?? string.Empty;
+ var trigger = KnownConsentTriggers.Contains(rawTrigger) ? rawTrigger.ToLowerInvariant() : "unknown";
+
+ _logger.LogInformation(
+ "ConsentFlowSuccess: tenant={TenantId} user={UserId} trigger={Trigger}",
+ requestCtx.TargetTenantId, requestCtx.UserPrincipalName, trigger);
+
+ _telemetryClient.TrackEvent("ConsentFlowSuccess", new Dictionary
+ {
+ ["TenantId"] = requestCtx.TargetTenantId,
+ ["UserId"] = requestCtx.UserPrincipalName,
+ ["Trigger"] = trigger,
+ });
+
+ await _opsEventService.RecordConsentFlowSuccessAsync(
+ requestCtx.TargetTenantId, requestCtx.UserPrincipalName, trigger);
+
+ return req.CreateResponse(HttpStatusCode.OK);
+ }
+
+ private sealed class ConsentSuccessReport
+ {
+ public string? Trigger { get; set; }
+ }
+
///
/// Frontend reports consent flow failures (Azure AD errors) so we have visibility
/// into broken consent flows that never reach our callback.
diff --git a/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs b/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs
index bdb6414c..19e3c2cf 100644
--- a/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs
@@ -190,6 +190,7 @@ public static class EndpointAccessPolicyCatalog
new("GET", "config/{tenantId}/autopilot-device-validation/consent-url", EndpointPolicy.TenantAdminOrGA, TenantScoping.RouteParam),
new("GET", "config/{tenantId}/autopilot-device-validation/consent-status", EndpointPolicy.TenantAdminOrGA, TenantScoping.RouteParam),
new("POST", "config/{tenantId}/autopilot-device-validation/consent-failure", EndpointPolicy.TenantAdminOrGA, TenantScoping.RouteParam),
+ new("POST", "config/{tenantId}/autopilot-device-validation/consent-success", EndpointPolicy.TenantAdminOrGA, TenantScoping.RouteParam),
new("POST", "config/{tenantId}/test-notification", EndpointPolicy.TenantAdminOrGA, TenantScoping.RouteParam),
// ── BootstrapManagerOrGA ────────────────────────────────────────
diff --git a/src/Backend/AutopilotMonitor.Functions/Services/OpsEventService.cs b/src/Backend/AutopilotMonitor.Functions/Services/OpsEventService.cs
index 7f0b7e27..f38df64d 100644
--- a/src/Backend/AutopilotMonitor.Functions/Services/OpsEventService.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Services/OpsEventService.cs
@@ -33,6 +33,11 @@ namespace AutopilotMonitor.Functions.Services
$"Admin consent flow started by {userId}",
tenantId, userId, new { redirectUri });
+ public Task RecordConsentFlowSuccessAsync(string tenantId, string userId, string trigger)
+ => WriteAsync(OpsEventCategory.Consent, "ConsentFlowSuccess", OpsEventSeverity.Info,
+ $"Admin consent confirmed for {trigger} by {userId}",
+ tenantId, userId, new { trigger });
+
public Task RecordConsentFlowFailedAsync(string tenantId, string userId, string error, string errorDescription)
=> WriteAsync(OpsEventCategory.Consent, "ConsentFlowFailed", OpsEventSeverity.Error,
$"Admin consent failed: {error}",
diff --git a/src/Web/autopilot-monitor-web/app/admin/components/OpsAlertRulesSection.tsx b/src/Web/autopilot-monitor-web/app/admin/components/OpsAlertRulesSection.tsx
index db015270..5f9e8e00 100644
--- a/src/Web/autopilot-monitor-web/app/admin/components/OpsAlertRulesSection.tsx
+++ b/src/Web/autopilot-monitor-web/app/admin/components/OpsAlertRulesSection.tsx
@@ -5,7 +5,7 @@ import { type OpsAlertRule } from "../AdminConfigContext";
// All known ops event types grouped by category
const OPS_EVENT_TYPES: Record = {
- Consent: ["ConsentFlowStarted", "ConsentFlowFailed", "ConsentRedirectUriMismatch"],
+ Consent: ["ConsentFlowStarted", "ConsentFlowSuccess", "ConsentFlowFailed", "ConsentRedirectUriMismatch"],
Maintenance: ["MaintenanceCompleted", "MaintenanceFailed", "OpsEventCleanup", "SessionTimeouts"],
Security: ["DeviceBlocked", "ExcessiveDataBlocked", "VersionBlocked"],
Tenant: ["TenantOffboarded"],
diff --git a/src/Web/autopilot-monitor-web/app/settings/TenantConfigContext.tsx b/src/Web/autopilot-monitor-web/app/settings/TenantConfigContext.tsx
index cf630849..4915521e 100644
--- a/src/Web/autopilot-monitor-web/app/settings/TenantConfigContext.tsx
+++ b/src/Web/autopilot-monitor-web/app/settings/TenantConfigContext.tsx
@@ -35,7 +35,7 @@ interface TenantConfigContextValue {
/** Toggle + persist DevPrep Device Association validation in one shot (no consent flow needed). */
handleToggleDeviceAssociationValidation: (newValue: boolean) => Promise;
autopilotConsentInProgress: boolean;
- beginDeviceValidationConsentFlow: (trigger: "autopilot" | "corporate") => Promise;
+ beginDeviceValidationConsentFlow: (trigger: "autopilot" | "corporate" | "device-preparation") => Promise;
// Hardware whitelist
manufacturerWhitelist: string;
@@ -658,7 +658,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
// -----------------------------------------------------------------------
// Consent flow
// -----------------------------------------------------------------------
- const beginDeviceValidationConsentFlow = useCallback(async (trigger: "autopilot" | "corporate") => {
+ const beginDeviceValidationConsentFlow = useCallback(async (trigger: "autopilot" | "corporate" | "device-preparation") => {
if (!tenantId) return;
try {
setAutopilotConsentInProgress(true);
@@ -760,6 +760,18 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
throw new Error(statusData.message || "Consent is not active yet for this tenant.");
}
+ // Best-effort ops-event: pairs with ConsentFlowStarted/Failed so admins can see
+ // whether repeated failures eventually resolved. Don't block the UI if it fails.
+ try {
+ await authenticatedFetch(api.config.autopilotConsentSuccess(tenantId), getAccessToken, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ trigger }),
+ });
+ } catch {
+ // swallow — observability only
+ }
+
if (trigger === "corporate") {
await saveConfiguration("autopilotValidation", { validateCorporateIdentifier: true });
setSuccessMessage("Corporate Identifier Validation enabled. Backend agent endpoints are now unlocked for this tenant.");
diff --git a/src/Web/autopilot-monitor-web/app/settings/components/AutopilotValidationSection.tsx b/src/Web/autopilot-monitor-web/app/settings/components/AutopilotValidationSection.tsx
index 0d462531..a7e69907 100644
--- a/src/Web/autopilot-monitor-web/app/settings/components/AutopilotValidationSection.tsx
+++ b/src/Web/autopilot-monitor-web/app/settings/components/AutopilotValidationSection.tsx
@@ -15,7 +15,7 @@ interface AutopilotValidationSectionProps {
showDeviceAssociationToggle?: boolean;
autopilotConsentInProgress: boolean;
saving: boolean;
- onBeginConsent: (trigger: 'autopilot' | 'corporate') => void;
+ onBeginConsent: (trigger: 'autopilot' | 'corporate' | 'device-preparation') => void;
}
export default function AutopilotValidationSection({
diff --git a/src/Web/autopilot-monitor-web/lib/api.ts b/src/Web/autopilot-monitor-web/lib/api.ts
index cfb90665..96f555d2 100644
--- a/src/Web/autopilot-monitor-web/lib/api.ts
+++ b/src/Web/autopilot-monitor-web/lib/api.ts
@@ -96,6 +96,8 @@ export const api = {
`${API_BASE_URL}/api/config/${tenantId}/autopilot-device-validation/consent-status`,
autopilotConsentFailure: (tenantId: string) =>
`${API_BASE_URL}/api/config/${tenantId}/autopilot-device-validation/consent-failure`,
+ autopilotConsentSuccess: (tenantId: string) =>
+ `${API_BASE_URL}/api/config/${tenantId}/autopilot-device-validation/consent-success`,
testNotification: (tenantId: string) =>
`${API_BASE_URL}/api/config/${tenantId}/test-notification`,
latestVersions: (opts?: { refresh?: boolean }) =>