require confirmation before approving tenant requests
This commit is contained in:
@@ -35,6 +35,11 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
|
|||||||
const [actionLoading, setActionLoading] = useState<string | null>(null);
|
const [actionLoading, setActionLoading] = useState<string | null>(null);
|
||||||
const [rejectModal, setRejectModal] = useState<string | null>(null);
|
const [rejectModal, setRejectModal] = useState<string | null>(null);
|
||||||
const [rejectNotes, setRejectNotes] = useState("");
|
const [rejectNotes, setRejectNotes] = useState("");
|
||||||
|
// Approve is the highest-consequence request action — it provisions
|
||||||
|
// real infrastructure and triggers the billable setup fee — so it now
|
||||||
|
// goes through a confirmation modal like reject/delete, instead of
|
||||||
|
// firing on a single click.
|
||||||
|
const [approveModal, setApproveModal] = useState<string | null>(null);
|
||||||
|
|
||||||
// Tenants state
|
// Tenants state
|
||||||
const [tenants, setTenants] = useState<PiecedTenant[]>(initialTenants);
|
const [tenants, setTenants] = useState<PiecedTenant[]>(initialTenants);
|
||||||
@@ -47,6 +52,11 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
|
|||||||
|
|
||||||
// Shared
|
// Shared
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
// Action-scoped error — shown inside the active confirmation modal so
|
||||||
|
// a failed approve/reject/delete surfaces next to the action that
|
||||||
|
// caused it (and keeps the modal open), rather than as a detached
|
||||||
|
// panel-level banner that isn't tied to any row.
|
||||||
|
const [actionError, setActionError] = useState("");
|
||||||
|
|
||||||
// ─── Requests fetching ───
|
// ─── Requests fetching ───
|
||||||
const fetchRequests = useCallback(async () => {
|
const fetchRequests = useCallback(async () => {
|
||||||
@@ -125,18 +135,21 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
|
|||||||
// ─── Request actions ───
|
// ─── Request actions ───
|
||||||
const handleApprove = async (id: string) => {
|
const handleApprove = async (id: string) => {
|
||||||
setActionLoading(id);
|
setActionLoading(id);
|
||||||
setError("");
|
setActionError("");
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/admin/requests/${id}/approve`, {
|
const res = await fetch(`/api/admin/requests/${id}/approve`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const data = await res.json();
|
const data = await res.json().catch(() => ({}));
|
||||||
throw new Error(data.error || "Approve failed");
|
throw new Error(data.error || "Approve failed");
|
||||||
}
|
}
|
||||||
|
setApproveModal(null);
|
||||||
await fetchRequests();
|
await fetchRequests();
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setError(e.message);
|
// Keep the modal open so the admin sees why provisioning didn't
|
||||||
|
// start; the error renders inside the dialog next to the action.
|
||||||
|
setActionError(e.message);
|
||||||
} finally {
|
} finally {
|
||||||
setActionLoading(null);
|
setActionLoading(null);
|
||||||
}
|
}
|
||||||
@@ -144,7 +157,7 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
|
|||||||
|
|
||||||
const handleReject = async (id: string) => {
|
const handleReject = async (id: string) => {
|
||||||
setActionLoading(id);
|
setActionLoading(id);
|
||||||
setError("");
|
setActionError("");
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/admin/requests/${id}/reject`, {
|
const res = await fetch(`/api/admin/requests/${id}/reject`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -152,14 +165,14 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
|
|||||||
body: JSON.stringify({ adminNotes: rejectNotes || undefined }),
|
body: JSON.stringify({ adminNotes: rejectNotes || undefined }),
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const data = await res.json();
|
const data = await res.json().catch(() => ({}));
|
||||||
throw new Error(data.error || "Reject failed");
|
throw new Error(data.error || "Reject failed");
|
||||||
}
|
}
|
||||||
setRejectModal(null);
|
setRejectModal(null);
|
||||||
setRejectNotes("");
|
setRejectNotes("");
|
||||||
await fetchRequests();
|
await fetchRequests();
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setError(e.message);
|
setActionError(e.message);
|
||||||
} finally {
|
} finally {
|
||||||
setActionLoading(null);
|
setActionLoading(null);
|
||||||
}
|
}
|
||||||
@@ -189,7 +202,7 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
|
|||||||
|
|
||||||
const handleDelete = async (name: string) => {
|
const handleDelete = async (name: string) => {
|
||||||
setActionLoading(name);
|
setActionLoading(name);
|
||||||
setError("");
|
setActionError("");
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/admin/tenants/${name}/delete`, {
|
const res = await fetch(`/api/admin/tenants/${name}/delete`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -216,7 +229,7 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
|
|||||||
fetchTenants();
|
fetchTenants();
|
||||||
setTimeout(() => fetchTenants(), 1500);
|
setTimeout(() => fetchTenants(), 1500);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setError(e.message);
|
setActionError(e.message);
|
||||||
} finally {
|
} finally {
|
||||||
setActionLoading(null);
|
setActionLoading(null);
|
||||||
}
|
}
|
||||||
@@ -436,16 +449,20 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
|
|||||||
{req.status === "pending" && (
|
{req.status === "pending" && (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleApprove(req.id)}
|
onClick={() => {
|
||||||
|
setActionError("");
|
||||||
|
setApproveModal(req.id);
|
||||||
|
}}
|
||||||
disabled={actionLoading === req.id}
|
disabled={actionLoading === req.id}
|
||||||
className="px-2.5 py-1 text-xs font-medium bg-emerald-500/15 text-emerald-400 rounded-md hover:bg-emerald-500/25 transition-colors disabled:opacity-50"
|
className="px-2.5 py-1 text-xs font-medium bg-emerald-500/15 text-emerald-400 rounded-md hover:bg-emerald-500/25 transition-colors disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{actionLoading === req.id
|
{t("approve")}
|
||||||
? "…"
|
|
||||||
: t("approve")}
|
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setRejectModal(req.id)}
|
onClick={() => {
|
||||||
|
setActionError("");
|
||||||
|
setRejectModal(req.id);
|
||||||
|
}}
|
||||||
disabled={actionLoading === req.id}
|
disabled={actionLoading === req.id}
|
||||||
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"
|
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"
|
||||||
>
|
>
|
||||||
@@ -466,7 +483,10 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
|
|||||||
)}
|
)}
|
||||||
{req.status === "rejected" && (
|
{req.status === "rejected" && (
|
||||||
<button
|
<button
|
||||||
onClick={() => handleApprove(req.id)}
|
onClick={() => {
|
||||||
|
setActionError("");
|
||||||
|
setApproveModal(req.id);
|
||||||
|
}}
|
||||||
disabled={actionLoading === req.id}
|
disabled={actionLoading === req.id}
|
||||||
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"
|
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"
|
||||||
>
|
>
|
||||||
@@ -642,9 +662,10 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
|
|||||||
: t("suspend")}
|
: t("suspend")}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() =>
|
onClick={() => {
|
||||||
setDeleteModal(tenant.metadata.name)
|
setActionError("");
|
||||||
}
|
setDeleteModal(tenant.metadata.name);
|
||||||
|
}}
|
||||||
disabled={actionLoading === 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"
|
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"
|
||||||
>
|
>
|
||||||
@@ -772,6 +793,56 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* ───── APPROVE MODAL ───── */}
|
||||||
|
{approveModal &&
|
||||||
|
(() => {
|
||||||
|
const req = requests.find((r) => r.id === approveModal);
|
||||||
|
const isReapprove = req?.status === "rejected";
|
||||||
|
return (
|
||||||
|
<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("approveTitle")}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-text-secondary mb-2">
|
||||||
|
{isReapprove
|
||||||
|
? t("approveReapproveWarning")
|
||||||
|
: t("approveWarning")}
|
||||||
|
</p>
|
||||||
|
{req && (
|
||||||
|
<p className="text-xs font-mono text-accent bg-surface-2 border border-border rounded-lg px-3 py-2 mb-4">
|
||||||
|
{req.companyName}
|
||||||
|
{req.agentName ? ` · ${req.agentName}` : ""}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{actionError && (
|
||||||
|
<p className="text-xs text-red-400 bg-red-400/10 border border-red-400/20 rounded-lg px-3 py-2 mb-4">
|
||||||
|
{actionError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-2 justify-end">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setApproveModal(null);
|
||||||
|
setActionError("");
|
||||||
|
}}
|
||||||
|
className="px-4 py-2 text-sm text-text-secondary hover:text-text-primary transition-colors"
|
||||||
|
>
|
||||||
|
{t("cancelAction")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleApprove(approveModal)}
|
||||||
|
disabled={actionLoading === approveModal}
|
||||||
|
className="px-4 py-2 text-sm font-medium bg-emerald-500/15 text-emerald-400 rounded-lg hover:bg-emerald-500/25 transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{actionLoading === approveModal ? "…" : t("confirmApprove")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
{/* ───── REJECT MODAL ───── */}
|
{/* ───── REJECT MODAL ───── */}
|
||||||
{rejectModal && (
|
{rejectModal && (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
||||||
@@ -789,6 +860,11 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
|
|||||||
rows={3}
|
rows={3}
|
||||||
className="w-full px-3 py-2 bg-surface-2 border border-border rounded-lg text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent focus:border-accent transition-colors resize-none mb-4"
|
className="w-full px-3 py-2 bg-surface-2 border border-border rounded-lg text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent focus:border-accent transition-colors resize-none mb-4"
|
||||||
/>
|
/>
|
||||||
|
{actionError && (
|
||||||
|
<p className="text-xs text-red-400 bg-red-400/10 border border-red-400/20 rounded-lg px-3 py-2 mb-4">
|
||||||
|
{actionError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
<div className="flex gap-2 justify-end">
|
<div className="flex gap-2 justify-end">
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -824,6 +900,11 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
|
|||||||
<p className="text-xs font-mono text-accent bg-surface-2 border border-border rounded-lg px-3 py-2 mb-4">
|
<p className="text-xs font-mono text-accent bg-surface-2 border border-border rounded-lg px-3 py-2 mb-4">
|
||||||
{deleteModal}
|
{deleteModal}
|
||||||
</p>
|
</p>
|
||||||
|
{actionError && (
|
||||||
|
<p className="text-xs text-red-400 bg-red-400/10 border border-red-400/20 rounded-lg px-3 py-2 mb-4">
|
||||||
|
{actionError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
<div className="flex gap-2 justify-end">
|
<div className="flex gap-2 justify-end">
|
||||||
<button
|
<button
|
||||||
onClick={() => setDeleteModal(null)}
|
onClick={() => setDeleteModal(null)}
|
||||||
|
|||||||
@@ -421,7 +421,11 @@
|
|||||||
"openclawTool": "OpenClaw-Versionen",
|
"openclawTool": "OpenClaw-Versionen",
|
||||||
"billingTool": "Abrechnung →",
|
"billingTool": "Abrechnung →",
|
||||||
"skillsQueueTool": "Aktivierungs-Warteschlange",
|
"skillsQueueTool": "Aktivierungs-Warteschlange",
|
||||||
"cronTool": "Automatisierung"
|
"cronTool": "Automatisierung",
|
||||||
|
"approveTitle": "Anfrage genehmigen?",
|
||||||
|
"approveWarning": "Dadurch wird die Infrastruktur des Mandanten bereitgestellt, die Einrichtungsgebühr berechnet und der Kunde benachrichtigt. Bitte prüfen Sie die Angaben, bevor Sie fortfahren.",
|
||||||
|
"approveReapproveWarning": "Dies genehmigt eine zuvor abgelehnte Anfrage erneut: Die Infrastruktur des Mandanten wird bereitgestellt, die Einrichtungsgebühr berechnet und der Kunde benachrichtigt.",
|
||||||
|
"confirmApprove": "Genehmigen & bereitstellen"
|
||||||
},
|
},
|
||||||
"channelUsers": {
|
"channelUsers": {
|
||||||
"title": "Autorisierte Benutzer",
|
"title": "Autorisierte Benutzer",
|
||||||
|
|||||||
@@ -421,7 +421,11 @@
|
|||||||
"openclawTool": "OpenClaw versions",
|
"openclawTool": "OpenClaw versions",
|
||||||
"billingTool": "Billing →",
|
"billingTool": "Billing →",
|
||||||
"skillsQueueTool": "Activation Queue",
|
"skillsQueueTool": "Activation Queue",
|
||||||
"cronTool": "Automation"
|
"cronTool": "Automation",
|
||||||
|
"approveTitle": "Approve request?",
|
||||||
|
"approveWarning": "This provisions the tenant's infrastructure, charges the setup fee, and notifies the customer. Check the request details are correct before continuing.",
|
||||||
|
"approveReapproveWarning": "This re-approves a previously rejected request: it provisions the tenant's infrastructure, charges the setup fee, and notifies the customer.",
|
||||||
|
"confirmApprove": "Approve & provision"
|
||||||
},
|
},
|
||||||
"channelUsers": {
|
"channelUsers": {
|
||||||
"title": "Authorized Users",
|
"title": "Authorized Users",
|
||||||
|
|||||||
@@ -421,7 +421,11 @@
|
|||||||
"openclawTool": "Versions OpenClaw",
|
"openclawTool": "Versions OpenClaw",
|
||||||
"billingTool": "Facturation →",
|
"billingTool": "Facturation →",
|
||||||
"skillsQueueTool": "File d'activation",
|
"skillsQueueTool": "File d'activation",
|
||||||
"cronTool": "Automatisation"
|
"cronTool": "Automatisation",
|
||||||
|
"approveTitle": "Approuver la demande ?",
|
||||||
|
"approveWarning": "Cela provisionne l'infrastructure du locataire, facture les frais d'installation et notifie le client. Vérifiez l'exactitude des détails de la demande avant de continuer.",
|
||||||
|
"approveReapproveWarning": "Ceci réapprouve une demande précédemment rejetée : l'infrastructure du locataire est provisionnée, les frais d'installation sont facturés et le client est notifié.",
|
||||||
|
"confirmApprove": "Approuver et provisionner"
|
||||||
},
|
},
|
||||||
"channelUsers": {
|
"channelUsers": {
|
||||||
"title": "Utilisateurs autorisés",
|
"title": "Utilisateurs autorisés",
|
||||||
|
|||||||
@@ -421,7 +421,11 @@
|
|||||||
"openclawTool": "Versioni OpenClaw",
|
"openclawTool": "Versioni OpenClaw",
|
||||||
"billingTool": "Fatturazione →",
|
"billingTool": "Fatturazione →",
|
||||||
"skillsQueueTool": "Coda di attivazione",
|
"skillsQueueTool": "Coda di attivazione",
|
||||||
"cronTool": "Automazione"
|
"cronTool": "Automazione",
|
||||||
|
"approveTitle": "Approvare la richiesta?",
|
||||||
|
"approveWarning": "Questa operazione effettua il provisioning dell'infrastruttura del tenant, addebita il costo di attivazione e notifica il cliente. Verifica che i dettagli della richiesta siano corretti prima di continuare.",
|
||||||
|
"approveReapproveWarning": "Questo riapprova una richiesta precedentemente rifiutata: effettua il provisioning dell'infrastruttura del tenant, addebita il costo di attivazione e notifica il cliente.",
|
||||||
|
"confirmApprove": "Approva e avvia provisioning"
|
||||||
},
|
},
|
||||||
"channelUsers": {
|
"channelUsers": {
|
||||||
"title": "Utenti autorizzati",
|
"title": "Utenti autorizzati",
|
||||||
|
|||||||
Reference in New Issue
Block a user