Renames every .js module under src/ to .ts (api, useResource, adminSchema, navConfig, adminNav and src/data/*) and points imports, comments and the seed script's module paths at the new names. Their types now come from inference; no .d.ts files and no shape interfaces. tsc stays strict (noImplicitAny off). The errors inference leaves behind get the lightest fix that clears them: `any` on empty state, contexts and list defaults, `: any` on components with optional or spread props, class fields on ApiError, and option shapes on get/useResource. Kept as real types, since they belong to modules that were already TypeScript and later features import them: PageShell's ShellSection and props, useContent's corrected record types (website/email/ instagram are bare strings) and EventListItem, and TimelineRef's orgKind. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
331 lines
12 KiB
TypeScript
331 lines
12 KiB
TypeScript
/* ═══════════════════════════════════════════════════════════════
|
|
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.ts";
|
|
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 }: any) {
|
|
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<any>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<any>(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<any>(null);
|
|
const [rowError, setRowError] = useState<any>(null);
|
|
|
|
const load = useCallback(async () => {
|
|
setLoading(true);
|
|
setError(null);
|
|
try {
|
|
setData(await get("/admin/panel/overview", { ttl: 0 }));
|
|
} catch (err: any) {
|
|
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: any) {
|
|
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>
|
|
);
|
|
}
|