added tenant offboarding

fix for user login -> only progress portal
default theme -> light
This commit is contained in:
Oliver Kieselbach
2026-02-10 16:39:01 +01:00
parent dadb897e8e
commit eeafddd223
6 changed files with 472 additions and 11 deletions
@@ -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;
/// <summary>
/// Tenant Offboarding endpoint
/// Allows a Tenant Admin to permanently delete all data for their tenant.
/// This operation is irreversible.
/// </summary>
public class TenantOffboardFunction
{
private readonly ILogger<TenantOffboardFunction> _logger;
private readonly TenantAdminsService _tenantAdminsService;
private readonly GalacticAdminService _galacticAdminService;
private readonly TableServiceClient _tableServiceClient;
public TenantOffboardFunction(
ILogger<TenantOffboardFunction> logger,
TenantAdminsService tenantAdminsService,
GalacticAdminService galacticAdminService,
IConfiguration configuration)
{
_logger = logger;
_tenantAdminsService = tenantAdminsService;
_galacticAdminService = galacticAdminService;
var connectionString = configuration["AzureTableStorageConnectionString"];
_tableServiceClient = new TableServiceClient(connectionString);
}
/// <summary>
/// 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.
/// </summary>
[Function("OffboardTenant")]
[Authorize]
public async Task<HttpResponseData> 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;
}
}
/// <summary>
/// Deletes all rows in a table where PartitionKey equals the given value.
/// Uses batched deletes (max 100 per transaction, same PartitionKey required).
/// </summary>
private async Task<int> DeleteAllRowsByPartitionKeyAsync(string tableName, string partitionKey)
{
try
{
var tableClient = _tableServiceClient.GetTableClient(tableName);
var filter = $"PartitionKey eq '{partitionKey}'";
var entities = tableClient.QueryAsync<TableEntity>(filter, select: new[] { "PartitionKey", "RowKey" });
int deleted = 0;
var batch = new List<TableTransactionAction>();
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;
}
}
/// <summary>
/// Deletes all events for a tenant. Events use PartitionKey = "{tenantId}_{sessionId}",
/// so we query with a starts-with filter.
/// </summary>
private async Task<int> 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<TableEntity>(filter, select: new[] { "PartitionKey", "RowKey" });
int deleted = 0;
// Group by PartitionKey because Azure Table batch transactions require same PartitionKey
var groups = new Dictionary<string, List<TableEntity>>();
await foreach (var entity in entities)
{
if (!groups.ContainsKey(entity.PartitionKey))
groups[entity.PartitionKey] = new List<TableEntity>();
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<string, int> DeletedCounts { get; set; } = new();
}
@@ -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 (
+12 -2
View File
@@ -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(() => {
@@ -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<TenantConfiguration | null>(null);
const [admins, setAdmins] = useState<TenantAdmin[]>([]);
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<string | null>(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 (
<ProtectedRoute>
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100">
@@ -845,6 +885,114 @@ export default function SettingsPage() {
</div>
{/* Danger Zone: Tenant Offboarding */}
{user?.isTenantAdmin && (
<div className="bg-white rounded-lg shadow border-2 border-red-200">
<div className="p-6 border-b border-red-100 bg-red-50">
<div className="flex items-center space-x-2">
<svg className="w-6 h-6 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<div>
<h2 className="text-xl font-semibold text-red-900">Danger Zone</h2>
<p className="text-sm text-red-600 mt-1">Irreversible and destructive actions</p>
</div>
</div>
</div>
<div className="p-6">
<div className="flex items-center justify-between">
<div>
<h3 className="text-base font-semibold text-gray-900">Offboard this Tenant</h3>
<p className="text-sm text-gray-500 mt-1">
Permanently deletes <strong>all data</strong> for this tenant – sessions, events, rules, audit logs, configuration, and all admin accounts including yours. This cannot be undone.
</p>
</div>
<button
onClick={() => { setShowOffboardDialog(true); setOffboardConfirmText(""); setOffboardError(null); }}
className="ml-6 flex-shrink-0 px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700 transition-colors text-sm font-medium"
>
Offboard Tenant
</button>
</div>
</div>
</div>
)}
{/* Offboard Confirmation Dialog */}
{showOffboardDialog && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-lg shadow-xl max-w-md w-full p-6">
<div className="flex items-center space-x-3 mb-4">
<div className="w-12 h-12 bg-red-100 rounded-full flex items-center justify-center flex-shrink-0">
<svg className="w-6 h-6 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
</div>
<div>
<h3 className="text-lg font-bold text-gray-900">Tenant Offboarding</h3>
<p className="text-sm text-red-600 font-medium">This action is permanent and cannot be undone.</p>
</div>
</div>
<div className="bg-red-50 border border-red-200 rounded-lg p-4 mb-4 text-sm text-red-800 space-y-1">
<p className="font-semibold">The following will be permanently deleted:</p>
<ul className="list-disc list-inside mt-2 space-y-1">
<li>All enrollment sessions and events</li>
<li>All gather and analyze rules</li>
<li>Audit logs and usage metrics</li>
<li>Tenant configuration</li>
<li>All admin accounts (including yours)</li>
</ul>
<p className="mt-3 font-semibold">After offboarding you will be signed out and lose all access.</p>
</div>
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-1">
Type <span className="font-bold text-red-600">OFFBOARD</span> to confirm
</label>
<input
type="text"
value={offboardConfirmText}
onChange={(e) => 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"
/>
</div>
{offboardError && (
<div className="mb-4 bg-red-50 border border-red-200 rounded p-3 text-sm text-red-800">
{offboardError}
</div>
)}
<div className="flex space-x-3">
<button
onClick={() => { setShowOffboardDialog(false); setOffboardConfirmText(""); setOffboardError(null); }}
disabled={offboarding}
className="flex-1 px-4 py-2 border border-gray-300 rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50 transition-colors"
>
Cancel
</button>
<button
onClick={handleOffboard}
disabled={offboarding || offboardConfirmText !== "OFFBOARD"}
className="flex-1 px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center justify-center space-x-2"
>
{offboarding ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
<span>Offboarding...</span>
</>
) : (
<span>Permanently Delete All Data</span>
)}
</button>
</div>
</div>
</div>
)}
{/* Configuration Info */}
{config && (
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
@@ -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 (
<nav className="bg-white border-b border-gray-200 shadow-sm">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between h-16">
<div className="flex items-center">
<Link href="/progress" className="flex flex-col">
<span className="text-2xl font-bold text-blue-600">Autopilot Monitor</span>
<span className="text-xs text-gray-500">v1.0.0</span>
</Link>
</div>
<div className="flex items-center space-x-2">
{/* User Menu */}
<div className="relative" ref={userMenuRef}>
<button onClick={() => setShowUserMenu(!showUserMenu)} className="flex items-center space-x-2 p-2 rounded-lg hover:bg-gray-100 transition-colors">
<div className="w-8 h-8 rounded-full bg-blue-600 flex items-center justify-center text-white font-semibold text-sm">
{(() => {
if (user?.displayName) {
const names = user.displayName.split(' ');
if (names.length >= 2) {
return `${names[0].charAt(0)}${names[names.length - 1].charAt(0)}`.toUpperCase();
}
return user.displayName.charAt(0).toUpperCase();
}
return user?.upn?.charAt(0).toUpperCase() || 'U';
})()}
</div>
<svg className="w-4 h-4 text-gray-600" fill="none" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" viewBox="0 0 24 24" stroke="currentColor">
<path d="M19 9l-7 7-7-7"></path>
</svg>
</button>
{showUserMenu && (
<div className="absolute right-0 mt-2 w-80 bg-white rounded-lg shadow-lg border border-gray-200 z-50">
<div className="px-4 py-3 border-b border-gray-200 flex items-start space-x-3">
<div className="w-10 h-10 rounded-full bg-blue-600 flex-shrink-0 flex items-center justify-center text-white font-semibold text-sm">
{(() => {
if (user?.displayName) {
const names = user.displayName.split(' ');
if (names.length >= 2) {
return `${names[0].charAt(0)}${names[names.length - 1].charAt(0)}`.toUpperCase();
}
return user.displayName.charAt(0).toUpperCase();
}
return user?.upn?.charAt(0).toUpperCase() || 'U';
})()}
</div>
<div className="min-w-0">
<p className="text-sm font-medium text-gray-900">{user?.displayName || 'User'}</p>
<p className="text-sm text-gray-600 truncate">{user?.upn}</p>
</div>
</div>
<div className="py-1">
<button onClick={() => { logout(); setShowUserMenu(false); }} className="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 flex items-center space-x-2">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
</svg>
<span>Sign out</span>
</button>
</div>
</div>
)}
</div>
</div>
</div>
</div>
</nav>
);
}
return (
<nav className="bg-white border-b border-gray-200 shadow-sm">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
@@ -17,14 +17,13 @@ const ThemeContext = createContext<ThemeContextType>({
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState<Theme>("light");
// Initialize from localStorage on mount
// Initialize from localStorage on mount, default is always "light"
useEffect(() => {
const stored = localStorage.getItem("theme") as Theme | null;
if (stored === "dark" || stored === "light") {
setTheme(stored);
} else if (window.matchMedia("(prefers-color-scheme: dark)").matches) {
setTheme("dark");
}
// No system preference fallback – default is always light
}, []);
// Apply class to <html> and persist