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
|
|
@ -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>
|
||||
|
|
|
|||
182
src/pages/admin/AdminHome.tsx
Normal file
182
src/pages/admin/AdminHome.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
34
src/pages/admin/RequireRole.tsx
Normal file
34
src/pages/admin/RequireRole.tsx
Normal 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
114
src/pages/admin/adminNav.js
Normal 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";
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue