v1.5 - history and timeline as well as many datastructure updates added, polished, fixes

This commit is contained in:
Zaldimmar 2026-09-25 02:38:51 -05:00
parent 1f0aa3078f
commit 1d84400aef
63 changed files with 7927 additions and 208 deletions

View file

@ -0,0 +1,331 @@
/* ═══════════════════════════════════════════════════════════════
ADMIN PANEL
Superadmin only, guarded by RequireRole on the route and by
requireRole("superadmin") on every endpoint it calls. This page
assumes neither: it renders whatever the API gives it and shows
whatever the API refuses.
Three blocks, deliberately boring:
System — what's actually running, for when something is off
Content — row counts, the cheapest "is the database there"
Accounts — roles, access and live sessions
The role select is driven by ROLES from lib/roles.ts, so a new
rung on the ladder appears here without this file changing. The
legend beside it is the same list — four roles is past the
point where "Editor" explains itself.
Every account write signs that person out, which the server
does rather than this page. Two things you can't do here: edit
your own row, or take the last active superadmin away. Both are
enforced server-side and mirrored in the disabled states, so
the reason shows up before the click rather than after it.
═══════════════════════════════════════════════════════════════ */
import { useCallback, useEffect, useState } from "react";
import { del, get, patch } from "../../lib/api.js";
import { isUnauthorized, useAuth } from "../../lib/auth.tsx";
import { useNavigate } from "react-router-dom";
import { ROLES, ROLE_LABELS, ROLE_NOTES } from "../../lib/roles.ts";
/* SQLite hands back "2026-09-22 04:11:07" — UTC, but without the
marker that says so. Left alone, browsers read it as local time
and last-login drifts by the timezone offset. */
function when(value) {
if (!value) return "—";
const iso = value.includes("T") ? value : `${value.replace(" ", "T")}Z`;
const date = new Date(iso);
return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
}
function uptime(seconds) {
if (seconds == null) return "—";
const d = Math.floor(seconds / 86400);
const h = Math.floor((seconds % 86400) / 3600);
const m = Math.floor((seconds % 3600) / 60);
if (d) return `${d}d ${h}h`;
if (h) return `${h}h ${m}m`;
return `${m}m`;
}
function Block({ title, note, children }) {
return (
<section className="mt-8 first:mt-0">
<div className="flex items-baseline gap-3">
<h2 className="text-lg font-semibold text-[#138ba0]">{title}</h2>
{note && <p className="text-sm text-[#4a6b72]">{note}</p>}
</div>
<div className="mt-3">{children}</div>
</section>
);
}
function Stat({ label, value }) {
return (
<div className="rounded-xl border border-[#138ba0]/20 bg-white px-4 py-3">
<div className="text-xs font-medium uppercase tracking-wider text-[#4a6b72]/70">
{label}
</div>
<div className="mt-1 break-words text-sm font-medium text-[#0f2f36]">{value}</div>
</div>
);
}
export default function AdminPanel() {
const { user: me } = useAuth();
const navigate = useNavigate();
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
// Which row is mid-request, and what went wrong on it. Scoped to
// the row so a failure on one account doesn't blank the table.
const [busyId, setBusyId] = useState(null);
const [rowError, setRowError] = useState(null);
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
setData(await get("/admin/panel/overview", { ttl: 0 }));
} catch (err) {
if (isUnauthorized(err)) return navigate("/admin/login", { replace: true });
setError(err.message || "Couldn't load the panel.");
} finally {
setLoading(false);
}
}, [navigate]);
useEffect(() => {
load();
}, [load]);
/* Replace the one row the server returns rather than refetching
the whole overview — the counts didn't change. */
function mergeUser(updated) {
setData((current) =>
current
? {
...current,
users: current.users.map((u) => (u.id === updated.id ? updated : u)),
}
: current,
);
}
async function run(id, work) {
setBusyId(id);
setRowError(null);
try {
mergeUser(await work());
} catch (err) {
if (isUnauthorized(err)) return navigate("/admin/login", { replace: true });
setRowError({ id, message: err.message || "That didn't work." });
} finally {
setBusyId(null);
}
}
const changeRole = (row, role) =>
run(row.id, async () => (await patch(`/admin/panel/users/${row.id}`, { role })).user);
const setActive = (row, is_active) =>
run(
row.id,
async () => (await patch(`/admin/panel/users/${row.id}`, { is_active })).user,
);
const revoke = (row) =>
run(row.id, async () => (await del(`/admin/panel/users/${row.id}/sessions`)).user);
if (loading) {
return (
<p className="py-12 text-[#4a6b72]" role="status">
Loading the panel…
</p>
);
}
if (error) {
return (
<div className="py-12">
<p className="text-[#b3261e]">{error}</p>
<button
type="button"
onClick={load}
className="mt-3 rounded-full border border-[#138ba0] px-4 py-1.5 text-sm font-medium text-[#138ba0] transition-colors hover:bg-[#eef9fb]"
>
Try again
</button>
</div>
);
}
const { system, content, users } = data;
const activeSupers = users.filter(
(u) => u.role === "superadmin" && u.is_active === 1,
).length;
return (
<div>
<h1 className="text-2xl font-bold text-[#138ba0]">Panel</h1>
<p className="mt-1 text-[#4a6b72]">
Accounts and server state. Everything here is superadmin-only.
</p>
<Block title="System">
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
<Stat label="Schema version" value={system.schemaVersion} />
<Stat label="API uptime" value={uptime(system.uptimeSeconds)} />
<Stat label="Started" value={when(system.startedAt)} />
<Stat label="Node" value={system.nodeVersion} />
<Stat label="Platform" value={system.platform} />
<Stat label="Live sessions" value={system.sessions} />
</div>
{system.dbPath && (
<p className="mt-3 font-mono text-xs text-[#4a6b72]/70">{system.dbPath}</p>
)}
</Block>
<Block title="Content" note="Row counts, straight from the tables.">
<div className="flex flex-wrap gap-2">
{content.map((row) => (
<span
key={row.label}
className="rounded-full border border-[#138ba0]/20 bg-white px-3 py-1 text-sm text-[#4a6b72]"
>
{row.label}{" "}
<strong className="font-semibold text-[#0f2f36]">
{row.count ?? "—"}
</strong>
</span>
))}
</div>
</Block>
<Block
title="Accounts"
note="Changing a role or disabling an account signs that person out."
>
<div className="overflow-x-auto rounded-2xl border border-[#138ba0]/20 bg-white">
<table className="w-full min-w-[46rem] text-left text-sm">
<thead className="border-b border-[#138ba0]/15 text-xs uppercase tracking-wider text-[#4a6b72]/70">
<tr>
<th className="px-4 py-3 font-medium">Account</th>
<th className="px-4 py-3 font-medium">Role</th>
<th className="px-4 py-3 font-medium">Last login</th>
<th className="px-4 py-3 font-medium">Sessions</th>
<th className="px-4 py-3 font-medium">Access</th>
</tr>
</thead>
<tbody className="divide-y divide-[#138ba0]/10">
{users.map((row) => {
const isMe = row.id === me?.id;
const lastSuper =
row.role === "superadmin" && row.is_active === 1 && activeSupers <= 1;
const locked = isMe || lastSuper;
const busy = busyId === row.id;
return (
<tr key={row.id} className={row.is_active ? "" : "bg-[#f6fbfc]"}>
<td className="px-4 py-3">
<div className="font-medium text-[#0f2f36]">
{row.name || row.email}
{isMe && (
<span className="ml-2 text-xs font-normal text-[#4a6b72]/70">
you
</span>
)}
</div>
{row.name && (
<div className="text-xs text-[#4a6b72]/80">{row.email}</div>
)}
{rowError?.id === row.id && (
<div className="mt-1 text-xs text-[#b3261e]">
{rowError.message}
</div>
)}
</td>
<td className="px-4 py-3">
<select
value={row.role}
disabled={locked || busy}
onChange={(e) => changeRole(row, e.target.value)}
className="rounded-lg border border-[#138ba0]/30 bg-white px-2 py-1 text-sm text-[#0f2f36] disabled:cursor-not-allowed disabled:bg-[#f6fbfc] disabled:text-[#4a6b72]/60"
title={
isMe
? "You can't change your own role."
: lastSuper
? "The last active superadmin can't be demoted."
: ROLE_NOTES[row.role]
}
>
{ROLES.map((r) => (
<option key={r} value={r}>
{ROLE_LABELS[r]}
</option>
))}
</select>
</td>
<td className="px-4 py-3 text-[#4a6b72]">{when(row.last_login_at)}</td>
<td className="px-4 py-3">
<span className="text-[#4a6b72]">{row.sessions}</span>
{row.sessions > 0 && (
<button
type="button"
disabled={busy}
onClick={() => revoke(row)}
className="ml-3 text-xs font-medium text-[#138ba0] underline-offset-4 hover:underline disabled:opacity-50"
>
Sign out
</button>
)}
</td>
<td className="px-4 py-3">
<button
type="button"
disabled={locked || busy}
onClick={() => setActive(row, row.is_active ? 0 : 1)}
className={`rounded-full border px-3 py-1 text-xs font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-50 ${
row.is_active
? "border-[#4a6b72]/30 text-[#4a6b72] hover:border-[#b3261e]/50 hover:text-[#b3261e]"
: "border-[#138ba0]/40 text-[#138ba0] hover:bg-[#eef9fb]"
}`}
>
{row.is_active ? "Disable" : "Enable"}
</button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{/* Each rung adds to the one above it. Worth stating, because
"Editor" doesn't tell you where the line falls. */}
<dl className="mt-4 grid gap-x-6 gap-y-1.5 text-sm sm:grid-cols-2">
{ROLES.map((role) => (
<div key={role} className="flex gap-2">
<dt className="shrink-0 font-medium text-[#0f2f36]">
{ROLE_LABELS[role]}
</dt>
<dd className="text-[#4a6b72]">{ROLE_NOTES[role]}</dd>
</div>
))}
</dl>
<p className="mt-4 text-xs text-[#4a6b72]/80">
New accounts are still created with <code>admin-cli.js</code> on the server.
</p>
</Block>
</div>
);
}