v1.4 - added admin page and auth

This commit is contained in:
Zaldimmar 2026-09-25 02:36:49 -05:00
parent 5efdafbb97
commit 1f0aa3078f
29 changed files with 5264 additions and 217 deletions

614
src/lib/adminSchema.js Normal file
View file

@ -0,0 +1,614 @@
/* ═══════════════════════════════════════════════════════════════
ADMIN FORM MANIFESTS
The presentation half of server/src/admin-schema.js: labels,
widgets, grouping, and which select pulls from which option
list. The server decides what's storable; this decides what the
form looks like. Paths here must match column names there, and
nested paths ('region.scope') match the side-table keys.
Adding a field is one entry. Adding an entity is one object here
plus one descriptor on the server — no new page component.
A value seeded in a repeater's `blank` must also be declared as
a default: on the matching server column, or a row the user adds
and never fills in reads as real input instead of being skipped.
═══════════════════════════════════════════════════════════════ */
const PLACE_FIELDS = [
{ path: "venue", label: "Venue" },
{ path: "address", label: "Address", full: true },
{ path: "locality", label: "City" },
{ path: "state_code", label: "State", help: "Two letters, US only" },
{ path: "country", label: "Country", help: "Defaults to US" },
{ path: "location_label", label: "Display location", help: "Overrides the line built from city and state" },
{ path: "latitude", label: "Latitude", widget: "number" },
{ path: "longitude", label: "Longitude", widget: "number" },
{ path: "is_online", label: "Online", widget: "checkbox" },
];
const PUBLISH_FIELDS = [
{ path: "is_published", label: "Published", widget: "checkbox" },
{ path: "sort_order", label: "Sort order", widget: "number" },
];
/* The parts of an affiliation that read the same from either end —
the person's list of roles, and the team's list of members. */
const AFFILIATION_ROLE_FIELDS = [
{ path: "title", label: "Title", help: "Board Chair, Chapter Lead" },
{
path: "role",
label: "Role",
widget: "select",
options: ["lead", "board", "staff", "volunteer", "member"],
},
{ path: "is_owner", label: "Owner", widget: "checkbox", help: "Listed first" },
{ path: "started_on", label: "Started", widget: "date" },
{ path: "ended_on", label: "Ended", widget: "date", help: "Blank means current" },
{ path: "is_public", label: "Public", widget: "checkbox" },
];
/* The two collections every entity carries. */
const linksChild = {
key: "links",
label: "Links and socials",
addLabel: "Add link",
title: (row) => row.label || row.url || "New link",
blank: { kind: "action", label: "", url: "", is_primary: 0 },
fields: [
{
path: "kind",
label: "Kind",
widget: "select",
options: ["action", "social", "website", "email"],
},
{ path: "platform", label: "Platform", help: "instagram, discord…" },
{ path: "label", label: "Label", required: true },
{ path: "url", label: "URL", required: true, full: true },
{ path: "is_primary", label: "Primary", widget: "checkbox" },
],
};
const blocksChild = {
key: "content_blocks",
label: "Content blocks",
addLabel: "Add block",
title: (row) => `${row.type ?? "block"} — ${(row.text ?? "").slice(0, 40) || "empty"}`,
blank: { slot: "body", type: "paragraph", text: "" },
fields: [
{ path: "slot", label: "Slot", widget: "select", options: ["card", "body"] },
{
path: "type",
label: "Type",
widget: "select",
options: [
"heading",
"subheading",
"paragraph",
"list",
"links",
"quote",
"image",
"divider",
],
},
{ path: "text", label: "Text", widget: "textarea", full: true },
{ path: "media", label: "Media", help: "Filename or URL" },
{ path: "href", label: "Link" },
],
children: [
{
key: "items",
label: "Items",
addLabel: "Add item",
title: (row) => row.text || "New item",
blank: { text: "" },
fields: [
{ path: "text", label: "Text", required: true },
{ path: "detail", label: "Detail" },
{ path: "url", label: "URL", help: "Blank for a plain list item" },
],
},
],
};
/* ── Organizations ───────────────────────────────────────────── */
const organizations = {
key: "organizations",
label: "Organizations",
singular: "organization",
idLabel: "Slug",
slugFrom: "name",
list: {
columns: [
{ key: "name", label: "Name", primary: true },
{ key: "kind", label: "Kind" },
{ key: "locality", label: "City" },
{ key: "state_code", label: "State" },
{ key: "is_published", label: "Live", widget: "bool" },
],
filters: [
{ key: "kind", label: "Kind", options: ["national", "region", "chapter", "partner"] },
{ key: "is_published", label: "Published", options: [["1", "Live"], ["0", "Hidden"]] },
],
},
groups: [
{
legend: "Identity",
fields: [
{
path: "kind",
label: "Kind",
widget: "select",
options: ["national", "region", "chapter", "partner"],
required: true,
help: "Changing this swaps which extra fields apply",
},
{ path: "name", label: "Name", required: true },
{ path: "short_name", label: "Short name" },
{ path: "tagline", label: "Tagline", full: true },
{ path: "color", label: "Colour", widget: "color" },
{ path: "logo", label: "Logo", help: "Filename in public/org-logos/" },
],
},
{
legend: "Region",
when: { path: "kind", value: "region" },
fields: [
{
path: "region.scope",
label: "Scope",
widget: "select",
options: ["domestic", "international", "virtual"],
required: true,
},
{ path: "region.map_note", label: "Map note", full: true },
],
},
{
legend: "Chapter",
when: { path: "kind", value: "chapter" },
fields: [
{
path: "chapter.region_id",
label: "Region",
widget: "select",
optionsFrom: "regions",
blankLabel: "— none —",
},
{ path: "chapter.meets", label: "Meets", help: "2nd Sundays, 6:00pm" },
{ path: "chapter.started", label: "Started", help: "Since 2021" },
],
},
{ legend: "Place", fields: PLACE_FIELDS },
{ legend: "Publishing", fields: PUBLISH_FIELDS },
],
children: [
{
key: "region_areas",
label: "Map areas",
when: { path: "kind", value: "region" },
addLabel: "Add area",
title: (row) => row.area_code || "New area",
blank: { area_code: "", share: 1 },
fields: [
{ path: "area_code", label: "Area code", required: true, help: "WA, CA, CANADA" },
{ path: "share", label: "Share", widget: "number", help: "1 for the whole tile, 0.5 for half" },
{
path: "edge",
label: "Edge",
widget: "select",
options: ["top", "bottom"],
blankLabel: "— whole tile —",
},
{ path: "note", label: "Note", help: "north, Salt Lake City area" },
],
},
linksChild,
blocksChild,
],
};
/* ── Events ──────────────────────────────────────────────────── */
const events = {
key: "events",
label: "Events",
singular: "event",
idLabel: "Slug",
slugFrom: "title",
list: {
columns: [
{ key: "title", label: "Title", primary: true },
{ key: "section_id", label: "Section" },
{ key: "date_label", label: "Dates" },
{ key: "status", label: "Status" },
{ key: "is_published", label: "Live", widget: "bool" },
],
filters: [
{ key: "section_id", label: "Section", optionsFrom: "event_sections" },
{ key: "status", label: "Status", options: ["upcoming", "past", "cancelled"] },
{ key: "is_published", label: "Published", options: [["1", "Live"], ["0", "Hidden"]] },
],
},
groups: [
{
legend: "Identity",
fields: [
{
path: "section_id",
label: "Section",
widget: "select",
optionsFrom: "event_sections",
required: true,
},
{
path: "host_org_id",
label: "Host",
widget: "select",
optionsFrom: "organizations",
blankLabel: "— none —",
help: "Supplies the logo and colour when this event sets neither",
},
{ path: "title", label: "Title", required: true },
{ path: "theme", label: "Theme" },
{ path: "tagline", label: "Tagline", full: true },
],
},
{
legend: "When",
fields: [
{ path: "starts_on", label: "Starts", widget: "date" },
{ path: "ends_on", label: "Ends", widget: "date" },
{
path: "date_label",
label: "Date label",
help: "What the card shows — 'March/April 2026' is fine here",
},
{
path: "status",
label: "Status",
widget: "select",
options: ["upcoming", "past", "cancelled"],
blankLabel: "— derive from end date —",
},
],
},
{ legend: "Where", fields: PLACE_FIELDS },
{
legend: "Appearance",
fields: [
{ path: "event_logo", label: "Event logo", help: "Filename in public/event-logos/" },
{ path: "org_logo", label: "Org logo override" },
{ path: "color", label: "Colour", widget: "color" },
{ path: "gradient", label: "Gradient", full: true },
],
},
{ legend: "Publishing", fields: PUBLISH_FIELDS },
],
children: [
{
key: "event_people",
label: "People at this event",
addLabel: "Add person",
title: (row, options) =>
options?.people?.find((p) => p.id === row.person_id)?.label ?? "New person",
blank: { person_id: "", role: "speaker", is_public: 1 },
fields: [
{
path: "person_id",
label: "Person",
widget: "select",
optionsFrom: "people",
required: true,
},
{
path: "role",
label: "Role",
widget: "select",
options: [
"speaker",
"leader",
"facilitator",
"host",
"musician",
"volunteer",
"attendee",
],
},
{ path: "title", label: "Title", help: "Keynote Speaker" },
{ path: "is_public", label: "Show on the site", widget: "checkbox" },
],
},
linksChild,
blocksChild,
],
};
/* ── People ──────────────────────────────────────────────────── */
const people = {
key: "people",
label: "People",
singular: "person",
idLabel: "Slug",
slugFrom: "display_name",
list: {
columns: [
{ key: "display_name", label: "Name", primary: true },
{ key: "tagline", label: "Tagline" },
{ key: "locality", label: "City" },
{ key: "is_published", label: "Live", widget: "bool" },
],
filters: [
{ key: "is_published", label: "Published", options: [["1", "Live"], ["0", "Hidden"]] },
],
},
groups: [
{
legend: "Identity",
fields: [
{ path: "display_name", label: "Display name", required: true },
{ path: "sort_name", label: "Sort name", help: "Doe, Jane" },
{ path: "pronouns", label: "Pronouns" },
{ path: "tagline", label: "Tagline", full: true },
{ path: "photo", label: "Photo", help: "Filename in public/people/" },
{
path: "primary_org_id",
label: "Home organization",
widget: "select",
optionsFrom: "organizations",
blankLabel: "— none —",
help: "Optional. Shown above their bio",
},
{
path: "bio",
label: "Bio",
widget: "textarea",
full: true,
help: "Leave a blank line between paragraphs",
},
],
},
{
legend: "Public contact",
fields: [
{ path: "public_email", label: "Email", help: "Printed on the site" },
{ path: "public_phone", label: "Phone" },
{ path: "locality", label: "City" },
{ path: "state_code", label: "State" },
{ path: "country", label: "Country" },
{ path: "location_label", label: "Display location" },
],
},
{
legend: "Private",
note: "Never sent to the public site. Only admins see this.",
fields: [
{ path: "private.birth_date", label: "Birth date", widget: "date" },
{ path: "private.private_email", label: "Email" },
{ path: "private.private_phone", label: "Phone" },
{ path: "private.address", label: "Address", full: true },
{ path: "private.notes", label: "Notes", widget: "textarea", full: true },
],
},
{
legend: "Publishing",
note: "Unpublished people are invisible everywhere, including as chapter leads.",
fields: PUBLISH_FIELDS,
},
],
children: [
{
key: "affiliations",
label: "Roles and organizations",
addLabel: "Add role",
note:
"Where someone appears within a team is set on that team's page, " +
"not by the order of this list.",
title: (row, options) =>
[row.title, options?.organizations?.find((o) => o.id === row.org_id)?.label]
.filter(Boolean)
.join(" · ") || "New role",
blank: { org_id: "", role: "member", is_public: 1, is_owner: 0 },
fields: [
{
path: "org_id",
label: "Organization",
widget: "select",
optionsFrom: "organizations",
required: true,
},
{
path: "team_id",
label: "Team",
widget: "select",
optionsFrom: "teams",
blankLabel: "— none —",
// A team belongs to an org; offering one from another
// org would fail the composite foreign key on save.
filterBy: (option, row) => option.org_id === row.org_id,
help: "Only teams of the chosen organization",
},
...AFFILIATION_ROLE_FIELDS,
],
},
{
key: "person_awards",
label: "Awards",
addLabel: "Add award",
title: (row, options) =>
options?.awards?.find((a) => a.id === row.award_id)?.label ?? "New award",
blank: { award_id: "", is_public: 1 },
fields: [
{
path: "award_id",
label: "Award",
widget: "select",
optionsFrom: "awards",
required: true,
help: "Labelled with the organization that gives it",
},
{
path: "event_id",
label: "Presented at",
widget: "select",
optionsFrom: "events",
blankLabel: "— none —",
},
{ path: "awarded_on", label: "Date", widget: "date" },
{ path: "citation", label: "Citation", widget: "textarea", full: true },
{ path: "is_public", label: "Public", widget: "checkbox" },
],
},
linksChild,
blocksChild,
],
};
/* ── Teams ───────────────────────────────────────────────────── */
const teams = {
key: "teams",
label: "Teams",
singular: "team",
idLabel: "Slug",
// teams.id is a global primary key, not scoped to the org, so
// 'board' can only exist once across the whole site. Composing
// the slug from both fields is what keeps NGU's board and the
// Northwest's board from colliding.
slugFrom: ["org_id", "name"],
list: {
columns: [
{ key: "name", label: "Name", primary: true },
{ key: "org_id", label: "Organization" },
{ key: "tagline", label: "Tagline" },
{ key: "is_published", label: "Live", widget: "bool" },
],
filters: [
{ key: "org_id", label: "Organization", optionsFrom: "organizations" },
{ key: "is_published", label: "Published", options: [["1", "Live"], ["0", "Hidden"]] },
],
},
groups: [
{
legend: "Identity",
note:
"Pick the organization first: the slug is built from it, " +
"and it can't be changed once anyone is filed under this team.",
fields: [
{
path: "org_id",
label: "Organization",
widget: "select",
optionsFrom: "organizations",
required: true,
help: "Who this team belongs to",
},
{ path: "name", label: "Name", required: true, help: "Board, Leadership Team" },
{ path: "tagline", label: "Tagline", full: true },
{ path: "color", label: "Colour", widget: "color" },
{ path: "logo", label: "Logo", help: "Filename in public/org-logos/" },
],
},
{ legend: "Publishing", fields: PUBLISH_FIELDS },
],
children: [
{
key: "members",
label: "Members",
addLabel: "Add member",
note:
"This order is the order they appear on the site. Owners are " +
"still listed first, in this order among themselves.",
title: (row, options) =>
[options?.people?.find((p) => p.id === row.person_id)?.label, row.title]
.filter(Boolean)
.join(" · ") || "New member",
blank: { person_id: "", role: "member", is_public: 1, is_owner: 0 },
fields: [
{
path: "person_id",
label: "Person",
widget: "select",
optionsFrom: "people",
required: true,
},
...AFFILIATION_ROLE_FIELDS,
],
},
linksChild,
blocksChild,
],
};
/* ── Awards ──────────────────────────────────────────────────── */
const awards = {
key: "awards",
label: "Awards",
singular: "award",
idLabel: "Slug",
slugFrom: "name",
list: {
columns: [
{ key: "name", label: "Name", primary: true },
{ key: "org_id", label: "Awarded by" },
{ key: "description", label: "Description" },
{ key: "sort_order", label: "Order" },
],
filters: [
{ key: "org_id", label: "Awarded by", optionsFrom: "organizations" },
],
},
groups: [
{
legend: "Identity",
note:
"An award exists whether or not anyone has received it. " +
"Who received it is edited on the person.",
fields: [
{
path: "org_id",
label: "Awarded by",
widget: "select",
optionsFrom: "organizations",
blankLabel: "— unattributed —",
help: "The organization that gives this award",
},
{ path: "name", label: "Name", required: true },
{ path: "description", label: "Description", widget: "textarea", full: true },
{ path: "logo", label: "Logo", help: "Filename in public/org-logos/" },
{ path: "sort_order", label: "Sort order", widget: "number" },
],
},
],
};
export const ADMIN_ENTITIES = { organizations, events, people, teams, awards };
export function slugify(value) {
return String(value ?? "")
.toLowerCase()
.normalize("NFKD")
.replace(/[^\w\s-]/g, "")
.trim()
.replace(/[\s_]+/g, "-")
.replace(/-+/g, "-")
.slice(0, 64);
}

