NGU-Web/src/pages/admin/AdminPanel.tsx
Zaldimmar 22d5328885 Convert src/ JavaScript modules to TypeScript
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>
2026-09-26 15:39:42 -05:00

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>
);
}