Add possibility for admin to suspend/delete

This commit is contained in:
2026-04-11 12:39:34 +02:00
parent 97b483c121
commit 1bd51ecb5d
8 changed files with 480 additions and 218 deletions

View File

@@ -16,14 +16,24 @@ interface AdminPanelProps {
export function AdminPanel({ initialTenants }: AdminPanelProps) {
const t = useTranslations("admin");
const [tab, setTab] = useState<Tab>("requests");
// Requests state
const [requests, setRequests] = useState<TenantRequest[]>([]);
const [filter, setFilter] = useState<RequestFilter>("all");
const [loading, setLoading] = useState(true);
const [loadingRequests, setLoadingRequests] = useState(true);
const [actionLoading, setActionLoading] = useState<string | null>(null);
const [rejectModal, setRejectModal] = useState<string | null>(null);
const [rejectNotes, setRejectNotes] = useState("");
// Tenants state
const [tenants, setTenants] = useState<PiecedTenant[]>(initialTenants);
const [loadingTenants, setLoadingTenants] = useState(false);
const [deleteModal, setDeleteModal] = useState<string | null>(null);
// Shared
const [error, setError] = useState("");
// ─── Requests fetching ───
const fetchRequests = useCallback(async () => {
try {
const url =
@@ -37,17 +47,39 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
} catch (e: any) {
setError(e.message);
} finally {
setLoading(false);
setLoadingRequests(false);
}
}, [filter]);
useEffect(() => {
if (tab === "requests") {
setLoading(true);
setLoadingRequests(true);
fetchRequests();
}
}, [tab, filter, fetchRequests]);
// ─── Tenants fetching ───
const fetchTenants = useCallback(async () => {
setLoadingTenants(true);
try {
const res = await fetch("/api/tenants");
if (!res.ok) throw new Error("Failed to fetch tenants");
const data = await res.json();
setTenants(data);
} catch (e: any) {
setError(e.message);
} finally {
setLoadingTenants(false);
}
}, []);
useEffect(() => {
if (tab === "tenants") {
fetchTenants();
}
}, [tab, fetchTenants]);
// ─── Request actions ───
const handleApprove = async (id: string) => {
setActionLoading(id);
setError("");
@@ -90,6 +122,48 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
}
};
// ─── Tenant actions ───
const handleSuspend = async (name: string, suspend: boolean) => {
setActionLoading(name);
setError("");
try {
const res = await fetch(`/api/admin/tenants/${name}/suspend`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ suspend }),
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || "Suspend failed");
}
await fetchTenants();
} catch (e: any) {
setError(e.message);
} finally {
setActionLoading(null);
}
};
const handleDelete = async (name: string) => {
setActionLoading(name);
setError("");
try {
const res = await fetch(`/api/admin/tenants/${name}/delete`, {
method: "POST",
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || "Delete failed");
}
setDeleteModal(null);
await fetchTenants();
} catch (e: any) {
setError(e.message);
} finally {
setActionLoading(null);
}
};
const FILTERS: RequestFilter[] = [
"all",
"pending",
@@ -132,7 +206,7 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
>
{t("allTenants")}
<span className="ml-1.5 text-xs text-text-muted tabular-nums">
{initialTenants.length}
{tenants.length}
</span>
{tab === "tenants" && (
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-accent" />
@@ -172,7 +246,7 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
))}
</div>
{loading ? (
{loadingRequests ? (
<div className="bg-surface-1 border border-border rounded-xl p-12 text-center">
<div className="h-5 w-5 border-2 border-accent border-t-transparent rounded-full animate-spin mx-auto mb-2" />
<p className="text-text-muted text-xs">{t("loadingRequests")}</p>
@@ -266,7 +340,8 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
</>
)}
{(req.status === "provisioning" ||
req.status === "approved") &&
req.status === "approved" ||
req.status === "active") &&
req.tenantName && (
<Link
href={`/tenants/${req.tenantName}`}
@@ -304,40 +379,41 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
{/* ───── TENANTS TAB ───── */}
{tab === "tenants" && (
<>
{/* Summary cards */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mb-6">
<SummaryCard
label={t("totalTenants")}
value={initialTenants.length}
value={tenants.length}
/>
<SummaryCard
label={t("running")}
value={
initialTenants.filter((t) => t.status?.phase === "Running")
.length
tenants.filter(
(t) => t.status?.phase === "Running" || t.status?.phase === "Ready"
).length
}
color="text-emerald-400"
/>
<SummaryCard
label={t("provisioning")}
value={
initialTenants.filter(
(t) =>
t.status?.phase === "Provisioning" ||
t.status?.phase === "Pending"
).length
}
label={t("suspended")}
value={tenants.filter((t) => t.spec.suspend).length}
color="text-amber-400"
/>
<SummaryCard
label={t("errors")}
value={
initialTenants.filter((t) => t.status?.phase === "Error").length
tenants.filter((t) => t.status?.phase === "Error").length
}
color="text-red-400"
/>
</div>
{initialTenants.length === 0 ? (
{loadingTenants ? (
<div className="bg-surface-1 border border-border rounded-xl p-12 text-center">
<div className="h-5 w-5 border-2 border-accent border-t-transparent rounded-full animate-spin mx-auto mb-2" />
<p className="text-text-muted text-xs">{t("loadingTenants")}</p>
</div>
) : tenants.length === 0 ? (
<div className="bg-surface-1 border border-border rounded-xl p-12 text-center">
<p className="text-text-secondary text-sm">{t("noTenants")}</p>
</div>
@@ -347,60 +423,94 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border text-left">
<th className="px-5 py-3 text-xs font-semibold uppercase tracking-wider text-text-muted">
<th className="px-4 py-3 text-xs font-semibold uppercase tracking-wider text-text-muted">
{t("name")}
</th>
<th className="px-5 py-3 text-xs font-semibold uppercase tracking-wider text-text-muted">
<th className="px-4 py-3 text-xs font-semibold uppercase tracking-wider text-text-muted">
{t("displayName")}
</th>
<th className="px-5 py-3 text-xs font-semibold uppercase tracking-wider text-text-muted">
<th className="px-4 py-3 text-xs font-semibold uppercase tracking-wider text-text-muted">
{t("phase")}
</th>
<th className="px-5 py-3 text-xs font-semibold uppercase tracking-wider text-text-muted hidden md:table-cell">
<th className="px-4 py-3 text-xs font-semibold uppercase tracking-wider text-text-muted hidden md:table-cell">
{t("packages")}
</th>
<th className="px-5 py-3 text-xs font-semibold uppercase tracking-wider text-text-muted hidden md:table-cell">
<th className="px-4 py-3 text-xs font-semibold uppercase tracking-wider text-text-muted hidden md:table-cell">
{t("created")}
</th>
<th className="px-5 py-3 text-xs font-semibold uppercase tracking-wider text-text-muted">
<th className="px-4 py-3 text-xs font-semibold uppercase tracking-wider text-text-muted">
{t("actions")}
</th>
</tr>
</thead>
<tbody>
{initialTenants.map((tenant) => (
{tenants.map((tenant) => (
<tr
key={tenant.metadata.name}
className="border-b border-border last:border-0 hover:bg-surface-2/50 transition-colors"
className={`border-b border-border last:border-0 hover:bg-surface-2/50 transition-colors ${
tenant.spec.suspend ? "opacity-60" : ""
}`}
>
<td className="px-5 py-3 font-mono text-xs text-accent">
<td className="px-4 py-3 font-mono text-xs text-accent">
{tenant.metadata.name}
</td>
<td className="px-5 py-3 text-text-primary">
{tenant.spec.displayName}
<td className="px-4 py-3 text-text-primary">
<span>{tenant.spec.displayName}</span>
{tenant.spec.suspend && (
<span className="ml-2 px-1.5 py-0.5 text-[10px] font-medium bg-amber-400/15 text-amber-400 rounded">
{t("suspendedBadge")}
</span>
)}
</td>
<td className="px-5 py-3">
<td className="px-4 py-3">
<StatusBadge
phase={tenant.status?.phase ?? "Pending"}
/>
</td>
<td className="px-5 py-3 text-xs text-text-secondary font-mono hidden md:table-cell">
<td className="px-4 py-3 text-xs text-text-secondary font-mono hidden md:table-cell">
{tenant.spec.packages?.join(", ") || "—"}
</td>
<td className="px-5 py-3 text-xs text-text-muted tabular-nums hidden md:table-cell">
<td className="px-4 py-3 text-xs text-text-muted tabular-nums hidden md:table-cell">
{tenant.metadata.creationTimestamp
? new Date(
tenant.metadata.creationTimestamp
).toLocaleDateString()
: "—"}
</td>
<td className="px-5 py-3">
<Link
href={`/tenants/${tenant.metadata.name}`}
className="px-2.5 py-1 text-xs font-medium bg-accent/15 text-accent rounded-md hover:bg-accent/25 transition-colors"
>
{t("manage")}
</Link>
<td className="px-4 py-3">
<div className="flex gap-1.5 flex-wrap">
<Link
href={`/tenants/${tenant.metadata.name}`}
className="px-2.5 py-1 text-xs font-medium bg-accent/15 text-accent rounded-md hover:bg-accent/25 transition-colors"
>
{t("manage")}
</Link>
<button
onClick={() =>
handleSuspend(
tenant.metadata.name,
!tenant.spec.suspend
)
}
disabled={actionLoading === tenant.metadata.name}
className="px-2.5 py-1 text-xs font-medium bg-amber-500/15 text-amber-400 rounded-md hover:bg-amber-500/25 transition-colors disabled:opacity-50"
>
{actionLoading === tenant.metadata.name
? "…"
: tenant.spec.suspend
? t("resume")
: t("suspend")}
</button>
<button
onClick={() =>
setDeleteModal(tenant.metadata.name)
}
disabled={actionLoading === tenant.metadata.name}
className="px-2.5 py-1 text-xs font-medium bg-red-500/15 text-red-400 rounded-md hover:bg-red-500/25 transition-colors disabled:opacity-50"
>
{t("deleteTenant")}
</button>
</div>
</td>
</tr>
))}
@@ -450,6 +560,38 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
</div>
</div>
)}
{/* ───── DELETE MODAL ───── */}
{deleteModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
<div className="bg-surface-1 border border-border rounded-xl p-6 max-w-md w-full mx-4 shadow-2xl">
<h3 className="font-display text-lg font-semibold text-text-primary mb-2">
{t("deleteTitle")}
</h3>
<p className="text-sm text-text-secondary mb-2">
{t("deleteWarning")}
</p>
<p className="text-xs font-mono text-accent bg-surface-2 border border-border rounded-lg px-3 py-2 mb-4">
{deleteModal}
</p>
<div className="flex gap-2 justify-end">
<button
onClick={() => setDeleteModal(null)}
className="px-4 py-2 text-sm text-text-secondary hover:text-text-primary transition-colors"
>
{t("cancelAction")}
</button>
<button
onClick={() => handleDelete(deleteModal)}
disabled={actionLoading === deleteModal}
className="px-4 py-2 text-sm font-medium bg-red-500/15 text-red-400 rounded-lg hover:bg-red-500/25 transition-colors disabled:opacity-50"
>
{actionLoading === deleteModal ? "…" : t("confirmDelete")}
</button>
</div>
</div>
</div>
)}
</>
);
}