- Control centre (QUORLA_CONTROL=true on app.quorlasocial.com): public /signup → Stripe Checkout, verified Stripe webhook (paid, updated, cancelled, payment failed), Admin → Agencies (approve without Stripe, suspend, resume, billing portal, resend welcome, remove) - deploy/provision-tenants.sh (cron): one container per agency with its own .env and data, nginx map for <agency>.quorlasocial.com, upgrades agencies after a rebuild, archives removed data - New workspaces: first admin only via the emailed one-time setup link (SETUP_TOKEN) - Meta system user token can be saved in Settings (encrypted) for workspaces that can't edit .env - docs/multi-tenant.md: DNS, wildcard cert, nginx, sudoers, cron, Stripe setup; tests for the control centre Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
171 lines
11 KiB
JavaScript
171 lines
11 KiB
JavaScript
import { useCallback, useEffect, useState } from "react";
|
|
import { CircleAlert, CircleCheck, History, RefreshCw, TriangleAlert } from "lucide-react";
|
|
import { api } from "../api.js";
|
|
import { Badge, Button, cx, Empty, ErrorNote, fmtDateTime, Panel, Spinner, timeAgo } from "../ui.jsx";
|
|
|
|
const ICON = { ok: CircleCheck, warn: TriangleAlert, bad: CircleAlert };
|
|
const TONE = { ok: "text-ok", warn: "text-warn", bad: "text-bad" };
|
|
|
|
/** Admin → System health: everything that runs in the background, in one list. */
|
|
export function Health() {
|
|
const [data, setData] = useState(null);
|
|
const [error, setError] = useState(null);
|
|
const [busy, setBusy] = useState(false);
|
|
const load = useCallback(() => { setBusy(true); return api("/api/admin/health").then(setData).catch(setError).finally(() => setBusy(false)); }, []);
|
|
useEffect(() => { load(); }, [load]);
|
|
const counts = data ? { bad: data.checks.filter((c) => c.status === "bad").length, warn: data.checks.filter((c) => c.status === "warn").length } : null;
|
|
return (
|
|
<div className="space-y-5 max-w-[960px]">
|
|
<header className="flex items-end justify-between gap-4">
|
|
<div>
|
|
<h1 className="text-[22px] font-extrabold">System health</h1>
|
|
<p className="text-muted mt-0.5">{!data ? "Checking…" : counts.bad ? `${counts.bad} problem${counts.bad === 1 ? "" : "s"} to fix` : counts.warn ? `All running, ${counts.warn} thing${counts.warn === 1 ? "" : "s"} to look at` : "Everything is running"}{data ? ` · checked ${timeAgo(data.at)}` : ""}</p>
|
|
</div>
|
|
<Button icon={RefreshCw} loading={busy} onClick={load}>Check again</Button>
|
|
</header>
|
|
<ErrorNote error={error} />
|
|
{!data ? <Spinner /> : (
|
|
<Panel bodyClass="divide-y divide-line">
|
|
{data.checks.map((c, i) => {
|
|
const Icon = ICON[c.status];
|
|
return (
|
|
<div key={i} className="px-4 py-3 flex gap-3 items-start">
|
|
<Icon className={cx("size-[18px] mt-0.5 shrink-0", TONE[c.status])} aria-label={c.status === "ok" ? "OK" : c.status === "warn" ? "Warning" : "Problem"} />
|
|
<div className="min-w-0">
|
|
<div className="text-[13.5px]"><span className="text-muted text-[12px] uppercase tracking-wide mr-2">{c.area}</span>{c.title}</div>
|
|
{c.detail && <div className="text-[12.5px] text-muted mt-0.5 break-words">{c.detail}</div>}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</Panel>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** Admin → Audit log: who did what, and what Quorla published. */
|
|
export function Audit() {
|
|
const [clients, setClients] = useState([]);
|
|
const [f, setF] = useState({ days: 30, client: "", user: "", q: "" });
|
|
const [items, setItems] = useState(null);
|
|
const [error, setError] = useState(null);
|
|
const [shown, setShown] = useState(100);
|
|
useEffect(() => { api("/api/overview").then((r) => setClients(r.clients)).catch(() => {}); }, []);
|
|
const load = useCallback(() => {
|
|
const q = new URLSearchParams({ days: String(f.days) });
|
|
for (const k of ["client", "user", "q"]) if (f[k]) q.set(k, f[k]);
|
|
setItems(null); setError(null);
|
|
return api(`/api/admin/audit?${q}`).then((r) => setItems(r.items)).catch(setError);
|
|
}, [f]);
|
|
useEffect(() => { const t = setTimeout(load, 250); return () => clearTimeout(t); }, [load]);
|
|
const users = [...new Set((items || []).map((i) => i.by))].sort();
|
|
const name = (slug) => clients.find((c) => c.slug === slug)?.name || slug;
|
|
return (
|
|
<div className="space-y-5">
|
|
<header>
|
|
<h1 className="text-[22px] font-extrabold">Audit log</h1>
|
|
<p className="text-muted mt-0.5">Every change made in Quorla and everything it published. Passwords and keys are never recorded.</p>
|
|
</header>
|
|
<div className="flex flex-wrap gap-2 items-center">
|
|
<select aria-label="Period" className="input h-9 w-auto" value={f.days} onChange={(e) => setF({ ...f, days: Number(e.target.value) })}>{[7, 30, 90].map((d) => <option key={d} value={d}>Last {d} days</option>)}</select>
|
|
<select aria-label="Client" className="input h-9 w-auto" value={f.client} onChange={(e) => setF({ ...f, client: e.target.value })}><option value="">All clients</option>{clients.map((c) => <option key={c.slug} value={c.slug}>{c.name}</option>)}</select>
|
|
<select aria-label="Person" className="input h-9 w-auto" value={f.user} onChange={(e) => setF({ ...f, user: e.target.value })}><option value="">Everyone</option>{[...new Set([f.user, ...users].filter(Boolean))].map((u) => <option key={u} value={u}>{u}</option>)}</select>
|
|
<input aria-label="Search" className="input h-9 w-56" placeholder="Search…" value={f.q} onChange={(e) => setF({ ...f, q: e.target.value })} />
|
|
</div>
|
|
<ErrorNote error={error} />
|
|
<Panel>
|
|
{!items ? <Spinner /> : !items.length ? <Empty icon={History} title="Nothing in this view" /> : (
|
|
<ul className="divide-y divide-line">
|
|
{items.slice(0, shown).map((e, i) => (
|
|
<li key={i} className="px-4 py-2.5 grid grid-cols-[150px_1fr] gap-3 text-[13px]">
|
|
<span className="text-muted num" title={e.at}>{fmtDateTime(e.at)}</span>
|
|
<div className="min-w-0">
|
|
<span className="font-medium">{e.by}</span> {e.text}
|
|
{e.client && <span className="text-muted"> · {name(e.client)}</span>}
|
|
{e.kind === "error" && <Badge tone="bad" className="ml-2">error</Badge>}
|
|
{e.detail && <div className="text-[12px] text-faint truncate" title={e.detail}>{e.detail}</div>}
|
|
</div>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
{items && items.length > shown && <div className="border-t border-line p-3 text-center"><Button size="sm" onClick={() => setShown(shown + 100)}>Show more</Button></div>}
|
|
</Panel>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const T_TONE = { active: "ok", provisioning: "brand", pending_payment: "neutral", past_due: "warn", suspended: "bad", cancelled: "neutral", removed: "neutral" };
|
|
const T_LABEL = { active: "Active", provisioning: "Setting up", pending_payment: "Awaiting payment", past_due: "Payment overdue", suspended: "Suspended", cancelled: "Cancelled", removed: "Removed" };
|
|
|
|
/** Admin → Agencies (control centre only): every hosted workspace, its subscription and container. */
|
|
export function Agencies() {
|
|
const [data, setData] = useState(null);
|
|
const [error, setError] = useState(null);
|
|
const [busy, setBusy] = useState(null);
|
|
const load = useCallback(() => api("/api/control/tenants").then(setData).catch(setError), []);
|
|
useEffect(() => { load(); }, [load]);
|
|
const act = async (slug, action, confirmText) => {
|
|
if (confirmText && !window.confirm(confirmText)) return;
|
|
setBusy(`${slug}-${action}`); setError(null);
|
|
try {
|
|
const r = await api(`/api/control/tenants/${slug}/${action}`, { method: "POST" });
|
|
if (r.url) window.open(r.url, "_blank", "noopener");
|
|
await load();
|
|
} catch (e) { setError(e); }
|
|
setBusy(null);
|
|
};
|
|
return (
|
|
<div className="space-y-5">
|
|
<header className="flex items-end justify-between gap-4 flex-wrap">
|
|
<div>
|
|
<h1 className="text-[22px] font-extrabold">Agencies</h1>
|
|
<p className="text-muted mt-0.5">Hosted Quorla workspaces: one private copy per agency. Sign-up page: <a className="text-brand-deep underline" href="/signup" target="_blank" rel="noreferrer">/signup</a></p>
|
|
</div>
|
|
<Button icon={RefreshCw} onClick={load}>Refresh</Button>
|
|
</header>
|
|
{data && (!data.stripe || !data.provisioner) && (
|
|
<div className="rounded-md bg-warn-soft text-warn text-[13px] px-3 py-2">
|
|
{!data.stripe && "Stripe isn't set up: sign-ups wait for you to approve them here. "}
|
|
{!data.provisioner && "PROVISION_TOKEN isn't set: the server script can't create workspaces yet. "}
|
|
See docs/multi-tenant.md.
|
|
</div>
|
|
)}
|
|
<ErrorNote error={error} />
|
|
<Panel>
|
|
{!data ? <Spinner /> : !data.tenants.length ? <Empty icon={History} title="No agencies yet">When an agency signs up at /signup it appears here.</Empty> : (
|
|
<div className="overflow-x-auto">
|
|
<table className="tbl min-w-[900px]">
|
|
<thead><tr><th>Agency</th><th>Status</th><th>Workspace</th><th>Owner</th><th>Signed up</th><th><span className="sr-only">Actions</span></th></tr></thead>
|
|
<tbody>
|
|
{data.tenants.map((t) => (
|
|
<tr key={t.slug}>
|
|
<td><div className="font-medium">{t.agencyName}</div><div className="text-[12px] text-muted">{t.slug}{t.port ? ` · port ${t.port}` : ""}</div></td>
|
|
<td>
|
|
<Badge tone={T_TONE[t.status] || "neutral"} dot>{T_LABEL[t.status] || t.status}</Badge>
|
|
<div className="text-[11.5px] text-muted mt-0.5">container: {t.actual}{t.desired !== t.actual && t.desired !== "none" ? ` → ${t.desired}` : ""}</div>
|
|
{t.lastError && <div className="text-[11.5px] text-bad mt-0.5">{t.lastError}</div>}
|
|
</td>
|
|
<td><a className="text-brand-deep underline text-[13px]" href={t.url} target="_blank" rel="noreferrer">{t.url.replace(/^https:\/\//, "")}</a>{t.setupPending && !t.welcomedAt && t.actual === "running" ? <div className="text-[11.5px] text-warn">welcome email not sent</div> : null}</td>
|
|
<td className="text-[13px]">{t.ownerName}<div className="text-[12px] text-muted">{t.ownerEmail}</div></td>
|
|
<td className="text-[13px] text-muted">{timeAgo(t.createdAt)}</td>
|
|
<td className="text-right whitespace-nowrap space-x-1">
|
|
{t.status === "pending_payment" && <Button size="sm" loading={busy === `${t.slug}-approve`} onClick={() => act(t.slug, "approve", `Create ${t.agencyName}'s workspace without payment?`)}>Approve</Button>}
|
|
{["active", "past_due", "provisioning"].includes(t.status) && <Button size="sm" variant="ghost" loading={busy === `${t.slug}-suspend`} onClick={() => act(t.slug, "suspend", `Stop ${t.agencyName}'s workspace? Their data is kept.`)}>Suspend</Button>}
|
|
{["suspended", "cancelled"].includes(t.status) && <Button size="sm" loading={busy === `${t.slug}-resume`} onClick={() => act(t.slug, "resume")}>Resume</Button>}
|
|
{t.actual === "running" && <Button size="sm" variant="ghost" loading={busy === `${t.slug}-welcome`} onClick={() => act(t.slug, "welcome")}>Resend welcome</Button>}
|
|
{t.stripe?.customer && <Button size="sm" variant="ghost" loading={busy === `${t.slug}-portal`} onClick={() => act(t.slug, "portal")}>Billing</Button>}
|
|
{["suspended", "cancelled"].includes(t.status) && <Button size="sm" variant="danger" loading={busy === `${t.slug}-remove`} onClick={() => act(t.slug, "remove", `Remove ${t.agencyName}'s container? Their data folder is archived on the server, not deleted.`)}>Remove</Button>}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</Panel>
|
|
</div>
|
|
);
|
|
}
|