30
src/lib/adminTitle.tsx Normal file
View file

@ -0,0 +1,30 @@
/* ═══════════════════════════════════════════════════════════════
ADMIN TITLE
The layout knows which section you're in; only the page knows
which record is on screen. Rather than have both write
document.title and race — child effects run before parent
effects, so the layout would win and the record name would
never survive — the page publishes a string and the layout is
the single writer.
Null is the normal state. A page that publishes nothing, or one
still loading, simply leaves the section title showing.
═══════════════════════════════════════════════════════════════ */
import { createContext, useContext, useEffect } from "react";
export const AdminTitleContext = createContext(null);
/* Publish the name of whatever this page is showing. Clears on
unmount, so navigating away can't leave a stale record name in
the tab. */
export function useAdminDetail(name) {
const setDetail = useContext(AdminTitleContext)?.setDetail;
useEffect(() => {
if (!setDetail) return undefined;
setDetail(name || null);
return () => setDetail(null);
}, [setDetail, name]);
}

View file

@ -100,3 +100,15 @@ export function post(path, data) {
body: JSON.stringify(data),
});
}
export function patch(path, data) {
return request(path, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
}
export function del(path) {
return request(path, { method: "DELETE" });
}

98
src/lib/auth.tsx Normal file
View file

@ -0,0 +1,98 @@
/* ═══════════════════════════════════════════════════════════════
AUTH CONTEXT
One fetch of /api/auth/me at mount decides whether anyone is
signed in. There's no token to store: the session cookie is
HttpOnly, so this code can't read it and neither can anything
injected into the page. "Am I signed in" is always a question
for the server.
RequireAuth guards routes. Worth being clear about what that
does and doesn't do: it hides the interface, not the data. The
protection is the 401 from /api/admin — this only stops someone
staring at an empty table.
═══════════════════════════════════════════════════════════════ */
import { createContext, useCallback, useContext, useEffect, useState } from "react";
import { Navigate, Outlet, useLocation } from "react-router-dom";
import { get, post, ApiError } from "./api.js";
const AuthContext = createContext(null);
export function AuthProvider({ children }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
let ignore = false;
get("/auth/me", { ttl: 0 })
.then((data) => {
if (!ignore) setUser(data.user);
})
.catch(() => {
if (!ignore) setUser(null); // 401 is the normal case
})
.finally(() => {
if (!ignore) setLoading(false);
});
return () => {
ignore = true;
};
}, []);
const login = useCallback(async (email, password) => {
const data = await post("/auth/login", { email, password });
setUser(data.user);
return data.user;
}, []);
const logout = useCallback(async () => {
try {
await post("/auth/logout", {});
} finally {
// Whatever the server said, this browser is done.
setUser(null);
}
}, []);
return (
<AuthContext.Provider value={{ user, loading, login, logout }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const value = useContext(AuthContext);
if (!value) throw new Error("useAuth used outside AuthProvider");
return value;
}
/* Signals a session that ended while the page was open — the
admin pages call this when a request comes back 401. */
export function isUnauthorized(error) {
return error instanceof ApiError && error.status === 401;
}
export function RequireAuth() {
const { user, loading } = useAuth();
const location = useLocation();
if (loading) {
return (
<div className="flex min-h-screen items-center justify-center text-[#4a6b72]">
Checking your session…
</div>
);
}
if (!user) {
// `from` lets the login page send them back where they aimed.
return <Navigate to="/admin/login" state={{ from: location }} replace />;
}
return <Outlet />;
}