fix re-auth issue, fix summary dialog startup issue, fix original ESP tracking parsing

This commit is contained in:
Oliver Kieselbach
2026-03-09 08:46:49 +01:00
parent 264b68fff4
commit 4fc80257f6
9 changed files with 394 additions and 173 deletions
@@ -14,7 +14,18 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors
///
/// Registry path: HKLM\SOFTWARE\Microsoft\Provisioning\AutopilotSettings
/// Values: DevicePreparationCategory.Status, DeviceSetupCategory.Status, AccountSetupCategory.Status
/// Each value is a JSON string with categorySucceeded (bool) and categoryStatusMessage (string).
///
/// JSON format varies across Windows versions:
/// Flat: { "CertificatesSubcategory": "Certificates (1 of 1 applied)", "categorySucceeded": true, ... }
/// Nested: { "CertificatesSubcategory": { "subcategoryState": "succeeded", "subcategoryStatusText": "..." }, ... }
///
/// categorySucceeded (bool) appears only once the category has finalized.
/// Until then, subcategory states ("succeeded"/"failed"/...) provide progressive status.
///
/// Event emission strategy:
/// - Emit once when a category first appears in the registry (initial snapshot)
/// - Emit once when categorySucceeded resolves to true or false (final outcome)
/// - Do NOT emit on every intermediate subcategory text change (avoids spam)
/// </summary>
public partial class EspAndHelloTracker
{
@@ -28,8 +39,12 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors
"AccountSetupCategory.Status"
};
// Last known JSON per category — only emit events on changes
private Dictionary<string, string> _lastProvisioningStatus;
// Track the raw JSON per category — used to detect any changes at all
private Dictionary<string, string> _lastProvisioningJson;
// Track which categories have been seen (for first-seen event)
private HashSet<string> _provisioningCategorySeen;
// Track the last known categorySucceeded per category (null = not yet resolved)
private Dictionary<string, bool?> _lastCategorySucceeded;
// Fire-once guard per category — prevent duplicate EspFailureDetected calls
private HashSet<string> _provisioningFailureFired;
// Track which categories have reported a final categorySucceeded value
@@ -37,7 +52,9 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors
private void StartProvisioningStatusPolling()
{
_lastProvisioningStatus = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
_lastProvisioningJson = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
_provisioningCategorySeen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
_lastCategorySucceeded = new Dictionary<string, bool?>(StringComparer.OrdinalIgnoreCase);
_provisioningFailureFired = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
_provisioningCategoriesResolved = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
@@ -86,29 +103,29 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors
if (string.IsNullOrEmpty(jsonValue))
continue;
// Skip if unchanged since last poll
if (_lastProvisioningStatus.TryGetValue(categoryName, out var lastJson) && lastJson == jsonValue)
// Skip if JSON is identical to last poll (no change at all)
if (_lastProvisioningJson.TryGetValue(categoryName, out var lastJson) && lastJson == jsonValue)
continue;
_lastProvisioningStatus[categoryName] = jsonValue;
_lastProvisioningJson[categoryName] = jsonValue;
var result = ProcessCategoryStatus(categoryName, jsonValue);
if (result.HasValue && !result.Value)
if (result.IsFailed)
{
failureDetected = true;
failureType = result.FailureType;
}
}
// Self-termination: all observed categories have reported a final status
// Self-termination: all observed categories have reported a final categorySucceeded
if (_provisioningCategoriesResolved.Count > 0
&& _lastProvisioningStatus.Count > 0
&& _provisioningCategoriesResolved.Count >= _lastProvisioningStatus.Count)
&& _lastProvisioningJson.Count > 0
&& _provisioningCategoriesResolved.Count >= _lastProvisioningJson.Count)
{
StopProvisioningStatusPolling("all_resolved");
}
// Fire EspFailureDetected AFTER timer stop decision and AFTER event emission
// Fire EspFailureDetected AFTER event emission
// (matches the pattern in ShellCoreTracking.cs — event in spool before agent reacts)
if (failureDetected && failureType != null)
{
@@ -132,10 +149,10 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors
}
/// <summary>
/// Processes a single category status JSON value. Emits an info event for timeline visibility.
/// Returns a result indicating whether the category succeeded, failed, or couldn't be determined.
/// Processes a single category status JSON value.
/// Emits events only on first-seen and on categorySucceeded resolution (not on every intermediate change).
/// </summary>
private CategoryStatusResult ProcessCategoryStatus(string categoryName, string jsonValue)
private ProvisioningResult ProcessCategoryStatus(string categoryName, string jsonValue)
{
var categoryLabel = categoryName.Replace("Category.Status", "");
@@ -145,161 +162,329 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors
{
var root = doc.RootElement;
// Extract core fields — defensive against unexpected JSON value types
bool? categorySucceeded = null;
string categoryStatusMessage = null;
// 1. Extract categorySucceeded — the authoritative outcome signal
bool? categorySucceeded = SafeGetBool(root, "categorySucceeded");
string categoryStatusMessage = SafeGetString(root, "categoryStatusMessage");
if (root.TryGetProperty("categorySucceeded", out var succeededProp))
// 2. Parse subcategories — handles both flat strings and nested objects
var subcategories = ParseSubcategories(root);
// 3. Derive a meaningful status summary
string statusText;
if (categoryStatusMessage != null)
statusText = categoryStatusMessage;
else if (categorySucceeded == true)
statusText = "Complete";
else if (categorySucceeded == false)
statusText = "Failed";
else
statusText = BuildProgressSummary(subcategories);
// 4. Decide whether to emit an event
bool isFirstSeen = !_provisioningCategorySeen.Contains(categoryName);
bool categorySucceededChanged = HasCategorySucceededChanged(categoryName, categorySucceeded);
if (isFirstSeen)
{
if (succeededProp.ValueKind == JsonValueKind.True)
categorySucceeded = true;
else if (succeededProp.ValueKind == JsonValueKind.False)
categorySucceeded = false;
else if (succeededProp.ValueKind == JsonValueKind.String
&& bool.TryParse(succeededProp.GetString(), out var parsed))
categorySucceeded = parsed;
_provisioningCategorySeen.Add(categoryName);
_lastCategorySucceeded[categoryName] = categorySucceeded;
EmitProvisioningEvent(categoryLabel, categorySucceeded, statusText, subcategories, EventSeverity.Info);
}
if (root.TryGetProperty("categoryStatusMessage", out var messageProp)
&& messageProp.ValueKind == JsonValueKind.String)
else if (categorySucceededChanged)
{
categoryStatusMessage = messageProp.GetString();
_lastCategorySucceeded[categoryName] = categorySucceeded;
var severity = categorySucceeded == false ? EventSeverity.Warning : EventSeverity.Info;
EmitProvisioningEvent(categoryLabel, categorySucceeded, statusText, subcategories, severity);
}
// else: intermediate subcategory text change — log only, no event
// Collect subcategory details — values can be strings or objects, use ToString() as fallback
var subcategories = new Dictionary<string, string>();
foreach (var prop in root.EnumerateObject())
{
if (prop.Name.Contains("Subcategory", StringComparison.OrdinalIgnoreCase))
{
subcategories[prop.Name] = prop.Value.ValueKind == JsonValueKind.String
? prop.Value.GetString() ?? ""
: prop.Value.ToString();
}
}
// Determine severity based on outcome
var severity = categorySucceeded == false ? EventSeverity.Warning : EventSeverity.Info;
var statusText = categoryStatusMessage ?? (categorySucceeded == true ? "Complete" : categorySucceeded == false ? "Failed" : "Unknown");
// Build event data
var eventData = new Dictionary<string, object>
{
{ "category", categoryLabel },
{ "categorySucceeded", categorySucceeded?.ToString() ?? "unknown" },
{ "categoryStatusMessage", statusText }
};
// Add subcategory details
if (subcategories.Count > 0)
eventData["subcategories"] = subcategories;
// Emit timeline event for every status change
_onEventCollected(new EnrollmentEvent
{
SessionId = _sessionId,
TenantId = _tenantId,
EventType = Constants.EventTypes.EspProvisioningStatus,
Severity = severity,
Source = "EspAndHelloTracker",
Phase = EnrollmentPhase.Unknown,
Message = $"ESP provisioning status: {categoryLabel} — {statusText}",
Data = eventData
});
_logger.Info($"Provisioning status: {categoryLabel} — succeeded={categorySucceeded}, message={statusText}");
// Track resolved categories (both success and failure are final)
// 5. Track resolved categories (both success and failure are final)
if (categorySucceeded.HasValue)
_provisioningCategoriesResolved.Add(categoryName);
// Handle failure
// 6. Handle failure
if (categorySucceeded == false)
{
if (_provisioningFailureFired.Contains(categoryName))
return CategoryStatusResult.AlreadyFired;
return ProvisioningResult.NoAction;
_provisioningFailureFired.Add(categoryName);
var failureType = ExtractProvisioningFailureType(categoryLabel, subcategories);
_logger.Warning($"Provisioning failure detected: {failureType}");
var failedSubcategory = FindFailedSubcategory(subcategories);
var failureTypeName = failedSubcategory != null
? $"Provisioning_{categoryLabel}_{failedSubcategory}_Failed"
: $"Provisioning_{categoryLabel}_Failed";
return CategoryStatusResult.Failed(failureType);
_logger.Warning($"Provisioning failure detected: {failureTypeName}");
return ProvisioningResult.Failure(failureTypeName);
}
return categorySucceeded == true
? CategoryStatusResult.Succeeded
: CategoryStatusResult.Unknown;
return ProvisioningResult.NoAction;
}
}
catch (JsonException ex)
{
_logger.Warning($"Failed to parse provisioning status JSON for {categoryLabel}: {ex.Message}");
return CategoryStatusResult.Unknown;
return ProvisioningResult.NoAction;
}
}
/// <summary>
/// Builds a structured failure type string from the category and its subcategories.
/// E.g. "Provisioning_DeviceSetup_Certificates_Failed" when the Certificates subcategory failed.
/// </summary>
private static string ExtractProvisioningFailureType(string categoryLabel, Dictionary<string, string> subcategories)
{
// Try to find which subcategory failed by looking for values that don't indicate success
foreach (var kvp in subcategories)
catch (Exception ex)
{
var value = kvp.Value;
// Success indicators in subcategory values
if (value.Contains("Complete", StringComparison.OrdinalIgnoreCase)
|| value.Contains("applied", StringComparison.OrdinalIgnoreCase)
|| value.Contains("installed", StringComparison.OrdinalIgnoreCase)
|| value.Contains("added", StringComparison.OrdinalIgnoreCase)
|| value.Contains("No setup needed", StringComparison.OrdinalIgnoreCase)
|| value.Contains("Succeeded", StringComparison.OrdinalIgnoreCase)
|| value.Contains("Identified", StringComparison.OrdinalIgnoreCase))
{
continue;
}
// This subcategory did not succeed — extract a clean name
// E.g. "CertificatesSubcategory" -> "Certificates"
// "SecurityPoliciesSubcategory" -> "SecurityPolicies"
// "AccountSetup.CertificatesSubcategory" -> "Certificates"
var subcategoryName = kvp.Key
.Replace("Subcategory", "")
.Replace("AccountSetup.", "")
.Replace("DeviceSetup.", "");
// Remove any remaining dots for clean naming
if (subcategoryName.Contains('.'))
subcategoryName = subcategoryName.Split('.').Last();
return $"Provisioning_{categoryLabel}_{subcategoryName}_Failed";
_logger.Warning($"Unexpected error processing provisioning status for {categoryLabel}: {ex.Message}");
return ProvisioningResult.NoAction;
}
return $"Provisioning_{categoryLabel}_Failed";
}
/// <summary>
/// Result of processing a single category status value.
/// Checks whether categorySucceeded has transitioned from null to true/false.
/// This ensures we only emit one event when the outcome is decided.
/// </summary>
private readonly struct CategoryStatusResult
private bool HasCategorySucceededChanged(string categoryName, bool? newValue)
{
public readonly bool HasValue;
public readonly bool Value; // true = succeeded, false = failed
if (!_lastCategorySucceeded.TryGetValue(categoryName, out var oldValue))
return false; // First-seen is handled separately
// Transition from null (in-progress) to true/false (resolved)
return oldValue != newValue;
}
private void EmitProvisioningEvent(string categoryLabel, bool? succeeded, string statusText,
List<SubcategoryInfo> subcategories, EventSeverity severity)
{
var eventData = new Dictionary<string, object>
{
{ "category", categoryLabel },
{ "categorySucceeded", succeeded?.ToString() ?? "in_progress" },
{ "categoryStatusMessage", statusText }
};
if (subcategories.Count > 0)
{
var subcatData = new Dictionary<string, object>();
foreach (var sub in subcategories)
{
subcatData[sub.Name] = new Dictionary<string, string>
{
{ "state", sub.State },
{ "statusText", sub.StatusText }
};
}
eventData["subcategories"] = subcatData;
}
_onEventCollected(new EnrollmentEvent
{
SessionId = _sessionId,
TenantId = _tenantId,
EventType = Constants.EventTypes.EspProvisioningStatus,
Severity = severity,
Source = "EspAndHelloTracker",
Phase = EnrollmentPhase.Unknown,
Message = $"ESP provisioning status: {categoryLabel} — {statusText}",
Data = eventData
});
_logger.Info($"Provisioning status event: {categoryLabel} — {statusText} (succeeded={succeeded?.ToString() ?? "in_progress"})");
}
// ===== JSON Parsing Helpers =====
/// <summary>
/// Parses subcategory entries from the JSON. Handles both formats:
/// Flat: "CertificatesSubcategory": "Certificates (1 of 1 applied)"
/// Nested: "CertificatesSubcategory": { "subcategoryState": "succeeded", "subcategoryStatusText": "..." }
/// </summary>
private static List<SubcategoryInfo> ParseSubcategories(JsonElement root)
{
var result = new List<SubcategoryInfo>();
foreach (var prop in root.EnumerateObject())
{
if (!prop.Name.Contains("Subcategory", StringComparison.OrdinalIgnoreCase))
continue;
var name = CleanSubcategoryName(prop.Name);
switch (prop.Value.ValueKind)
{
case JsonValueKind.String:
// Flat format: value is the status text, derive state from text content
var text = prop.Value.GetString() ?? "";
result.Add(new SubcategoryInfo
{
Name = name,
State = InferStateFromText(text),
StatusText = text
});
break;
case JsonValueKind.Object:
// Nested format: { "subcategoryState": "...", "subcategoryStatusText": "..." }
result.Add(new SubcategoryInfo
{
Name = name,
State = SafeGetString(prop.Value, "subcategoryState") ?? "unknown",
StatusText = SafeGetString(prop.Value, "subcategoryStatusText") ?? ""
});
break;
default:
// Unknown format — include with raw value for debugging
result.Add(new SubcategoryInfo
{
Name = name,
State = "unknown",
StatusText = prop.Value.ToString()
});
break;
}
}
return result;
}
/// <summary>
/// Cleans a subcategory property name to a readable short name.
/// "AccountSetup.CertificatesSubcategory" -> "Certificates"
/// "SecurityPoliciesSubcategory" -> "SecurityPolicies"
/// </summary>
private static string CleanSubcategoryName(string rawName)
{
var name = rawName;
// Remove "Subcategory" suffix
var idx = name.IndexOf("Subcategory", StringComparison.OrdinalIgnoreCase);
if (idx >= 0)
name = name.Substring(0, idx);
// Remove category prefix (e.g. "AccountSetup.", "DeviceSetup.", "DevicePreparation.")
var dotIdx = name.LastIndexOf('.');
if (dotIdx >= 0 && dotIdx < name.Length - 1)
name = name.Substring(dotIdx + 1);
return string.IsNullOrEmpty(name) ? rawName : name;
}
/// <summary>
/// Infers a state string from flat-format subcategory text.
/// Used when the JSON doesn't have explicit subcategoryState.
/// </summary>
private static string InferStateFromText(string text)
{
if (string.IsNullOrEmpty(text))
return "unknown";
if (text.Contains("Complete", StringComparison.OrdinalIgnoreCase)
|| text.Contains("applied", StringComparison.OrdinalIgnoreCase)
|| text.Contains("installed", StringComparison.OrdinalIgnoreCase)
|| text.Contains("added", StringComparison.OrdinalIgnoreCase)
|| text.Contains("No setup needed", StringComparison.OrdinalIgnoreCase)
|| text.Contains("Identified", StringComparison.OrdinalIgnoreCase))
{
return "succeeded";
}
if (text.Contains("Error", StringComparison.OrdinalIgnoreCase)
|| text.Contains("Failed", StringComparison.OrdinalIgnoreCase)
|| text.Contains("Failure", StringComparison.OrdinalIgnoreCase))
{
return "failed";
}
return "in_progress";
}
/// <summary>
/// Finds the first subcategory that is not in a succeeded state.
/// Returns the clean name, or null if all succeeded (generic failure).
/// </summary>
private static string FindFailedSubcategory(List<SubcategoryInfo> subcategories)
{
foreach (var sub in subcategories)
{
if (!string.Equals(sub.State, "succeeded", StringComparison.OrdinalIgnoreCase)
&& !string.Equals(sub.State, "notRequired", StringComparison.OrdinalIgnoreCase))
{
return sub.Name;
}
}
return null;
}
/// <summary>
/// Builds a human-readable progress summary from subcategory states.
/// E.g. "3 of 5 subcategories completed"
/// </summary>
private static string BuildProgressSummary(List<SubcategoryInfo> subcategories)
{
if (subcategories.Count == 0)
return "In progress";
var succeeded = subcategories.Count(s =>
string.Equals(s.State, "succeeded", StringComparison.OrdinalIgnoreCase)
|| string.Equals(s.State, "notRequired", StringComparison.OrdinalIgnoreCase));
var failed = subcategories.Count(s =>
string.Equals(s.State, "failed", StringComparison.OrdinalIgnoreCase));
if (failed > 0)
return $"{failed} of {subcategories.Count} subcategories failed";
return $"{succeeded} of {subcategories.Count} subcategories completed";
}
/// <summary>
/// Safely extracts a boolean from a JSON property. Handles True, False, and string "true"/"false".
/// Returns null if the property doesn't exist or has an unexpected type.
/// </summary>
private static bool? SafeGetBool(JsonElement element, string propertyName)
{
if (!element.TryGetProperty(propertyName, out var prop))
return null;
switch (prop.ValueKind)
{
case JsonValueKind.True: return true;
case JsonValueKind.False: return false;
case JsonValueKind.String:
return bool.TryParse(prop.GetString(), out var parsed) ? parsed : (bool?)null;
default: return null;
}
}
/// <summary>
/// Safely extracts a string from a JSON property. Returns null if missing or not a string.
/// </summary>
private static string SafeGetString(JsonElement element, string propertyName)
{
if (!element.TryGetProperty(propertyName, out var prop))
return null;
return prop.ValueKind == JsonValueKind.String ? prop.GetString() : null;
}
// ===== Internal Types =====
private class SubcategoryInfo
{
public string Name { get; set; }
public string State { get; set; } // "succeeded", "failed", "in_progress", "unknown", "notRequired"
public string StatusText { get; set; } // Human-readable text
}
private readonly struct ProvisioningResult
{
public readonly bool IsFailed;
public readonly string FailureType;
private CategoryStatusResult(bool hasValue, bool value, string failureType = null)
private ProvisioningResult(bool isFailed, string failureType)
{
HasValue = hasValue;
Value = value;
IsFailed = isFailed;
FailureType = failureType;
}
public static CategoryStatusResult Succeeded => new CategoryStatusResult(true, true);
public static CategoryStatusResult AlreadyFired => new CategoryStatusResult(false, false);
public static CategoryStatusResult Unknown => new CategoryStatusResult(false, false);
public static CategoryStatusResult Failed(string failureType) => new CategoryStatusResult(true, false, failureType);
public static ProvisioningResult NoAction => new ProvisioningResult(false, null);
public static ProvisioningResult Failure(string failureType) => new ProvisioningResult(true, failureType);
}
}
}
@@ -1 +1 @@
342
347
@@ -1,10 +0,0 @@
<?xml version="1.0" encoding="utf-8" ?>
<configuration>
<runtime>
<!-- Allow WPF to render even when no display devices are detected or the window station
appears non-interactive. Required for CreateProcessAsUser from SYSTEM during Autopilot
enrollment while the Hello screen is still active on the user's desktop. Without these
switches, wpfgfx DLL init fails with 0x8007045A (ERROR_DLL_INIT_FAILED). -->
<AppContextSwitchOverrides value="Switch.System.Windows.Media.ShouldRenderEvenWhenNoDisplayDevicesAreAvailable=true;Switch.System.Windows.Media.ShouldNotRenderInNonInteractiveWindowStation=false"/>
</runtime>
</configuration>
@@ -8,8 +8,7 @@
WindowStartupLocation="CenterScreen"
Topmost="True"
WindowStyle="None"
AllowsTransparency="True"
Background="Transparent"
Background="{DynamicResource WindowBackground}"
Loaded="Window_Loaded">
<Window.Resources>
@@ -36,18 +35,10 @@
</Window.Resources>
<Grid>
<!-- Shadow + border stroke layer (not clipped, so the border line stays crisp at corners) -->
<Border CornerRadius="12" Background="{DynamicResource WindowBackground}"
BorderBrush="{DynamicResource BorderColor}" BorderThickness="1">
<Border.Effect>
<DropShadowEffect BlurRadius="24" ShadowDepth="4" Opacity="0.15" Color="#000000"/>
</Border.Effect>
</Border>
<!-- Content layer (clipped to rounded corners, no border stroke — sits inside the stroke) -->
<Border x:Name="ContentBorder" CornerRadius="11"
<!-- Content layer with border and rounded corners -->
<Border x:Name="ContentBorder" CornerRadius="12"
Background="{DynamicResource WindowBackground}"
Margin="1">
BorderBrush="{DynamicResource BorderColor}" BorderThickness="1">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/> <!-- Branding banner -->
@@ -3,9 +3,11 @@ using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Runtime.InteropServices;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Interop;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using System.Windows.Threading;
@@ -43,6 +45,7 @@ namespace AutopilotMonitor.SummaryDialog
ApplyScrollBarStyle();
ApplyContentClip();
ApplyRoundedCorners();
ApplyTaskbarIcon();
LoadStatusData();
@@ -425,6 +428,32 @@ namespace AutopilotMonitor.SummaryDialog
Resources[typeof(System.Windows.Controls.Primitives.ScrollBar)] = style;
}
/// <summary>
/// Tells Windows 11 DWM to round the window corners natively at the compositor level.
/// Without AllowsTransparency the Win32 window is rectangular — this API makes the
/// OS clip it to rounded corners, matching our Border's CornerRadius.
/// Silently ignored on Windows 10 (no visual effect, no error).
/// </summary>
private void ApplyRoundedCorners()
{
try
{
var hwnd = new WindowInteropHelper(this).Handle;
if (hwnd == IntPtr.Zero) return;
// DWMWA_WINDOW_CORNER_PREFERENCE = 33, DWMWCP_ROUND = 2
int preference = 2;
DwmSetWindowAttribute(hwnd, 33, ref preference, sizeof(int));
}
catch (Exception ex)
{
App.Log($"ApplyRoundedCorners: {ex.Message}");
}
}
[DllImport("dwmapi.dll", PreserveSig = true)]
private static extern int DwmSetWindowAttribute(IntPtr hwnd, int attribute, ref int value, int size);
/// <summary>
/// Clips the inner content Grid to a rounded rectangle so child elements
/// (like the title bar) don't bleed past the outer Border's rounded corners.
@@ -433,7 +462,7 @@ namespace AutopilotMonitor.SummaryDialog
{
// Clip the entire content Border to a rounded rectangle.
// This prevents child backgrounds from bleeding past the rounded corners.
const double radius = 11; // matches inner CornerRadius (outer 12 minus 1px border)
const double radius = 12; // matches Border CornerRadius
void UpdateClip()
{
@@ -14,7 +14,7 @@ interface ProtectedRouteProps {
* Optionally requires Galactic Admin role
*/
export function ProtectedRoute({ children, requireGalacticAdmin = false }: ProtectedRouteProps) {
const { isAuthenticated, user, isLoading } = useAuth();
const { isAuthenticated, user, isLoading, login } = useAuth();
const router = useRouter();
// Once authenticated, remember it so transient auth-state flips (e.g. MSAL
@@ -24,17 +24,27 @@ export function ProtectedRoute({ children, requireGalacticAdmin = false }: Prote
wasAuthenticated.current = true;
}
useEffect(() => {
// Only redirect if MSAL has fully settled (!isLoading) and we were never
// authenticated in this session. wasAuthenticated guards against transient
// isAuthenticated=false flips during MSAL's double handleRedirectPromise cycle.
if (!isLoading && !isAuthenticated && !wasAuthenticated.current) {
router.push("/");
}
}, [isAuthenticated, isLoading, router]);
// Prevent infinite redirect loops: only attempt re-login once per mount.
const reloginAttempted = useRef(false);
// Show loading state only on the very first load (never been authenticated yet)
if (isLoading && !wasAuthenticated.current) {
useEffect(() => {
if (!isLoading && !isAuthenticated) {
if (wasAuthenticated.current && !reloginAttempted.current) {
// Session was lost (e.g. Safari tab suspension cleared sessionStorage).
// Trigger MSAL login redirect once to re-authenticate.
reloginAttempted.current = true;
login().catch(() => {
router.push("/");
});
} else {
// Never authenticated in this session, or re-login already attempted.
router.push("/");
}
}
}, [isAuthenticated, isLoading, router, login]);
// Show loading spinner while MSAL settles or while re-login redirect is pending.
if (isLoading || (!isAuthenticated && wasAuthenticated.current)) {
return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 flex items-center justify-center">
<div className="text-center">
@@ -1,7 +1,7 @@
"use client";
import React, { createContext, useContext, useEffect, useState, useCallback, useRef } from 'react';
import { PublicClientApplication, AccountInfo, InteractionStatus } from '@azure/msal-browser';
import { PublicClientApplication, AccountInfo, InteractionStatus, InteractionRequiredAuthError } from '@azure/msal-browser';
import { MsalProvider, useMsal, useIsAuthenticated } from '@azure/msal-react';
import { msalConfig, loginRequest, apiRequest } from '@/lib/msalConfig';
import { API_BASE_URL } from '@/lib/config';
@@ -134,9 +134,25 @@ function AuthProviderInternal({ children }: { children: React.ReactNode }) {
canManageBootstrapTokens: data.canManageBootstrapTokens || false,
};
} catch (error) {
// If the refresh token is expired or consent is required, redirect to
// interactive login immediately instead of falling back to stale claims.
if (error instanceof InteractionRequiredAuthError) {
console.warn('[Auth] Interactive login required — redirecting:', error.errorCode);
try {
await instance.acquireTokenRedirect({
scopes: apiRequest.scopes,
account: account,
});
} catch (redirectError) {
console.error('[Auth] Redirect for re-auth failed:', redirectError);
}
return null;
}
console.error('[Auth] Failed to fetch user info:', error);
// Fallback to token claims if API call fails
// Fallback to token claims only for non-auth errors (network issues,
// backend cold starts, etc.) so the user can still see the app.
return {
displayName: account.name || '',
upn: account.username || '',
@@ -20,7 +20,7 @@ export const msalConfig: Configuration = {
},
cache: {
cacheLocation: "sessionStorage", // Using sessionStorage for better XSS protection
storeAuthStateInCookie: false, // Set to true for IE11 or Edge legacy
storeAuthStateInCookie: true, // Required for Safari ITP compatibility during auth redirects
},
system: {
loggerOptions: {
File diff suppressed because one or more lines are too long