diff --git a/src/Web/autopilot-monitor-web/app/admin/components/OffboardTenantConfirmDialog.tsx b/src/Web/autopilot-monitor-web/app/admin/components/OffboardTenantConfirmDialog.tsx new file mode 100644 index 00000000..8447b4bb --- /dev/null +++ b/src/Web/autopilot-monitor-web/app/admin/components/OffboardTenantConfirmDialog.tsx @@ -0,0 +1,92 @@ +"use client"; + +import { useState } from "react"; + +interface OffboardTenantConfirmDialogProps { + tenantLabel: string; + tenantId: string; + saving: boolean; + onCancel: () => void; + onConfirm: () => void; +} + +/** + * GA confirmation for the irreversible tenant-offboarding cascade. Renders above the tenant + * editor modal (z-[60] vs its z-50). Type-to-confirm ("OFFBOARD") mirrors the tenant-facing + * self-service dialog — a plain OK button is not enough of a guard for a destructive action, + * Global Admin included. + */ +export function OffboardTenantConfirmDialog({ + tenantLabel, + tenantId, + saving, + onCancel, + onConfirm, +}: OffboardTenantConfirmDialogProps) { + const [confirmText, setConfirmText] = useState(""); + const armed = confirmText === "OFFBOARD"; + + return ( +
+
+
+

Offboard tenant

+

{tenantLabel}

+

{tenantId}

+
+ +
+

+ This queues the offboarding cascade: the tenant is suspended immediately, + and after a short drain window all of its data is permanently deleted — + sessions, events, rules, admins, and the tenant configuration itself. +

+
+ This cannot be undone, and it is not a ban: because the + configuration row (including any suspension) is deleted, a new sign-in from this tenant + re-onboards it from scratch — and re-activates it automatically while tenant + auto-activation is enabled. To keep an abusive tenant locked out, use suspension and do + NOT offboard. +
+
+ + setConfirmText(e.target.value)} + placeholder="OFFBOARD" + autoComplete="off" + className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm font-mono focus:ring-2 focus:ring-red-500 focus:border-red-500" + /> +
+
+ +
+ + +
+
+
+ ); +} diff --git a/src/Web/autopilot-monitor-web/app/admin/components/TenantManagementSection.tsx b/src/Web/autopilot-monitor-web/app/admin/components/TenantManagementSection.tsx index 98469714..6832d600 100644 --- a/src/Web/autopilot-monitor-web/app/admin/components/TenantManagementSection.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/components/TenantManagementSection.tsx @@ -9,6 +9,7 @@ import { appHomingErrorMessage } from "@/lib/appHoming"; import { trackEvent } from "@/lib/appInsights"; import { TenantAdminSection } from "./TenantAdminSection"; import { AppHomingConfirmDialog } from "./AppHomingConfirmDialog"; +import { OffboardTenantConfirmDialog } from "./OffboardTenantConfirmDialog"; import { useCanMutatePlatform } from "@/hooks/useCanMutatePlatform"; export interface TenantConfiguration { @@ -125,6 +126,8 @@ function TenantManagementSectionInner({ const [savingPlan, setSavingPlan] = useState(false); const [homingDialogTarget, setHomingDialogTarget] = useState<"primary" | "legacy" | null>(null); const [savingHoming, setSavingHoming] = useState(false); + const [offboardDialogOpen, setOffboardDialogOpen] = useState(false); + const [offboarding, setOffboarding] = useState(false); const [currentPage, setCurrentPage] = useState(0); const tenantsPerPage = tenantSectionExpanded ? 7 : 3; @@ -309,6 +312,51 @@ function TenantManagementSectionInner({ } }; + // Offboarding has its OWN path (DELETE tenants/{id}/offboard) — a queued cascade, not part + // of the modal's Save. The backend suspends the tenant immediately (Disabled-gate) and + // deletes all data after the drain barrier; the endpoint is idempotent on re-click. + const handleOffboardTenant = async (tenant: TenantConfiguration) => { + if (!canMutate) return; // read-only Global Reader + try { + setOffboarding(true); + setError(null); + setSuccessMessage(null); + + const response = await authenticatedFetch(api.tenants.offboard(tenant.tenantId), getAccessToken, { + method: "DELETE", + }); + const data = await response.json().catch(() => ({})); + if (!response.ok) { + trackEvent("admin_tenant_offboard_failed", { + tenantId: tenant.tenantId, + reason: data.error ?? `http-${response.status}`, + }); + throw new Error(data.error || `Failed to offboard tenant: ${response.statusText}`); + } + trackEvent("admin_tenant_offboarded", { + tenantId: tenant.tenantId, + status: data.status ?? "unknown", + }); + + setOffboardDialogOpen(false); + setEditingTenant(null); + // The tenant now shows as suspended ("Offboarding in progress") until the cascade + // removes its row entirely — refresh instead of patching local state. + fetchTenants(); + setSuccessMessage(data.message || `Offboarding queued for tenant ${tenant.tenantId}`); + setTimeout(() => setSuccessMessage(null), 8000); + } catch (err) { + if (err instanceof TokenExpiredError) { + console.error("Session expired while offboarding tenant"); + } else { + console.error("Error offboarding tenant:", err); + } + setError(err instanceof Error ? err.message : "Failed to offboard tenant"); + } finally { + setOffboarding(false); + } + }; + const handleSendWelcomeEmail = async (tenantId: string, email?: string) => { if (!canMutate) return; // read-only Global Reader try { @@ -640,7 +688,12 @@ function TenantManagementSectionInner({
{/* Tenant Suspension */}
-

Tenant Suspension

+

Tenant Suspension

+

+ Blocks sign-in and tenant auto-activation while the tenant's data stays in + place — this is the durable lock-out lever for abuse cases (offboarding below is + not: it deletes the suspension along with everything else). +

{/* Modal Actions */} @@ -1001,6 +1077,17 @@ function TenantManagementSectionInner({
)} + {/* Offboarding confirmation (renders above the editor modal) */} + {editingTenant && offboardDialogOpen && ( + setOffboardDialogOpen(false)} + onConfirm={() => handleOffboardTenant(editingTenant)} + /> + )} + {/* App-homing flip confirmation (renders above the editor modal) */} {editingTenant && homingDialogTarget && (