v1.5 - history and timeline as well as many datastructure updates added, polished, fixes
This commit is contained in:
parent
1f0aa3078f
commit
1d84400aef
63 changed files with 7927 additions and 208 deletions
331
src/pages/admin/AdminPanel.tsx
Normal file
331
src/pages/admin/AdminPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue