From eeafddd22374432f302b13d0fe57d136d77224f0 Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Tue, 10 Feb 2026 16:39:01 +0100 Subject: [PATCH] added tenant offboarding fix for user login -> only progress portal default theme -> light --- .../Functions/TenantOffboardFunction.cs | 228 ++++++++++++++++++ .../app/landing/page.tsx | 14 +- src/Web/autopilot-monitor-web/app/page.tsx | 14 +- .../app/settings/page.tsx | 150 +++++++++++- .../components/Navbar.tsx | 72 ++++++ .../contexts/ThemeContext.tsx | 5 +- 6 files changed, 472 insertions(+), 11 deletions(-) create mode 100644 src/Backend/AutopilotMonitor.Functions/Functions/TenantOffboardFunction.cs diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/TenantOffboardFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/TenantOffboardFunction.cs new file mode 100644 index 00000000..4ce2af5f --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Functions/TenantOffboardFunction.cs @@ -0,0 +1,228 @@ +using System.Net; +using AutopilotMonitor.Functions.Extensions; +using AutopilotMonitor.Functions.Helpers; +using AutopilotMonitor.Functions.Services; +using AutopilotMonitor.Shared; +using Azure.Data.Tables; +using Microsoft.AspNetCore.Authorization; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Azure.Functions.Worker.Http; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; + +namespace AutopilotMonitor.Functions.Functions; + +/// +/// Tenant Offboarding endpoint +/// Allows a Tenant Admin to permanently delete all data for their tenant. +/// This operation is irreversible. +/// +public class TenantOffboardFunction +{ + private readonly ILogger _logger; + private readonly TenantAdminsService _tenantAdminsService; + private readonly GalacticAdminService _galacticAdminService; + private readonly TableServiceClient _tableServiceClient; + + public TenantOffboardFunction( + ILogger logger, + TenantAdminsService tenantAdminsService, + GalacticAdminService galacticAdminService, + IConfiguration configuration) + { + _logger = logger; + _tenantAdminsService = tenantAdminsService; + _galacticAdminService = galacticAdminService; + var connectionString = configuration["AzureTableStorageConnectionString"]; + _tableServiceClient = new TableServiceClient(connectionString); + } + + /// + /// DELETE /api/tenants/{tenantId}/offboard + /// Permanently deletes ALL data for a tenant across all tables. + /// Accessible by: Tenant Admins of the same tenant OR Galactic Admins. + /// This action is IRREVERSIBLE. + /// + [Function("OffboardTenant")] + [Authorize] + public async Task OffboardTenant( + [HttpTrigger(AuthorizationLevel.Anonymous, "delete", Route = "tenants/{tenantId}/offboard")] HttpRequestData req, + string tenantId, + FunctionContext context) + { + var principal = context.GetUser(); + if (principal == null) + { + return req.CreateResponse(HttpStatusCode.Unauthorized); + } + + var userTenantId = principal.GetTenantId(); + var upn = principal.GetUserPrincipalName(); + + // Only Tenant Admins of the same tenant OR Galactic Admins may offboard + var isGalacticAdmin = await _galacticAdminService.IsGalacticAdminAsync(upn); + var isTenantAdmin = tenantId.Equals(userTenantId, StringComparison.OrdinalIgnoreCase) && + await _tenantAdminsService.IsTenantAdminAsync(tenantId, upn); + + if (!isGalacticAdmin && !isTenantAdmin) + { + _logger.LogWarning($"User {upn} attempted to offboard tenant {tenantId} without authorization"); + var forbiddenResponse = req.CreateResponse(HttpStatusCode.Forbidden); + await forbiddenResponse.WriteAsJsonAsync(new + { + error = "Access denied. Only a Tenant Admin of this tenant may perform offboarding." + }); + return forbiddenResponse; + } + + _logger.LogWarning($"TENANT OFFBOARD initiated for tenant {tenantId} by {upn}"); + + var result = new OffboardResult { TenantId = tenantId, InitiatedBy = upn!, InitiatedAt = DateTime.UtcNow }; + + try + { + // Tables where PartitionKey = tenantId (normalized lowercase) + var tenantPartitionTables = new[] + { + Constants.TableNames.Sessions, + Constants.TableNames.AuditLogs, + Constants.TableNames.UsageMetrics, + Constants.TableNames.UserActivity, + Constants.TableNames.RuleResults, + Constants.TableNames.GatherRules, + Constants.TableNames.AnalyzeRules, + Constants.TableNames.AppInstallSummaries, + Constants.TableNames.TenantConfiguration, + Constants.TableNames.TenantAdmins, + }; + + var normalizedTenantId = tenantId.ToLowerInvariant(); + + foreach (var tableName in tenantPartitionTables) + { + var deleted = await DeleteAllRowsByPartitionKeyAsync(tableName, normalizedTenantId); + result.DeletedCounts[tableName] = deleted; + _logger.LogInformation($"Offboard [{tenantId}] {tableName}: deleted {deleted} rows"); + } + + // Events table uses PartitionKey = "{tenantId}_{sessionId}" – query by prefix + var eventsDeleted = await DeleteEventsByTenantPrefixAsync(normalizedTenantId); + result.DeletedCounts[Constants.TableNames.Events] = eventsDeleted; + _logger.LogInformation($"Offboard [{tenantId}] Events: deleted {eventsDeleted} rows"); + + result.Success = true; + _logger.LogWarning($"TENANT OFFBOARD completed for tenant {tenantId} by {upn}. " + + $"Total rows deleted: {result.DeletedCounts.Values.Sum()}"); + + var okResponse = req.CreateResponse(HttpStatusCode.OK); + await okResponse.WriteAsJsonAsync(result); + return okResponse; + } + catch (Exception ex) + { + _logger.LogError(ex, $"Tenant offboard failed for tenant {tenantId}"); + result.Success = false; + result.Error = ex.Message; + + var errorResponse = req.CreateResponse(HttpStatusCode.InternalServerError); + await errorResponse.WriteAsJsonAsync(result); + return errorResponse; + } + } + + /// + /// Deletes all rows in a table where PartitionKey equals the given value. + /// Uses batched deletes (max 100 per transaction, same PartitionKey required). + /// + private async Task DeleteAllRowsByPartitionKeyAsync(string tableName, string partitionKey) + { + try + { + var tableClient = _tableServiceClient.GetTableClient(tableName); + var filter = $"PartitionKey eq '{partitionKey}'"; + var entities = tableClient.QueryAsync(filter, select: new[] { "PartitionKey", "RowKey" }); + + int deleted = 0; + var batch = new List(); + + await foreach (var entity in entities) + { + batch.Add(new TableTransactionAction(TableTransactionActionType.Delete, entity)); + deleted++; + + if (batch.Count >= 100) + { + await tableClient.SubmitTransactionAsync(batch); + batch.Clear(); + } + } + + if (batch.Count > 0) + { + await tableClient.SubmitTransactionAsync(batch); + } + + return deleted; + } + catch (Exception ex) + { + _logger.LogError(ex, $"Failed to delete rows in {tableName} for partition {partitionKey}"); + return 0; + } + } + + /// + /// Deletes all events for a tenant. Events use PartitionKey = "{tenantId}_{sessionId}", + /// so we query with a starts-with filter. + /// + private async Task DeleteEventsByTenantPrefixAsync(string normalizedTenantId) + { + try + { + var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.Events); + // OData startsWith: PartitionKey ge 'prefix' and PartitionKey lt 'prefix~' + var prefix = normalizedTenantId + "_"; + var filter = $"PartitionKey ge '{prefix}' and PartitionKey lt '{prefix}~'"; + var entities = tableClient.QueryAsync(filter, select: new[] { "PartitionKey", "RowKey" }); + + int deleted = 0; + // Group by PartitionKey because Azure Table batch transactions require same PartitionKey + var groups = new Dictionary>(); + + await foreach (var entity in entities) + { + if (!groups.ContainsKey(entity.PartitionKey)) + groups[entity.PartitionKey] = new List(); + groups[entity.PartitionKey].Add(entity); + deleted++; + } + + foreach (var group in groups.Values) + { + for (int i = 0; i < group.Count; i += 100) + { + var chunk = group.Skip(i).Take(100).ToList(); + var batch = chunk.Select(e => new TableTransactionAction(TableTransactionActionType.Delete, e)).ToList(); + await tableClient.SubmitTransactionAsync(batch); + } + } + + return deleted; + } + catch (Exception ex) + { + _logger.LogError(ex, $"Failed to delete events for tenant {normalizedTenantId}"); + return 0; + } + } +} + +public class OffboardResult +{ + public string TenantId { get; set; } = string.Empty; + public string InitiatedBy { get; set; } = string.Empty; + public DateTime InitiatedAt { get; set; } + public bool Success { get; set; } + public string? Error { get; set; } + public Dictionary DeletedCounts { get; set; } = new(); +} diff --git a/src/Web/autopilot-monitor-web/app/landing/page.tsx b/src/Web/autopilot-monitor-web/app/landing/page.tsx index 9a350a69..aef6d646 100644 --- a/src/Web/autopilot-monitor-web/app/landing/page.tsx +++ b/src/Web/autopilot-monitor-web/app/landing/page.tsx @@ -12,19 +12,23 @@ const PLATFORM_STATS = { }; export default function LandingPage() { - const { login, isAuthenticated, isLoading } = useAuth(); + const { login, isAuthenticated, isLoading, user } = useAuth(); const router = useRouter(); const searchParams = useSearchParams(); // Check for preview mode (for debugging while logged in) const previewMode = searchParams.get("preview") === "true"; - // Redirect to dashboard if already authenticated (unless in preview mode) + // Redirect after login: regular users go to /progress, admins go to dashboard useEffect(() => { - if (isAuthenticated && !isLoading && !previewMode) { - router.push("/"); + if (isAuthenticated && !isLoading && !previewMode && user) { + if (user.isTenantAdmin || user.isGalacticAdmin) { + router.push("/"); + } else { + router.push("/progress"); + } } - }, [isAuthenticated, isLoading, previewMode, router]); + }, [isAuthenticated, isLoading, previewMode, user, router]); if (isLoading) { return ( diff --git a/src/Web/autopilot-monitor-web/app/page.tsx b/src/Web/autopilot-monitor-web/app/page.tsx index e96acdad..326513d4 100644 --- a/src/Web/autopilot-monitor-web/app/page.tsx +++ b/src/Web/autopilot-monitor-web/app/page.tsx @@ -100,8 +100,18 @@ export default function Home() { } }; - // Initial data fetch + // Redirect regular users (non-admin) to progress portal – they must never see the session list useEffect(() => { + if (user && !user.isTenantAdmin && !user.isGalacticAdmin) { + router.replace("/progress"); + } + }, [user, router]); + + // Initial data fetch (only runs for admins) + useEffect(() => { + if (user && !user.isTenantAdmin && !user.isGalacticAdmin) { + return; // regular users are being redirected, don't fetch + } // Prevent duplicate fetches in React StrictMode (development double-mounting) if (hasInitialFetch.current) { return; @@ -111,7 +121,7 @@ export default function Home() { // Only fetch sessions on load, no automatic health check fetchSessions(); // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); // Empty deps - only run once on mount + }, [user]); // re-run once user is known // Join tenant group when SignalR is connected (for multi-tenancy) useEffect(() => { diff --git a/src/Web/autopilot-monitor-web/app/settings/page.tsx b/src/Web/autopilot-monitor-web/app/settings/page.tsx index 44394bd4..c0e726c5 100644 --- a/src/Web/autopilot-monitor-web/app/settings/page.tsx +++ b/src/Web/autopilot-monitor-web/app/settings/page.tsx @@ -33,7 +33,7 @@ interface TenantAdmin { export default function SettingsPage() { const router = useRouter(); const { tenantId } = useTenant(); - const { getAccessToken, user } = useAuth(); + const { getAccessToken, user, logout } = useAuth(); const [config, setConfig] = useState(null); const [admins, setAdmins] = useState([]); const [loading, setLoading] = useState(true); @@ -48,6 +48,12 @@ export default function SettingsPage() { const [adminSearchQuery, setAdminSearchQuery] = useState(""); const [currentAdminPage, setCurrentAdminPage] = useState(0); + // Offboard state + const [showOffboardDialog, setShowOffboardDialog] = useState(false); + const [offboardConfirmText, setOffboardConfirmText] = useState(""); + const [offboarding, setOffboarding] = useState(false); + const [offboardError, setOffboardError] = useState(null); + // Form state const [manufacturerWhitelist, setManufacturerWhitelist] = useState("Dell*,HP*,Lenovo*,Microsoft Corporation"); const [modelWhitelist, setModelWhitelist] = useState("*"); @@ -348,6 +354,40 @@ export default function SettingsPage() { }; + const handleOffboard = async () => { + if (!tenantId) return; + + try { + setOffboarding(true); + setOffboardError(null); + + const token = await getAccessToken(); + if (!token) throw new Error('Failed to get access token'); + + const response = await fetch(`${API_BASE_URL}/api/tenants/${tenantId}/offboard`, { + method: 'DELETE', + headers: { 'Authorization': `Bearer ${token}` }, + }); + + if (!response.ok) { + const data = await response.json().catch(() => ({})); + throw new Error(data?.error || `Offboard failed: ${response.statusText}`); + } + + // Offboard successful – sign out the user as their admin access is gone + logout(); + } catch (err) { + setOffboardError(err instanceof Error ? err.message : 'Offboard failed'); + setOffboarding(false); + } + }; + + // Redirect regular users (non-TenantAdmin) to progress portal + if (user && !user.isTenantAdmin && !user.isGalacticAdmin) { + router.replace('/progress'); + return null; + } + return (
@@ -845,6 +885,114 @@ export default function SettingsPage() {
+ {/* Danger Zone: Tenant Offboarding */} + {user?.isTenantAdmin && ( +
+
+
+ + + +
+

Danger Zone

+

Irreversible and destructive actions

+
+
+
+
+
+
+

Offboard this Tenant

+

+ Permanently deletes all data for this tenant – sessions, events, rules, audit logs, configuration, and all admin accounts including yours. This cannot be undone. +

+
+ +
+
+
+ )} + + {/* Offboard Confirmation Dialog */} + {showOffboardDialog && ( +
+
+
+
+ + + +
+
+

Tenant Offboarding

+

This action is permanent and cannot be undone.

+
+
+ +
+

The following will be permanently deleted:

+
    +
  • All enrollment sessions and events
  • +
  • All gather and analyze rules
  • +
  • Audit logs and usage metrics
  • +
  • Tenant configuration
  • +
  • All admin accounts (including yours)
  • +
+

After offboarding you will be signed out and lose all access.

+
+ +
+ + setOffboardConfirmText(e.target.value)} + placeholder="OFFBOARD" + className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-red-500 focus:border-red-500" + autoComplete="off" + /> +
+ + {offboardError && ( +
+ {offboardError} +
+ )} + +
+ + +
+
+
+ )} + {/* Configuration Info */} {config && (
diff --git a/src/Web/autopilot-monitor-web/components/Navbar.tsx b/src/Web/autopilot-monitor-web/components/Navbar.tsx index 6e863086..8c2b8622 100644 --- a/src/Web/autopilot-monitor-web/components/Navbar.tsx +++ b/src/Web/autopilot-monitor-web/components/Navbar.tsx @@ -104,6 +104,78 @@ export default function Navbar() { return `${Math.floor(diffHours / 24)}d ago`; }; + const isTenantAdmin = user?.isTenantAdmin ?? false; + + // Regular users (non-TenantAdmin): show minimal navbar with only Progress Portal + if (!isTenantAdmin && !user?.isGalacticAdmin) { + return ( + + ); + } + return (