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:
Oliver Kieselbach
2026-08-02 16:48:53 +02:00
parent d3a10dfb96
commit 86bb255b11
2 changed files with 180 additions and 1 deletions
@@ -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&apos;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&apos;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