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

@ -2,8 +2,15 @@
ADMIN — FEEDBACK TRIAGE
Reads /api/admin/feedback, writes status and notes back through
PATCH. Deliberately a flat list rather than a table: the message
is the content, and messages don't fit in a cell.
PATCH, and deletes through DELETE. Deliberately a flat list
rather than a table: the message is the content, and messages
don't fit in a cell.
Two capabilities, two ranks. Editors and above can change a
status or leave a note; deleting is admin and above, matching
requireRole on the server. Both come from the roles ladder
rather than an equality check — a superadmin is not role ===
"admin", and reading it that way is what hid these controls.
Every read passes ttl: 0. The api cache exists for public
content that changes weekly; a triage queue two people are
@ -13,8 +20,9 @@
import { useCallback, useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { get, patch, ApiError } from "../../lib/api.js";
import { del, get, patch, ApiError } from "../../lib/api.js";
import { isUnauthorized, useAuth } from "../../lib/auth.tsx";
import { canWrite as roleCanWrite, canDelete } from "../../lib/roles.ts";
import { feedbackTypeLabel } from "../../data/feedbackTypes.js";
const STATUSES = ["new", "read", "actioned", "archived", "spam"];
@ -44,10 +52,11 @@ function locationOf(row) {
/* ── One submission ──────────────────────────────────────────── */
function FeedbackCard({ row, onChange, canWrite }) {
function FeedbackCard({ row, onChange, onRemove, canWrite, canRemove }) {
const [note, setNote] = useState(row.admin_note ?? "");
const [busy, setBusy] = useState(false);
const [error, setError] = useState(null);
const [confirming, setConfirming] = useState(false);
const noteDirty = note !== (row.admin_note ?? "");
@ -64,6 +73,21 @@ function FeedbackCard({ row, onChange, canWrite }) {
}
}
// On success this card unmounts, so there's no finally here:
// busy only needs clearing on the path where the row survives.
async function remove() {
setBusy(true);
setError(null);
try {
await del(`/admin/feedback/${row.id}`);
onRemove(row);
} catch (err) {
setError(err instanceof ApiError ? err.message : "Couldn't delete that.");
setConfirming(false);
setBusy(false);
}
}
return (
<article className="rounded-2xl border border-[#138ba0]/20 bg-white p-5">
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-sm">
@ -126,8 +150,6 @@ function FeedbackCard({ row, onChange, canWrite }) {
</option>
))}
</select>
{error && <span className="text-sm text-[#b3261e]">{error}</span>}
</div>
<textarea
@ -150,6 +172,52 @@ function FeedbackCard({ row, onChange, canWrite }) {
)}
</div>
)}
{/* Deleting is the irreversible option; marking something
spam or archived is the habit this defers to. Hence the
second click rather than a window.confirm. */}
{canRemove && (
<div className="mt-4 flex flex-wrap items-center gap-3 border-t border-[#4a6b72]/15 pt-4">
{confirming ? (
<>
<span className="text-sm text-[#26454c]">
Delete #{row.id} for good? Marking it spam keeps it recoverable.
</span>
<button
type="button"
disabled={busy}
onClick={remove}
className="rounded-full bg-[#b3261e] px-4 py-1.5 text-sm font-semibold text-white transition-colors hover:bg-[#8f1e18] disabled:bg-[#4a6b72]/25"
>
{busy ? "Deleting…" : "Delete"}
</button>
<button
type="button"
disabled={busy}
onClick={() => setConfirming(false)}
className="rounded-full border border-[#4a6b72]/25 px-4 py-1.5 text-sm text-[#4a6b72] transition-colors hover:border-[#4a6b72]/50"
>
Keep it
</button>
</>
) : (
<button
type="button"
disabled={busy}
onClick={() => setConfirming(true)}
className="rounded-full border border-[#b3261e]/30 px-4 py-1.5 text-sm font-medium text-[#b3261e] transition-colors hover:bg-[#fdf3f2]"
>
Delete
</button>
)}
</div>
)}
{error && (
<p role="alert" className="mt-3 text-sm text-[#b3261e]">
{error}
</p>
)}
</article>
);
}
@ -170,7 +238,9 @@ export default function AdminFeedback() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const canWrite = user?.role === "admin";
// Minimums, not equality — see lib/roles.ts.
const canWrite = roleCanWrite(user);
const canRemove = canDelete(user);
const load = useCallback(
async (before = null) => {
@ -218,6 +288,16 @@ export default function AdminFeedback() {
setCounts((prev) => ({ ...prev })); // counts refresh on next load
}
// The deleted row is passed whole rather than by id: its status
// is what says which tab count to drop.
function removeRow(removed) {
setRows((prev) => prev.filter((row) => row.id !== removed.id));
setCounts((prev) => ({
...prev,
[removed.status]: Math.max((prev[removed.status] ?? 1) - 1, 0),
}));
}
const tabs = [
{ id: "all", label: "All" },
...STATUSES.map((s) => ({ id: s, label: s, count: counts[s] })),
@ -294,7 +374,9 @@ export default function AdminFeedback() {
key={row.id}
row={row}
canWrite={canWrite}
canRemove={canRemove}
onChange={replaceRow}
onRemove={removeRow}
/>
))}
</div>

View file

@ -0,0 +1,182 @@
/* ═══════════════════════════════════════════════════════════════
ADMIN HOME
Where login lands. Two columns: the cards are the navigation —
the header deliberately drops its tab row here so the same links
aren't drawn twice — and a standing info panel on the right.
The cards come from adminNav.js, the same list the CMS header
reads, so a new entity shows up here the moment it's registered.
Forms sit in their own block below: they're submissions coming
in rather than content going out, and there'll be more of them
than the feedback queue eventually. The Panel block below that
only exists for superadmins.
The right-hand panel is deliberately inert. Nothing here
fetches, so the landing page can't be slow or half-broken on
arrival; anything live (open feedback count, last-edited
record) wants to be a separate component that fails on its own.
═══════════════════════════════════════════════════════════════ */
import { Link } from "react-router-dom";
import { useAuth } from "../../lib/auth.tsx";
import { SITE_VERSION } from "../../lib/version.ts";
import { ROLE_LABELS, isSuper } from "../../lib/roles.ts";
import { CMS_NAV, FORMS_NAV, PANEL_NAV, target } from "./adminNav.js";
/* One card. The title link is stretched over the whole card with
`after:absolute`, which makes the card clickable without nesting
an anchor inside an anchor; the sub-links sit above it on z-10 so
they stay separately clickable. */
function NavCard({ item }) {
const to = target(item);
// Drop the child that just repeats the card's own destination —
// "All organizations" under Organizations.
const extras = (item.children ?? []).filter((child) => child.to !== to);
return (
<div className="group relative flex flex-col rounded-2xl border border-[#138ba0]/20 bg-white p-5 transition-all hover:border-[#138ba0]/60 hover:shadow-sm">
<div className="flex items-baseline gap-3">
<h3 className="text-base font-semibold text-[#138ba0]">
<Link
to={to}
className="after:absolute after:inset-0 after:rounded-2xl after:content-['']"
>
{item.label}
</Link>
</h3>
<span
aria-hidden="true"
className="ml-auto text-[#138ba0] opacity-0 transition-opacity group-hover:opacity-100"
>
→
</span>
</div>
{item.blurb && (
<p className="mt-1.5 text-sm leading-relaxed text-[#4a6b72]">{item.blurb}</p>
)}
{extras.length > 0 && (
<div className="relative z-10 mt-4 flex flex-wrap gap-x-4 gap-y-1 border-t border-[#138ba0]/10 pt-3 text-sm">
{extras.map((child) => (
<Link
key={child.to}
to={child.to}
className="text-[#4a6b72] underline-offset-4 transition-colors hover:text-[#138ba0] hover:underline"
>
{child.label}
</Link>
))}
</div>
)}
</div>
);
}
function CardBlock({ title, blurb, children }) {
return (
<div className="mt-10">
<div className="flex items-baseline gap-3">
<h2 className="text-lg font-semibold text-[#138ba0]">{title}</h2>
{blurb && <p className="text-sm text-[#4a6b72]">{blurb}</p>}
</div>
<div className="mt-4 grid gap-4 sm:grid-cols-2">{children}</div>
</div>
);
}
function PanelSection({ title, children }) {
return (
<section className="border-b border-[#138ba0]/10 px-5 py-4 last:border-b-0">
<h2 className="text-xs font-semibold uppercase tracking-wider text-[#4a6b72]/70">
{title}
</h2>
<div className="mt-2.5 text-sm text-[#4a6b72]">{children}</div>
</section>
);
}
const QUICK_ADD = [
{ to: "/admin/events/new", label: "New event" },
{ to: "/admin/people/new", label: "New person" },
{ to: "/admin/organizations/new", label: "New organization" },
{ to: "/admin/timeline/new", label: "New timeline entry" },
];
export default function AdminHome() {
const { user } = useAuth();
const role = ROLE_LABELS[user?.role] ?? user?.role;
return (
<div className="grid gap-8 lg:grid-cols-[1fr_17rem] lg:items-start">
<div>
<h1 className="text-2xl font-bold text-[#138ba0]">
{user?.name ? `Welcome back, ${user.name.split(" ")[0]}.` : "Welcome back."}
</h1>
<p className="mt-1 text-[#4a6b72]">Pick a section to work in.</p>
<div className="mt-6 grid gap-4 sm:grid-cols-2">
{CMS_NAV.map((item) => (
<NavCard key={item.label} item={item} />
))}
</div>
<CardBlock title={FORMS_NAV.label} blurb={FORMS_NAV.blurb}>
{FORMS_NAV.children.map((form) => (
<NavCard key={form.to} item={form} />
))}
</CardBlock>
{isSuper(user) && (
<CardBlock title="Superadmin" blurb="Only superadmins see this block.">
<NavCard item={PANEL_NAV} />
</CardBlock>
)}
</div>
{/* Sticky so it stays put once the card column outgrows it. */}
<aside className="divide-y divide-[#138ba0]/10 rounded-2xl border border-[#138ba0]/20 bg-white lg:sticky lg:top-6">
<PanelSection title="Signed in">
<p className="font-medium text-[#0f2f36]">{user?.name || user?.email}</p>
{user?.name && user?.email && (
<p className="mt-0.5 break-all text-xs text-[#4a6b72]/80">{user.email}</p>
)}
{role && (
<span className="mt-2 inline-block rounded-full bg-[#eef9fb] px-2.5 py-0.5 text-xs font-medium text-[#138ba0]">
{role}
</span>
)}
</PanelSection>
<PanelSection title="Start something">
<ul className="space-y-1.5">
{QUICK_ADD.map((link) => (
<li key={link.to}>
<Link
to={link.to}
className="underline-offset-4 transition-colors hover:text-[#138ba0] hover:underline"
>
{link.label}
</Link>
</li>
))}
</ul>
</PanelSection>
<PanelSection title="Site">
<p className="font-mono text-xs text-[#4a6b72]/80">{SITE_VERSION}</p>
<a
href="/"
target="_blank"
rel="noreferrer"
className="mt-2 inline-block underline-offset-4 transition-colors hover:text-[#138ba0] hover:underline"
>
View the public site ↗
</a>
</PanelSection>
</aside>
</div>
);
}

View file

@ -5,59 +5,45 @@
footer site map — none of that belongs around a staff tool, and
/admin should never appear in navConfig.
The nav is two tiers, the same shape as the public header: a
primary row of the things you'd go looking for, and a subnav of
whatever sits under the one you're in. Teams and Awards live
under Organizations because that's where they belong
conceptually — a team is part of an org, an award is given by
one — even though each is its own table and its own page.
Three areas share this chrome: Home, the CMS and the Panel. The
wordmark names whichever one you're in, and from anywhere but
Home it's the way back to Home.
NAV is the single source for the rows, the document title and
which tab lights up. Adding an entity is an entry here plus a
descriptor; there's no second list to keep in step.
The tab row is drawn everywhere except Home, where the card grid
is the navigation and drawing both would say the same thing
twice. Which tabs appear depends on the signed-in user —
navFor() drops the superadmin-only ones — but that's cosmetics.
The route guard and the API are what actually say no.
═══════════════════════════════════════════════════════════════ */
import { useCallback, useEffect, useMemo, useState } from "react";
import { NavLink, Outlet, useLocation, useNavigate } from "react-router-dom";
import { Link, NavLink, Outlet, useLocation, useNavigate } from "react-router-dom";
import { useAuth } from "../../lib/auth.tsx";
import { AdminTitleContext } from "../../lib/adminTitle.tsx";
const SITE_TITLE = "NGU Admin CMS";
// A group with no `to` of its own opens its first child, so
// clicking the word Forms goes somewhere rather than nowhere.
const NAV = [
{ to: "/admin/events", label: "Events" },
{
to: "/admin/organizations",
label: "Organizations",
children: [
{ to: "/admin/organizations", label: "All organizations" },
{ to: "/admin/teams", label: "Teams" },
{ to: "/admin/awards", label: "Awards" },
],
},
{ to: "/admin/people", label: "People" },
{
label: "Forms",
separated: true,
children: [{ to: "/admin/feedback", label: "Website feedback" }],
},
];
// A tab owns its own page and everything below it, so editing
// /admin/teams/ngu-board keeps Teams lit.
const matches = (pathname, to) =>
Boolean(to) && (pathname === to || pathname.startsWith(`${to}/`));
const target = (item) => item.to ?? item.children?.[0]?.to;
import { SITE_VERSION } from "../../lib/version.ts";
import { ROLE_LABELS, isSuper } from "../../lib/roles.ts";
import nguLogo from "../../assets/NGU_Logo.svg";
import {
ADMIN_HOME,
AREA_TITLES,
areaFor,
matches,
navFor,
target,
} from "./adminNav.js";
export default function AdminLayout() {
const { user, logout } = useAuth();
const navigate = useNavigate();
const { pathname } = useLocation();
const active = NAV.find(
const area = areaFor(pathname);
const areaTitle = AREA_TITLES[area];
const isHome = area === "home";
const nav = useMemo(() => navFor(user), [user]);
const active = nav.find(
(item) =>
matches(pathname, item.to) ||
(item.children ?? []).some((child) => matches(pathname, child.to)),
@ -75,9 +61,12 @@ export default function AdminLayout() {
const titleContext = useMemo(() => ({ setDetail: stableSet }), [stableSet]);
useEffect(() => {
const section = activeChild?.label ?? active?.label;
document.title = [detail, section, SITE_TITLE].filter(Boolean).join(" | ");
}, [active, activeChild, detail]);
// Sections only exist in the CMS. Home and Panel already say
// what they are in the area title; "Panel | NGU Admin Panel"
// would just stutter.
const section = area === "cms" ? activeChild?.label ?? active?.label : null;
document.title = [detail, section, areaTitle].filter(Boolean).join(" | ");
}, [area, areaTitle, active, activeChild, detail]);
async function handleLogout() {
await logout();
@ -86,37 +75,54 @@ export default function AdminLayout() {
const subnav = active?.children ?? [];
const wordmark = <span className="text-lg font-bold text-[#138ba0]">{areaTitle}</span>;
return (
<div className="min-h-screen bg-[#f6fbfc]">
<div className="flex min-h-screen flex-col bg-[#f6fbfc]">
<header className="border-b border-[#138ba0]/20 bg-white">
<div className="mx-auto flex max-w-5xl flex-wrap items-center gap-x-8 gap-y-3 px-6 py-4">
<span className="text-lg font-bold text-[#138ba0]">{SITE_TITLE}</span>
{/* Already home, so nothing to link to. */}
{isHome ? (
wordmark
) : (
<Link
to={ADMIN_HOME}
className="rounded transition-opacity hover:opacity-70"
title="Back to the admin home"
>
{wordmark}
</Link>
)}
<nav className="flex items-center gap-6 text-sm">
{NAV.map((item) => (
<div key={item.label} className="flex items-center gap-6">
{item.separated && (
<span
aria-hidden="true"
className="h-4 w-px bg-[#4a6b72]/25"
/>
)}
<NavLink
to={target(item)}
className={
item === active
? "font-semibold text-[#138ba0]"
: "text-[#4a6b72] transition-colors hover:text-[#138ba0]"
}
>
{item.label}
</NavLink>
</div>
))}
</nav>
{!isHome && (
<nav className="flex items-center gap-6 text-sm">
{nav.map((item) => (
<div key={item.label} className="flex items-center gap-6">
{item.separated && (
<span
aria-hidden="true"
className="h-4 w-px bg-[#4a6b72]/25"
/>
)}
<NavLink
to={target(item)}
className={
item === active
? "font-semibold text-[#138ba0]"
: "text-[#4a6b72] transition-colors hover:text-[#138ba0]"
}
>
{item.label}
</NavLink>
</div>
))}
</nav>
)}
<div className="ml-auto flex items-center gap-4 text-sm text-[#4a6b72]">
<span>{user?.name || user?.email}</span>
<span>
{user?.name || user?.email}
</span>
<button
type="button"
onClick={handleLogout}
@ -150,11 +156,25 @@ export default function AdminLayout() {
)}
</header>
<main className="mx-auto max-w-5xl px-6 py-10">
{/* flex-1 rather than a fixed height: the footer sits at the
bottom of a short page and below the content of a long one,
without ever floating over it. */}
<main className="mx-auto w-full max-w-5xl flex-1 px-6 py-10">
<AdminTitleContext.Provider value={titleContext}>
<Outlet />
</AdminTitleContext.Provider>
</main>
<footer className="border-t border-[#138ba0]/15 bg-white">
<div className="mx-auto flex max-w-5xl items-center gap-4 px-6 py-3">
<Link to={ADMIN_HOME} className="transition-opacity hover:opacity-70">
<img src={nguLogo} alt="NGU" className="h-6 w-auto" />
</Link>
<span className="ml-auto font-mono text-xs text-[#4a6b72]/70">
{SITE_VERSION}
</span>
</div>
</footer>
</div>
);
}

View file

@ -32,7 +32,7 @@ export default function AdminLogin() {
const [error, setError] = useState(null);
const [busy, setBusy] = useState(false);
const destination = location.state?.from?.pathname ?? "/admin/feedback";
const destination = location.state?.from?.pathname ?? "/admin/home";
async function handleSubmit(event) {
event.preventDefault();

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

View file

@ -23,6 +23,16 @@
updated_at column simply never get one, and the 409 path stays
dormant for them.
Two capabilities, not one role. An editor may create and update
but not delete, so the action bar asks canWrite/canDelete rather
than comparing user.role to a string. The comparison this
replaced — role === "admin" — locked superadmins out of saving
the moment a rank above admin existed, which is what an equality
test against a ladder always eventually does.
None of this is protection. The server refuses the request; this
only decides whether to draw a button that would be refused.
slugFrom may name one field or several. Most ids are unique
because the name is: two organizations aren't both called
Northwest. Team ids are the exception — teams.id is a global
@ -37,6 +47,7 @@ import { get, post, patch, del, ApiError } from "../../lib/api.js";
import { isUnauthorized, useAuth } from "../../lib/auth.tsx";
import { useAdminDetail } from "../../lib/adminTitle.tsx";
import { ADMIN_ENTITIES, slugify } from "../../lib/adminSchema.js";
import { atLeast } from "../../lib/roles.ts";
import { Field, FieldGrid, Repeater, getPath, setPath } from "../../components/admin/fields.tsx";
/* A foreign key refusing to budge is the most common way a save or
@ -56,7 +67,14 @@ export default function EntityEdit() {
const { user } = useAuth();
const isNew = id === "new";
const canWrite = user?.role === "admin";
const canWrite = atLeast(user, "editor");
const canDelete = atLeast(user, "admin");
// Some entities have no slug: the table assigns an integer id, so
// there is nothing to type on create and nothing to compose from
// other fields. Timeline entries are the first — an entry that
// references an event has no name of its own.
const autoId = manifest?.idKind === "auto";
// Hoisted above the loading guards: the title hook below is a
// hook, so it can't sit after an early return, and it needs the
@ -92,8 +110,9 @@ export default function EntityEdit() {
};
// The heading wants the specific half, not the qualifier: a team
// page reads "Board", not "northwest Board".
const headingPath = slugPaths[slugPaths.length - 1];
// page reads "Board", not "northwest Board". An entity with no slug
// names the field to read instead.
const headingPath = slugPaths[slugPaths.length - 1] ?? manifest?.titleFrom;
const [form, setForm] = useState(null);
const [options, setOptions] = useState({});
@ -120,7 +139,9 @@ export default function EntityEdit() {
// touch", which is wrong for a row that doesn't exist yet.
// The parent's own fields stay absent on purpose so the
// server's column defaults apply to whatever isn't filled in.
const blank = { id: "" };
// No id key for an auto entity: the table assigns it, and
// sending "" would be an explicit value rather than an absence.
const blank = autoId ? {} : { id: "" };
for (const child of manifest.children ?? []) blank[child.key] = [];
setForm(blank);
baseline.current = JSON.stringify(blank);
@ -139,7 +160,7 @@ export default function EntityEdit() {
} finally {
setLoading(false);
}
}, [manifest, id, isNew, navigate]);
}, [manifest, id, isNew, autoId, navigate]);
useEffect(() => {
load();
@ -288,7 +309,19 @@ export default function EntityEdit() {
{isNew ? `New ${manifest.singular}` : heading}
</h1>
{/* Slug */}
{/* Slug. An auto-id entity has nothing to ask for on create, and
nothing editable afterwards — so it gets a plain line rather
than a disabled box pretending to be a field. */}
{autoId ? (
!isNew && (
<div className="mt-6 rounded-2xl border border-[#138ba0]/20 bg-white p-5">
<p className="text-sm text-[#4a6b72]">
{manifest.idLabel} #{form.id}
{form.updated_at && <> · last saved {form.updated_at}</>}
</p>
</div>
)
) : (
<div className="mt-6 rounded-2xl border border-[#138ba0]/20 bg-white p-5">
<Field
field={{
@ -317,6 +350,7 @@ export default function EntityEdit() {
</p>
)}
</div>
)}
{/* Field groups */}
{manifest.groups.filter((group) => visible(group.when)).map((group) => (
@ -332,6 +366,7 @@ export default function EntityEdit() {
<Field
key={field.path}
field={field}
row={form}
value={getPath(form, field.path)}
options={options}
error={errors[field.path]}
@ -376,7 +411,7 @@ export default function EntityEdit() {
>
{saving ? "Saving…" : isNew ? "Create" : "Save changes"}
</button>
{!isNew && (
{!isNew && canDelete && (
<button
type="button"
onClick={remove}
@ -388,7 +423,7 @@ export default function EntityEdit() {
</>
) : (
<span className="text-sm text-[#4a6b72]">
Read-only: your account can't save changes.
Read-only: your account can view this but not change it.
</span>
)}

View file

@ -4,6 +4,12 @@
One component for organizations, events and people. The :entity
route param picks the manifest; nothing here knows what a
chapter or a retreat is.
The "New X" link follows the same rule as EntityEdit's save
button: atLeast(user, "editor"), matching requireRole("editor")
on POST /api/admin/:entity. Drawing it is not permission — the
server decides — it only avoids offering a click that 403s, and
avoids hiding one that wouldn't.
═══════════════════════════════════════════════════════════════ */
import { useCallback, useEffect, useState } from "react";
@ -12,6 +18,7 @@ import { Link, useNavigate, useParams, useSearchParams } from "react-router-dom"
import { get, ApiError } from "../../lib/api.js";
import { isUnauthorized, useAuth } from "../../lib/auth.tsx";
import { ADMIN_ENTITIES } from "../../lib/adminSchema.js";
import { atLeast } from "../../lib/roles.ts";
export default function EntityList() {
const { entity: entityKey } = useParams();
@ -26,7 +33,12 @@ export default function EntityList() {
const [error, setError] = useState(null);
const [query, setQuery] = useState(params.get("q") ?? "");
const canWrite = user?.role === "admin";
// Minimum rank, never equality. POST /api/admin/:entity is gated
// at "editor", so anyone from editor upward may create — and the
// equality test this replaced hid the button from superadmins as
// well as editors, which is the failure mode an == against a
// ladder always produces once a rank is added above it.
const canWrite = atLeast(user, "editor");
const load = useCallback(async () => {
if (!manifest) return;

View file

@ -0,0 +1,34 @@
/* ═══════════════════════════════════════════════════════════════
ROLE GUARD — src/pages/admin/RequireRole.tsx
Sits inside RequireAuth, never instead of it: by the time this
renders, the session question has already been answered. All
this decides is whether the answer was good enough.
Same caveat as RequireAuth — this hides the interface, not the
data. /api/admin/panel/* is superadmin-only on the server, and
that's the part that matters. Without it, a bookmarked URL and
a disabled select would be the only thing between a viewer and
the account list.
Bounces to Home rather than showing a "denied" page. Someone
who lands here has almost always followed a stale link, and a
working page beats an explanation of one.
═══════════════════════════════════════════════════════════════ */
import { Navigate, Outlet } from "react-router-dom";
import { useAuth } from "../../lib/auth.tsx";
import { atLeast } from "../../lib/roles.ts";
import { ADMIN_HOME } from "./adminNav.js";
export default function RequireRole({ role = "superadmin" }) {
const { user, loading } = useAuth();
// RequireAuth is already showing its own placeholder above this.
if (loading) return null;
if (!user) return <Navigate to="/admin/login" replace />;
if (!atLeast(user, role)) return <Navigate to={ADMIN_HOME} replace />;
return <Outlet />;
}

114
src/pages/admin/adminNav.js Normal file
View file

@ -0,0 +1,114 @@
/* ═══════════════════════════════════════════════════════════════
ADMIN NAVIGATION — src/pages/admin/adminNav.js
Lifted out of AdminLayout because two things read it now: the
header tabs and the card grid on the home page. Adding an entity
stays one entry here plus a descriptor — there's still no second
list to keep in step, it just isn't inside the layout file
any more.
`blurb` is only read by the home cards. The header ignores it.
`superOnly` hides an entry from anyone below superadmin. It
hides, nothing more: the route still has to be guarded and the
API still has to say no. Treating a filtered menu as access
control is how you end up with a URL that works.
Three areas, three titles. The area is derived from the path
rather than declared per route, so a page added under
/admin/panel/… inherits the right title without registering
anything.
═══════════════════════════════════════════════════════════════ */
import { isSuper } from "../../lib/roles.ts";
export const ADMIN_HOME = "/admin/home";
export const ADMIN_PANEL = "/admin/panel";
export const AREA_TITLES = {
home: "NGU Admin Home",
cms: "NGU Admin CMS",
panel: "NGU Admin Panel",
};
/* The CMS tabs. Teams and Awards live under Organizations because
that's where they belong conceptually — a team is part of an org,
an award is given by one — even though each is its own table and
its own page. */
export const CMS_NAV = [
{
to: "/admin/events",
label: "Events",
blurb: "Retreats, conferences and gatherings, with their sections and rosters.",
},
{
to: "/admin/organizations",
label: "Organizations",
blurb: "Regions, chapters and partners — plus the teams and awards they own.",
children: [
{ to: "/admin/organizations", label: "All organizations" },
{ to: "/admin/teams", label: "Teams" },
{ to: "/admin/awards", label: "Awards" },
],
},
{
to: "/admin/people",
label: "People",
blurb: "Bios, contact details, affiliations and awards received.",
},
// Its own tab rather than a child of anything: a timeline entry can
// point at an event, an organization, an award, a person or a team,
// so filing it under one of them would be arbitrary.
{
to: "/admin/timeline",
label: "Timeline",
blurb: "What the history page shows, and the order it shows it in.",
},
];
/* Forms are submissions coming in rather than content going out, so
they get their own group in the header and their own block on the
home page. A group with no `to` of its own opens its first child,
so clicking the word Forms goes somewhere rather than nowhere. */
export const FORMS_NAV = {
label: "Forms",
blurb: "Whatever the public site has sent us.",
separated: true,
children: [
{
to: "/admin/feedback",
label: "Website feedback",
blurb: "The triage queue for the feedback form.",
},
],
};
/* Accounts and server state, not content — which is why it sits
outside the CMS rather than as another tab within it. */
export const PANEL_NAV = {
to: ADMIN_PANEL,
label: "Panel",
blurb: "Accounts, sessions and the state of the server.",
separated: true,
superOnly: true,
};
export const NAV = [...CMS_NAV, FORMS_NAV, PANEL_NAV];
/* What this user may see. Call it with the user from useAuth. */
export function navFor(user) {
return NAV.filter((item) => !item.superOnly || isSuper(user));
}
/* A tab owns its own page and everything below it, so editing
/admin/teams/ngu-board keeps Teams lit. */
export const matches = (pathname, to) =>
Boolean(to) && (pathname === to || pathname.startsWith(`${to}/`));
export const target = (item) => item.to ?? item.children?.[0]?.to;
export function areaFor(pathname) {
if (matches(pathname, ADMIN_HOME)) return "home";
if (matches(pathname, ADMIN_PANEL)) return "panel";
return "cms";
}