mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 04:32:32 +00:00
feat(web): GA offboard button in tenant management - with type-to-confirm and ban-semantics note
The admin tenant editor gains a danger zone that queues the existing
offboarding cascade (DELETE tenants/{id}/offboard, policy already admits
GA). Guarded by a type-OFFBOARD confirmation dialog so a stray click can
never trigger it, Global Admin included.
Both the suspension block and the danger zone now spell out the
semantics: suspension is the durable lock-out lever (blocks sign-in and
auto-activation, data stays); offboarding is NOT a ban - the cascade
deletes the config row including any suspension, so a completed offboard
lets the tenant re-onboard and auto-activate on the next sign-in.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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 (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-[60] p-4">
|
||||
<div className="bg-white rounded-lg shadow-xl max-w-lg w-full">
|
||||
<div className="p-5 rounded-t-lg text-white bg-red-600">
|
||||
<h2 className="text-lg font-bold">Offboard tenant</h2>
|
||||
<p className="text-sm opacity-90 mt-0.5">{tenantLabel}</p>
|
||||
<p className="text-xs opacity-75">{tenantId}</p>
|
||||
</div>
|
||||
|
||||
<div className="p-5 space-y-3 text-sm text-gray-700">
|
||||
<p>
|
||||
This queues the offboarding cascade: the tenant is <strong>suspended immediately</strong>,
|
||||
and after a short drain window <strong>all of its data is permanently deleted</strong> —
|
||||
sessions, events, rules, admins, and the tenant configuration itself.
|
||||
</p>
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-3 text-red-800">
|
||||
<strong>This cannot be undone,</strong> and it is <strong>not a ban</strong>: 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.
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Type <span className="font-mono font-bold">OFFBOARD</span> to confirm
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={confirmText}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 px-5 py-4 border-t border-gray-200 rounded-b-lg flex justify-end space-x-3">
|
||||
<button
|
||||
onClick={onCancel}
|
||||
disabled={saving}
|
||||
className="px-4 py-2 border border-gray-300 rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
disabled={!armed || saving}
|
||||
className="px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700 disabled:opacity-50 flex items-center space-x-2"
|
||||
>
|
||||
{saving ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
|
||||
<span>Offboarding…</span>
|
||||
</>
|
||||
) : (
|
||||
<span>Offboard tenant</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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({
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Tenant Suspension */}
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
|
||||
<h3 className="font-semibold text-red-900 mb-3">Tenant Suspension</h3>
|
||||
<h3 className="font-semibold text-red-900 mb-1">Tenant Suspension</h3>
|
||||
<p className="text-xs text-gray-600 mb-3">
|
||||
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).
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
<label className="flex items-center space-x-2 cursor-pointer">
|
||||
<input
|
||||
@@ -971,6 +1024,29 @@ function TenantManagementSectionInner({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Danger Zone — offboarding cascade (own path: DELETE tenants/{id}/offboard;
|
||||
deliberately NOT part of the modal's Save button) */}
|
||||
<div className="bg-red-50 border-2 border-red-300 rounded-lg p-4">
|
||||
<h3 className="font-semibold text-red-900 mb-1">Offboard Tenant</h3>
|
||||
<p className="text-xs text-gray-600 mb-2">
|
||||
Suspends the tenant immediately and permanently deletes all of its data
|
||||
(sessions, events, rules, admins, configuration) after a short drain window.
|
||||
Same cascade as the tenant's self-service offboarding.
|
||||
</p>
|
||||
<p className="text-xs text-red-700 font-medium mb-3">
|
||||
Not a ban: the deletion includes the suspension, so once the cascade completes a
|
||||
new sign-in re-onboards (and auto-activates) the tenant. To lock a tenant out,
|
||||
suspend it above and leave its data in place.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setOffboardDialogOpen(true)}
|
||||
disabled={!canMutate || offboarding}
|
||||
className="px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700 disabled:opacity-50 text-sm font-medium"
|
||||
>
|
||||
Offboard Tenant…
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Modal Actions */}
|
||||
@@ -1001,6 +1077,17 @@ function TenantManagementSectionInner({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Offboarding confirmation (renders above the editor modal) */}
|
||||
{editingTenant && offboardDialogOpen && (
|
||||
<OffboardTenantConfirmDialog
|
||||
tenantLabel={editingTenant.domainName || editingTenant.tenantId}
|
||||
tenantId={editingTenant.tenantId}
|
||||
saving={offboarding}
|
||||
onCancel={() => setOffboardDialogOpen(false)}
|
||||
onConfirm={() => handleOffboardTenant(editingTenant)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* App-homing flip confirmation (renders above the editor modal) */}
|
||||
{editingTenant && homingDialogTarget && (
|
||||
<AppHomingConfirmDialog
|
||||
|
||||
Reference in New Issue
Block a user