v1.4 - added admin page and auth

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

200
server/src/admin-cli.js Normal file
View file

@ -0,0 +1,200 @@
#!/usr/bin/env node
/* ═══════════════════════════════════════════════════════════════
ADMIN ACCOUNT CLI
The only way an account comes into existence. Run it on the box,
against the live database:
cd /srv/ngu-api
DB_PATH=/var/lib/ngu/ngu.db node src/admin-cli.js add you@ngu.org
DB_PATH=/var/lib/ngu/ngu.db node src/admin-cli.js list
DB_PATH=/var/lib/ngu/ngu.db node src/admin-cli.js passwd you@ngu.org
DB_PATH=/var/lib/ngu/ngu.db node src/admin-cli.js disable them@ngu.org
DB_PATH=/var/lib/ngu/ngu.db node src/admin-cli.js enable them@ngu.org
add takes --role=viewer for read-only, --name="Full Name".
Press enter at the password prompt and it generates one and
prints it once.
Changing or disabling a password also drops that person's live
sessions, so "disable" takes effect now rather than in 30 days.
═══════════════════════════════════════════════════════════════ */
import { createInterface } from "node:readline";
import { randomBytes } from "node:crypto";
import { openDatabase, migrate } from "./db.js";
import { hashPassword, destroyAllSessionsFor } from "./auth.js";
const MIN_PASSWORD = 12;
/* ── Prompt, with echo suppressed for secrets ────────────────── */
function ask(question, { hidden = false } = {}) {
return new Promise((resolve) => {
const rl = createInterface({
input: process.stdin,
output: process.stdout,
terminal: true,
});
rl.muted = false;
rl._writeToOutput = function (text) {
if (!rl.muted) rl.output.write(text);
};
rl.question(question, (answer) => {
if (hidden) rl.output.write("\n");
rl.close();
resolve(answer);
});
rl.muted = hidden;
});
}
async function readPassword() {
const first = await ask("Password (enter to generate): ", { hidden: true });
if (first === "") {
const generated = randomBytes(12).toString("base64url");
console.log(`\nGenerated password: ${generated}`);
console.log("Copy it now — it isn't stored anywhere readable.\n");
return generated;
}
if (first.length < MIN_PASSWORD) {
fail(`Password must be at least ${MIN_PASSWORD} characters.`);
}
const second = await ask("Again: ", { hidden: true });
if (first !== second) fail("Passwords didn't match.");
return first;
}
function fail(message) {
console.error(`✗ ${message}`);
process.exit(1);
}
/* ── Commands ────────────────────────────────────────────────── */
function findUser(db, email) {
return db
.prepare("SELECT id, email, name, role, is_active FROM admin_users WHERE email = ?")
.get(email);
}
async function add(db, email, flags) {
if (findUser(db, email)) fail(`${email} already exists. Use passwd to change it.`);
const role = flags.role ?? "admin";
if (!["admin", "viewer"].includes(role)) fail("Role must be admin or viewer.");
const password = await readPassword();
db.prepare(
`INSERT INTO admin_users (email, name, password_hash, role)
VALUES (?, ?, ?, ?)`,
).run(email, flags.name ?? null, hashPassword(password), role);
console.log(`✓ ${email} created as ${role}`);
}
async function passwd(db, email) {
const user = findUser(db, email);
if (!user) fail(`No account for ${email}.`);
const password = await readPassword();
db.prepare("UPDATE admin_users SET password_hash = ? WHERE id = ?").run(
hashPassword(password),
user.id,
);
destroyAllSessionsFor(db, user.id);
console.log(`✓ password changed for ${email}, existing sessions ended`);
}
function setActive(db, email, active) {
const user = findUser(db, email);
if (!user) fail(`No account for ${email}.`);
db.prepare("UPDATE admin_users SET is_active = ? WHERE id = ?").run(
active ? 1 : 0,
user.id,
);
if (!active) destroyAllSessionsFor(db, user.id);
console.log(`✓ ${email} ${active ? "enabled" : "disabled"}`);
}
function list(db) {
const rows = db
.prepare(
`SELECT u.email, u.name, u.role, u.is_active, u.last_login_at,
(SELECT COUNT(*) FROM sessions s
WHERE s.user_id = u.id AND s.expires_at > datetime('now')) AS sessions
FROM admin_users u
ORDER BY u.email`,
)
.all();
if (rows.length === 0) {
console.log("No accounts yet. Create one with: admin-cli.js add you@ngu.org");
return;
}
for (const r of rows) {
const state = r.is_active ? r.role : "disabled";
const seen = r.last_login_at ?? "never";
console.log(
`${r.email.padEnd(32)} ${state.padEnd(9)} last login ${seen.padEnd(20)} ${r.sessions} session(s)`,
);
}
}
/* ── Entry ───────────────────────────────────────────────────── */
const [command, ...rest] = process.argv.slice(2);
const flags = {};
const positional = [];
for (const arg of rest) {
const match = /^--([^=]+)=(.*)$/.exec(arg);
if (match) flags[match[1]] = match[2];
else positional.push(arg);
}
const email = positional[0]?.trim().toLowerCase();
const db = await openDatabase(process.env.DB_PATH ?? "./ngu.db");
migrate(db); // so a fresh database gets the tables before we use them
try {
switch (command) {
case "add":
if (!email) fail("Usage: admin-cli.js add email@example.com");
await add(db, email, flags);
break;
case "passwd":
if (!email) fail("Usage: admin-cli.js passwd email@example.com");
await passwd(db, email);
break;
case "disable":
setActive(db, email, false);
break;
case "enable":
setActive(db, email, true);
break;
case "list":
list(db);
break;
default:
console.log("Commands: add, passwd, disable, enable, list");
process.exit(command ? 1 : 0);
}
} finally {
db.close();
}

533
server/src/admin-crud.js Normal file
View file

@ -0,0 +1,533 @@
/* ═══════════════════════════════════════════════════════════════
ADMIN CRUD ENGINE
Reads a descriptor from admin-schema.js and does the SQL. No
entity names appear in this file.
Writes run inside tx() so a parent, its side table, and every
child collection either all land or none do. Children are
replaced wholesale rather than diffed: the client sends the list
it wants to exist, the engine deletes and reinserts in array
order, and sort_order becomes the index. That makes drag-to-
reorder free and removes a whole class of "which row is this"
bugs, at the cost of churning autoincrement ids — which is fine
precisely because nothing references them.
A column that isn't present in the payload at all is left out of
the statement entirely, so the table's DEFAULT applies on insert
and the existing value survives on update. A column present but
empty ("" or null) is an explicit clear and writes NULL. The
difference matters: sending NULL for every unmentioned column is
what turns a missing form field into a NOT NULL constraint
failure instead of a default.
═══════════════════════════════════════════════════════════════ */
import { tx } from "./db.js";
export class HttpError extends Error {
constructor(status, message, fields) {
super(message);
this.status = status;
this.fields = fields;
}
}
const SLUG = /^[a-z0-9][a-z0-9-]{0,63}$/;
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
/* Not a value the caller can ever send, so it can mean "leave this
column out of the statement" without colliding with real data. */
const OMIT = Symbol("omit");
/* ── Coercion ──────────────────────────────────────────────────
SQLite STRICT tables reject a type mismatch at the wall, but
the error it throws is unreadable. Everything is converted and
checked here so failures come back as named fields.
───────────────────────────────────────────────────────────── */
function coerceValue(column, raw, errors, prefix = "") {
const key = `${prefix}${column.name}`;
// Absent from the payload. Fall back to the descriptor default
// if it declares one, otherwise let the column's own DEFAULT do
// the work — which needs the column omitted, not nulled.
if (raw === undefined) {
if (column.default !== undefined) return column.default;
if (column.required) errors[key] = "Required.";
return OMIT;
}
// Before the blank check: an unchecked box legitimately arrives
// as false, "", or null, and all of those mean 0, not NULL.
if (column.type === "bool") {
return raw === true || raw === 1 || raw === "1" || raw === "true" ? 1 : 0;
}
if (raw === null || raw === "") {
if (column.required) {
errors[key] = "Required.";
return null;
}
// The table refuses NULL but declares a default: clearing the
// field means "use the default", not "write NULL".
if (column.notNullable) return OMIT;
return null;
}
switch (column.type) {
case "int": {
const n = Number(raw);
if (!Number.isInteger(n)) errors[key] = "Must be a whole number.";
return Number.isInteger(n) ? n : null;
}
case "real": {
const n = Number(raw);
if (!Number.isFinite(n)) errors[key] = "Must be a number.";
return Number.isFinite(n) ? n : null;
}
case "enum": {
const value = String(raw);
if (!column.values.includes(value)) {
errors[key] = `Must be one of: ${column.values.join(", ")}.`;
return null;
}
return value;
}
case "date": {
const value = String(raw).trim();
if (!ISO_DATE.test(value)) errors[key] = "Use YYYY-MM-DD.";
return ISO_DATE.test(value) ? value : null;
}
default: {
const value = String(raw).trim();
return value === "" ? null : value;
}
}
}
function coerceRow(columns, data, { prefix = "" } = {}) {
const errors = {};
const values = {};
for (const column of columns) {
const value = coerceValue(column, data?.[column.name], errors, prefix);
if (value !== OMIT) values[column.name] = value;
}
return { values, errors };
}
const applies = (gate, row) => !gate || row[gate.column] === gate.value;
/* ── Read ────────────────────────────────────────────────────── */
export function listRows(db, entity, query = {}) {
const { columns, filters, search, order } = entity.list;
const where = [];
const params = [];
for (const name of filters) {
const value = query[name];
if (value === undefined || value === "" || value === "all") continue;
where.push(`${name} = ?`);
params.push(value);
}
if (query.q) {
const term = `%${String(query.q).slice(0, 100)}%`;
where.push(`(${search.map((c) => `${c} LIKE ?`).join(" OR ")})`);
params.push(...search.map(() => term));
}
const limit = Math.min(Number(query.limit) || 200, 500);
const rows = db
.prepare(
`SELECT ${columns.join(", ")}
FROM ${entity.table}
${where.length ? `WHERE ${where.join(" AND ")}` : ""}
ORDER BY ${order}
LIMIT ?`,
)
.all(...params, limit);
return { rows, total: rows.length };
}
export function readRow(db, entity, id) {
const row = db
.prepare(`SELECT * FROM ${entity.table} WHERE ${entity.idColumn} = ?`)
.get(id);
if (!row) throw new HttpError(404, "Not found.");
for (const ext of entity.extensions ?? []) {
row[ext.key] =
db
.prepare(`SELECT * FROM ${ext.table} WHERE ${ext.idColumn} = ?`)
.get(id) ?? null;
}
for (const child of entity.children ?? []) {
row[child.key] = readChildren(db, child, id);
}
return row;
}
function readChildren(db, child, ownerId) {
const where = [`${child.owner.column} = ?`];
const params = [ownerId];
if (child.owner.kindColumn) {
where.push(`${child.owner.kindColumn} = ?`);
params.push(child.owner.kindValue);
}
const rows = db
.prepare(
`SELECT * FROM ${child.table}
WHERE ${where.join(" AND ")}
ORDER BY ${child.order}`,
)
.all(...params);
for (const child2 of child.children ?? []) {
for (const row of rows) {
row[child2.key] = db
.prepare(
`SELECT * FROM ${child2.table}
WHERE ${child2.owner.column} = ?
ORDER BY ${child2.order}`,
)
.all(row.id);
}
}
return rows;
}
/* ── Write ───────────────────────────────────────────────────── */
export function createRow(db, entity, payload) {
const id = String(payload?.[entity.idColumn] ?? "").trim().toLowerCase();
if (entity.idKind === "slug" && !SLUG.test(id)) {
throw new HttpError(422, "Validation failed", {
[entity.idColumn]: "Lowercase letters, numbers and hyphens only.",
});
}
const exists = db
.prepare(`SELECT 1 FROM ${entity.table} WHERE ${entity.idColumn} = ?`)
.get(id);
if (exists) {
throw new HttpError(422, "Validation failed", {
[entity.idColumn]: "Already taken.",
});
}
const { values, errors } = coerceRow(entity.columns, payload);
if (Object.keys(errors).length) {
throw new HttpError(422, "Validation failed", errors);
}
const names = [entity.idColumn, ...Object.keys(values)];
wrapDbErrors(() =>
tx(db, () => {
db.prepare(
`INSERT INTO ${entity.table} (${names.join(", ")})
VALUES (${names.map(() => "?").join(", ")})`,
).run(id, ...Object.values(values));
writeExtensions(db, entity, id, payload, values);
writeChildren(db, entity, id, payload, values);
}),
);
return readRow(db, entity, id);
}
export function updateRow(db, entity, id, payload) {
const current = db
.prepare(`SELECT * FROM ${entity.table} WHERE ${entity.idColumn} = ?`)
.get(id);
if (!current) throw new HttpError(404, "Not found.");
// Optimistic concurrency. The client echoes back the updated_at
// it loaded; anything else means someone saved in between.
if (entity.concurrency) {
const seen = payload?.[entity.concurrency];
if (!seen) {
throw new HttpError(400, `Missing ${entity.concurrency}.`);
}
if (seen !== current[entity.concurrency]) {
throw new HttpError(
409,
"Someone else saved this while you were editing. Reload to see their version.",
);
}
}
const { values, errors } = coerceRow(entity.columns, payload);
if (Object.keys(errors).length) {
throw new HttpError(422, "Validation failed", errors);
}
// An unsent column keeps its stored value, so the gates below
// have to read the merged row, not just what came in.
const merged = { ...current, ...values };
wrapDbErrors(() =>
tx(db, () => {
const sets = Object.keys(values).map((name) => `${name} = ?`);
if (sets.length) {
db.prepare(
`UPDATE ${entity.table} SET ${sets.join(", ")}
WHERE ${entity.idColumn} = ?`,
).run(...Object.values(values), id);
}
writeExtensions(db, entity, id, payload, merged);
writeChildren(db, entity, id, payload, merged);
}),
);
return readRow(db, entity, id);
}
export function deleteRow(db, entity, id) {
const result = wrapDbErrors(() =>
db.prepare(`DELETE FROM ${entity.table} WHERE ${entity.idColumn} = ?`).run(id),
);
if (result.changes === 0) throw new HttpError(404, "Not found.");
}
/* ── Write helpers ───────────────────────────────────────────── */
function writeExtensions(db, entity, id, payload, parentValues) {
for (const ext of entity.extensions ?? []) {
if (!applies(ext.when, parentValues)) {
// The gate closed — the kind changed away from this side
// table, so its row (and anything cascading off it) goes.
db.prepare(`DELETE FROM ${ext.table} WHERE ${ext.idColumn} = ?`).run(id);
continue;
}
const { values, errors } = coerceRow(ext.columns, payload[ext.key] ?? {}, {
prefix: `${ext.key}.`,
});
if (Object.keys(errors).length) {
throw new HttpError(422, "Validation failed", errors);
}
if (ext.touch) values.updated_at = new Date().toISOString().replace("T", " ").slice(0, 19);
const names = [ext.idColumn, ...Object.keys(values)];
const sets = Object.keys(values).map((n) => `${n} = excluded.${n}`);
// Upsert rather than delete-and-insert: deleting a regions row
// would cascade its region_areas away underneath us. With every
// optional column omitted there is nothing to set, so the
// conflict clause has to degrade to DO NOTHING or the SQL is
// syntactically invalid.
db.prepare(
`INSERT INTO ${ext.table} (${names.join(", ")})
VALUES (${names.map(() => "?").join(", ")})
ON CONFLICT(${ext.idColumn}) ${
sets.length ? `DO UPDATE SET ${sets.join(", ")}` : "DO NOTHING"
}`,
).run(id, ...Object.values(values));
}
}
function isBlankChildRow(child, raw) {
if (!raw || typeof raw !== "object") return true;
for (const column of child.columns) {
const value = raw[column.name];
if (value === undefined || value === null || value === "") continue;
// An unchecked box is the default state of a new row, not input.
if (
column.type === "bool" &&
(value === false || value === 0 || value === "0" || value === "false")
) {
continue;
}
return false;
}
for (const child2 of child.children ?? []) {
const nested = raw[child2.key];
if (Array.isArray(nested) && nested.some((item) => !isBlankChildRow(child2, item))) {
return false;
}
}
return true;
}
function keyedDbErrors(prefix, fn) {
try {
return fn();
} catch (err) {
if (err instanceof HttpError) throw err;
const notNull = /NOT NULL constraint failed: \w+\.(\w+)/.exec(String(err.message ?? ""));
if (notNull) {
throw new HttpError(422, "Validation failed", {
[`${prefix}${notNull[1]}`]: "Required.",
});
}
throw err;
}
}
function writeChildren(db, entity, id, payload, parentValues) {
for (const child of entity.children ?? []) {
if (!applies(child.when, parentValues)) {
deleteChildren(db, child, id);
continue;
}
if (payload[child.key] === undefined) continue; // not sent, not touched
deleteChildren(db, child, id);
const incoming = Array.isArray(payload[child.key]) ? payload[child.key] : [];
// index is the row's place in what the client sent, so a
// validation error still points at the row the user is looking
// at. position counts only the rows that survive, so dropping a
// blank in the middle doesn't leave a gap in sort_order.
const rows = incoming
.map((raw, index) => ({ raw, index }))
.filter(({ raw }) => child.allowBlank || !isBlankChildRow(child, raw));
rows.forEach(({ raw, index }, position) => {
const { values, errors } = coerceRow(child.columns, raw, {
prefix: `${child.key}.${index}.`,
});
if (Object.keys(errors).length) {
throw new HttpError(422, "Validation failed", errors);
}
const names = [child.owner.column, ...Object.keys(values)];
const params = [id, ...Object.values(values)];
if (child.owner.kindColumn) {
names.push(child.owner.kindColumn);
params.push(child.owner.kindValue);
}
for (const [column, from] of Object.entries(child.owner.inherit ?? {})) {
if (names.includes(column)) continue;
names.push(column);
params.push(parentValues[from] ?? id);
}
if (child.order === "sort_order" && !names.includes("sort_order")) {
names.push("sort_order");
params.push(position);
}
const result = keyedDbErrors(`${child.key}.${index}.`, () =>
db
.prepare(
`INSERT INTO ${child.table} (${names.join(", ")})
VALUES (${names.map(() => "?").join(", ")})`,
)
.run(...params),
);
for (const child2 of child.children ?? []) {
const incomingNested = Array.isArray(raw[child2.key]) ? raw[child2.key] : [];
const nested = incomingNested
.map((rawItem, i) => ({ rawItem, i }))
.filter(({ rawItem }) => child2.allowBlank || !isBlankChildRow(child2, rawItem));
nested.forEach(({ rawItem, i }, nestedPosition) => {
const item = coerceRow(child2.columns, rawItem, {
prefix: `${child.key}.${index}.${child2.key}.${i}.`,
});
if (Object.keys(item.errors).length) {
throw new HttpError(422, "Validation failed", item.errors);
}
const itemNames = [child2.owner.column, ...Object.keys(item.values)];
const itemParams = [result.lastInsertRowid, ...Object.values(item.values)];
if (!itemNames.includes("sort_order")) {
itemNames.push("sort_order");
itemParams.push(nestedPosition);
}
db.prepare(
`INSERT INTO ${child2.table} (${itemNames.join(", ")})
VALUES (${itemNames.map(() => "?").join(", ")})`,
).run(...itemParams);
});
}
});
}
}
function deleteChildren(db, child, ownerId) {
if (child.owner.kindColumn) {
db.prepare(
`DELETE FROM ${child.table}
WHERE ${child.owner.column} = ? AND ${child.owner.kindColumn} = ?`,
).run(ownerId, child.owner.kindValue);
} else {
db.prepare(`DELETE FROM ${child.table} WHERE ${child.owner.column} = ?`).run(
ownerId,
);
}
}
/* SQLite's constraint messages are accurate and unreadable. Turn
the ones that users actually cause into something actionable,
and name the column wherever the message carries it — a missing
field is the client's problem to fix, not a 500.
The descriptor and the schema agreeing (see admin-schema-sync.js)
should stop most of these arriving. This is the backstop for the
cases it can't see: partial indexes, triggers, CHECK constraints. */
function wrapDbErrors(fn) {
try {
return fn();
} catch (err) {
if (err instanceof HttpError) throw err;
const message = String(err.message ?? "");
const notNull = /NOT NULL constraint failed: \w+\.(\w+)/.exec(message);
if (notNull) {
throw new HttpError(422, "Validation failed", {
[notNull[1]]: "Required.",
});
}
const unique = /UNIQUE constraint failed: (.+)/.exec(message);
if (unique) {
const columns = unique[1]
.split(",")
.map((part) => part.trim().split(".")[1])
.filter(Boolean);
if (columns.length === 1) {
throw new HttpError(422, "Validation failed", {
[columns[0]]: "Already taken.",
});
}
throw new HttpError(422, "That combination already exists.");
}
if (message.includes("FOREIGN KEY")) {
throw new HttpError(
422,
"Something references a row that doesn't exist, or is still referenced elsewhere.",
);
}
const check = /CHECK constraint failed: (\w+)/.exec(message);
if (check) {
throw new HttpError(422, `A value was rejected by the "${check[1]}" rule.`);
}
throw err;
}
}

View file

@ -0,0 +1,136 @@
/* ═══════════════════════════════════════════════════════════════
DESCRIPTOR / SCHEMA RECONCILIATION
The CRUD engine trusts admin-schema.js about which fields are
required. The database is the one that actually enforces it. When
those two disagree the gap shows up as a 500 on somebody's save,
which is how organizations.country was found.
Run this once at boot. For every table a descriptor touches it
compares the declared columns against PRAGMA table_info and:
- marks a descriptor column required when the table says
NOT NULL with no DEFAULT, so validation catches it as a named
field instead of SQLite catching it as a 500;
- reports a descriptor column the table doesn't have;
- reports a NOT NULL, no-DEFAULT column that no descriptor
column and no engine-supplied column covers — nothing can
ever set it, so every insert through the admin will fail.
The first is a silent repair. The other two are deployment bugs,
so with { strict: true } they stop the service starting rather
than waiting to surface one row at a time.
═══════════════════════════════════════════════════════════════ */
function tableMeta(db, table) {
const rows = db.prepare(`PRAGMA table_info(${table})`).all();
if (!rows.length) return null;
const meta = new Map();
for (const row of rows) {
meta.set(row.name, {
// A NOT NULL column with no default and no engine to fill it
// has to arrive in the payload or the insert dies.
mustSupply: row.notnull === 1 && row.dflt_value === null && row.pk === 0,
notNullable: row.notnull === 1 && row.pk === 0,
});
}
return meta;
}
/* Every table the descriptor writes to, with the columns it
declares and the ones the engine fills in on its own. */
function collectGroups(entity) {
const groups = [
{
table: entity.table,
columns: entity.columns ?? [],
engineSupplied: [entity.idColumn],
},
];
for (const ext of entity.extensions ?? []) {
groups.push({
table: ext.table,
columns: ext.columns ?? [],
engineSupplied: [ext.idColumn, ...(ext.touch ? ["updated_at"] : [])],
});
}
const walk = (child) => {
groups.push({
table: child.table,
columns: child.columns ?? [],
engineSupplied: [
child.owner.column,
child.owner.kindColumn,
"sort_order",
...Object.keys(child.owner.inherit ?? {}),
].filter(Boolean),
});
for (const nested of child.children ?? []) walk(nested);
};
for (const child of entity.children ?? []) walk(child);
return groups;
}
export function syncDescriptorsWithSchema(db, entities, { strict = true } = {}) {
const list = Array.isArray(entities) ? entities.map((e) => [e.name ?? e.table, e]) : Object.entries(entities);
const inferred = [];
const problems = [];
for (const [name, entity] of list) {
for (const group of collectGroups(entity)) {
const meta = tableMeta(db, group.table);
if (!meta) {
problems.push(`${name}: table "${group.table}" does not exist.`);
continue;
}
const declared = new Set(group.columns.map((column) => column.name));
for (const column of group.columns) {
const info = meta.get(column.name);
if (!info) {
problems.push(
`${name}: ${group.table}.${column.name} is in the descriptor but not in the table.`,
);
continue;
}
if (info.mustSupply && !column.required) {
column.required = true;
inferred.push(`${name}: ${group.table}.${column.name}`);
}
if (info.notNullable) column.notNullable = true;
}
for (const [columnName, info] of meta) {
if (!info.mustSupply) continue;
if (declared.has(columnName)) continue;
if (group.engineSupplied.includes(columnName)) continue;
problems.push(
`${name}: ${group.table}.${columnName} is NOT NULL with no default, ` +
`but no descriptor column covers it — every insert will fail.`,
);
}
}
}
if (inferred.length) {
console.warn(
`[admin-schema] marked required from the schema (add required: true to the descriptor):\n ${inferred.join("\n ")}`,
);
}
if (problems.length) {
const report = `[admin-schema] descriptor does not match the database:\n ${problems.join("\n ")}`;
if (strict) throw new Error(report);
console.error(report);
}
return { inferred, problems };
}

518
server/src/admin-schema.js Normal file
View file

@ -0,0 +1,518 @@
/* ═══════════════════════════════════════════════════════════════
ADMIN ENTITY DESCRIPTORS
One object per editable entity. Everything the CRUD handlers do
— validation, SQL, nesting — is read from here, so adding a
table later is a descriptor rather than another set of
hand-written statements to keep in step with the schema.
Anatomy of a descriptor:
columns writable columns of the parent row
extensions 1:1 side tables, optionally gated on a column
value (organizations.kind decides whether a
regions or chapters row should exist)
children ordered collections, replaced wholesale on save
Replacing children wholesale is only safe because nothing has a
foreign key INTO these tables. That is the dividing line, and
it is why teams and awards are entities of their own rather
than repeaters on the organization form: affiliations.team_id
and person_awards.award_id point at them, so a delete-and-
reinsert save would abort the moment either had a single
dependent row.
Parent ids are immutable. Polymorphic children reference their
owner by free-text owner_id, so renaming a slug in place would
silently orphan every link and content block attached to it.
affiliations is edited from both ends — a person's roles, and a
team's members. Each side deletes and reinserts only its own
slice (WHERE person_id = ?, WHERE team_id = ?) and declares
every column of the row, so a save from one side round-trips
what the other side owns rather than blanking it.
═══════════════════════════════════════════════════════════════ */
/* ── Column helpers ──────────────────────────────────────────── */
const text = (name, opts = {}) => ({ name, type: "text", ...opts });
const int = (name, opts = {}) => ({ name, type: "int", ...opts });
const real = (name, opts = {}) => ({ name, type: "real", ...opts });
const bool = (name, opts = {}) => ({ name, type: "bool", ...opts });
const date = (name, opts = {}) => ({ name, type: "date", ...opts });
const enumeration = (name, values, opts = {}) => ({
name,
type: "enum",
values,
...opts,
});
/* Place columns shared by organizations and events, in schema order. */
const placeColumns = [
text("venue"),
text("address"),
text("locality"),
text("state_code"),
text("country"),
text("location_label"),
real("latitude"),
real("longitude"),
bool("is_online"),
];
/* The affiliation's own fields, minus whichever end owns the row.
Both editors write the same shape so neither loses the other's
values on save. */
const affiliationRole = [
text("title"),
enumeration("role", ["lead", "board", "staff", "volunteer", "member"], {
required: true,
}),
bool("is_owner"),
date("started_on"),
date("ended_on"),
bool("is_public"),
];
/* The two polymorphic collections, parameterised by owner_kind. */
const linksChild = (ownerKind) => ({
key: "links",
table: "links",
owner: { column: "owner_id", kindColumn: "owner_kind", kindValue: ownerKind },
order: "sort_order",
columns: [
enumeration("kind", ["action", "social", "website", "email"], {
required: true,
}),
text("platform"),
text("label", { required: true }),
text("url", { required: true }),
bool("is_primary"),
],
});
const blocksChild = (ownerKind) => ({
key: "content_blocks",
table: "content_blocks",
owner: { column: "owner_id", kindColumn: "owner_kind", kindValue: ownerKind },
order: "sort_order",
columns: [
enumeration("slot", ["card", "body"], { required: true }),
enumeration(
"type",
[
"heading",
"subheading",
"paragraph",
"list",
"links",
"quote",
"image",
"divider",
],
{ required: true },
),
text("text"),
text("media"),
text("href"),
],
children: [
{
key: "items",
table: "content_block_items",
owner: { column: "block_id" },
order: "sort_order",
columns: [
text("text", { required: true }),
text("detail"),
text("url"),
],
},
],
});
/* ── Organizations ───────────────────────────────────────────── */
const organizations = {
key: "organizations",
table: "organizations",
idColumn: "id",
idKind: "slug",
concurrency: "updated_at",
list: {
columns: [
"id",
"kind",
"name",
"short_name",
"locality",
"state_code",
"is_published",
"sort_order",
"updated_at",
],
filters: ["kind", "is_published"],
search: ["name", "id", "locality"],
order: "kind, sort_order, name",
},
columns: [
enumeration("kind", ["national", "region", "chapter", "partner"], {
required: true,
}),
text("name", { required: true }),
text("short_name"),
text("tagline"),
text("color"),
text("logo"),
...placeColumns,
bool("is_published"),
int("sort_order"),
],
extensions: [
{
key: "region",
table: "regions",
idColumn: "id",
when: { column: "kind", value: "region" },
columns: [
enumeration("scope", ["domestic", "international", "virtual"], {
required: true,
}),
text("map_note"),
],
},
{
key: "chapter",
table: "chapters",
idColumn: "id",
when: { column: "kind", value: "chapter" },
columns: [text("region_id"), text("meets"), text("started")],
},
],
children: [
linksChild("organization"),
blocksChild("organization"),
{
key: "region_areas",
table: "region_areas",
owner: { column: "region_id" },
when: { column: "kind", value: "region" },
order: "area_code",
columns: [
text("area_code", { required: true }),
real("share"),
enumeration("edge", ["top", "bottom"]),
text("note"),
],
},
],
};
/* ── Events ──────────────────────────────────────────────────── */
const events = {
key: "events",
table: "events",
idColumn: "id",
idKind: "slug",
concurrency: "updated_at",
list: {
columns: [
"id",
"title",
"section_id",
"host_org_id",
"date_label",
"starts_on",
"status",
"is_published",
"sort_order",
"updated_at",
],
filters: ["section_id", "status", "is_published", "host_org_id"],
search: ["title", "id", "theme"],
order: "sort_order, starts_on DESC, title",
},
columns: [
text("section_id", { required: true }),
text("host_org_id"),
text("title", { required: true }),
text("theme"),
text("tagline"),
date("starts_on"),
date("ends_on"),
text("date_label"),
enumeration("status", ["upcoming", "past", "cancelled"]),
...placeColumns,
text("org_logo"),
text("event_logo"),
text("color"),
text("gradient"),
bool("is_published"),
int("sort_order"),
],
children: [
linksChild("event"),
blocksChild("event"),
{
key: "event_people",
table: "event_people",
owner: { column: "event_id" },
order: "sort_order",
columns: [
text("person_id", { required: true }),
enumeration(
"role",
[
"speaker",
"leader",
"facilitator",
"host",
"musician",
"volunteer",
"attendee",
],
{ required: true },
),
text("title"),
bool("is_public"),
],
},
],
};
/* ── People ──────────────────────────────────────────────────── */
const people = {
key: "people",
table: "people",
idColumn: "id",
idKind: "slug",
concurrency: "updated_at",
list: {
columns: [
"id",
"display_name",
"sort_name",
"tagline",
"locality",
"is_published",
"sort_order",
"updated_at",
],
filters: ["is_published"],
search: ["display_name", "sort_name", "id"],
order: "sort_order, sort_name, display_name",
},
columns: [
text("display_name", { required: true }),
text("sort_name"),
text("pronouns"),
text("tagline"),
text("photo"),
text("bio"),
text("primary_org_id"),
text("public_email"),
text("public_phone"),
text("locality"),
text("state_code"),
text("country"),
text("location_label"),
bool("is_published"),
int("sort_order"),
],
extensions: [
{
key: "private",
table: "person_private",
idColumn: "person_id",
touch: true, // has its own updated_at with no trigger behind it
columns: [
date("birth_date"),
text("private_email"),
text("private_phone"),
text("address"),
text("notes"),
],
},
],
children: [
linksChild("person"),
blocksChild("person"),
{
key: "affiliations",
table: "affiliations",
owner: { column: "person_id" },
// sort_order is where this person sits on that team, so it
// belongs to the team's editor. reindex: false stops this
// form renumbering by row position; declaring the column
// keeps the team's value intact across a save here.
reindex: false,
order: "org_id, team_id, sort_order",
columns: [
text("org_id", { required: true }),
text("team_id"),
...affiliationRole,
int("sort_order"),
],
},
{
key: "person_awards",
table: "person_awards",
owner: { column: "person_id" },
order: "awarded_on",
columns: [
text("award_id", { required: true }),
text("event_id"),
date("awarded_on"),
text("citation"),
bool("is_public"),
],
},
],
};
/* ── Teams ───────────────────────────────────────────────────── */
// A team belongs to exactly one organization, and affiliations
// point at the pair (team_id, org_id) rather than the team alone.
// Two consequences the form has to live with:
//
// · org_id cannot be changed once anyone is filed under the
// team. The composite foreign key has no ON UPDATE CASCADE, so
// SQLite aborts the UPDATE. That surfaces as a constraint
// error, which is the correct answer — reassign the members
// first.
//
// · deleting a team with members fails the same way, rather than
// quietly detaching them. Emptying the members list first is
// now something the form can do.
//
// No concurrency column: teams have no updated_at. Adding one
// means rebuilding a STRICT table for a row that one person edits
// at a time, which is not a trade worth making yet.
const teams = {
key: "teams",
table: "teams",
idColumn: "id",
idKind: "slug",
list: {
columns: ["id", "org_id", "name", "tagline", "is_published", "sort_order"],
filters: ["org_id", "is_published"],
search: ["name", "id", "tagline"],
order: "org_id, sort_order, name",
},
columns: [
text("org_id", { required: true }),
text("name", { required: true }),
text("tagline"),
text("color"),
text("logo"),
bool("is_published"),
int("sort_order"),
],
children: [
// 'team' is already a valid owner_kind in both polymorphic
// tables, and teams_cleanup drops the rows on delete, so a team
// page comes free.
linksChild("team"),
blocksChild("team"),
{
key: "members",
table: "affiliations",
// org_id is inherited from the team rather than asked for:
// the composite foreign key (team_id, org_id) means a member
// of this team can only belong to this team's organization,
// so a second dropdown could only ever be wrong.
owner: { column: "team_id", inherit: { org_id: "org_id" } },
// Row position is the order they appear on the public site.
// This is the only editor that writes it.
order: "sort_order",
columns: [text("person_id", { required: true }), ...affiliationRole],
},
],
};
/* ── Awards ──────────────────────────────────────────────────── */
// org_id is who gives the award, added in 004. Nullable, because
// an award can predate any decision about which organization owns
// it, and because person_awards rows must survive the awarding
// org being deleted.
//
// No children: 'award' is not in the owner_kind CHECK on
// content_blocks or links. If awards ever need a page of their
// own, that CHECK is a table rebuild, so decide before adding one
// rather than after.
const awards = {
key: "awards",
table: "awards",
idColumn: "id",
idKind: "slug",
list: {
columns: ["id", "org_id", "name", "description", "sort_order"],
filters: ["org_id"],
search: ["name", "id", "description"],
order: "org_id, sort_order, name",
},
columns: [
text("org_id"),
text("name", { required: true }),
text("description"),
text("logo"),
int("sort_order"),
],
};
export const ENTITIES = { organizations, events, people, teams, awards };
/* ── Options for the form's select inputs ────────────────────── */
export const OPTION_QUERIES = {
organizations:
"SELECT id, name AS label, kind FROM organizations ORDER BY kind, name",
regions:
"SELECT id, name AS label FROM organizations WHERE kind = 'region' ORDER BY name",
event_sections: "SELECT id, name AS label FROM event_sections ORDER BY sort_order",
events: "SELECT id, title AS label FROM events ORDER BY starts_on DESC, title",
people: "SELECT id, display_name AS label FROM people ORDER BY sort_name, display_name",
// org_id rides along so the affiliation row can filter the list
// down to teams of the organization it already names.
teams:
"SELECT id, name AS label, org_id FROM teams ORDER BY org_id, sort_order, name",
// The awarding organization is folded into the label instead,
// because a person can receive an award from any organization —
// there is nothing to filter on, only something to disambiguate
// when two orgs name an award the same thing.
awards: `
SELECT a.id,
CASE WHEN o.name IS NULL THEN a.name
ELSE a.name || ' — ' || o.name END AS label,
a.org_id
FROM awards a
LEFT JOIN organizations o ON o.id = a.org_id
ORDER BY a.sort_order, a.name`,
};

226
server/src/auth.js Normal file
View file

@ -0,0 +1,226 @@
/* ═══════════════════════════════════════════════════════════════
AUTH
Everything that decides who someone is. Routes decide what they
may do.
Password storage is scrypt from node:crypto — no native module,
nothing to compile, nothing for pnpm to get wrong. The stored
string carries its own parameters, so raising the cost later
doesn't invalidate existing hashes.
Sessions are opaque random tokens. The browser holds the token
in an HttpOnly cookie; the database holds only its SHA-256. A
leaked backup therefore contains no usable session, and signing
someone out is a DELETE rather than a wait for expiry.
═══════════════════════════════════════════════════════════════ */
import {
createHash,
randomBytes,
scryptSync,
timingSafeEqual,
} from "node:crypto";
import { getCookie, setCookie, deleteCookie } from "hono/cookie";
export const COOKIE_NAME = "ngu_session";
const SESSION_DAYS = 30;
// Re-issue the expiry when a session is used with less than this
// left, so an active person is never signed out mid-task.
const REFRESH_WITHIN_DAYS = 7;
// Off only for plain-http local work. Production is behind TLS.
const SECURE_COOKIE = (process.env.COOKIE_SECURE ?? "true") !== "false";
const IP_SALT = process.env.IP_SALT ?? randomBytes(16).toString("hex");
/* ── Passwords ─────────────────────────────────────────────────
Format: scrypt$N$r$p$saltHex$keyHex
N=16384 r=8 p=1 is the standard interactive cost, roughly
100ms per hash on this class of hardware.
───────────────────────────────────────────────────────────── */
const SCRYPT = { N: 16384, r: 8, p: 1, keyLen: 64 };
export function hashPassword(password) {
const salt = randomBytes(16);
const key = scryptSync(password.normalize("NFKC"), salt, SCRYPT.keyLen, {
N: SCRYPT.N,
r: SCRYPT.r,
p: SCRYPT.p,
// scrypt's default memory cap is below what N=16384 needs.
maxmem: 256 * 1024 * 1024,
});
return [
"scrypt",
SCRYPT.N,
SCRYPT.r,
SCRYPT.p,
salt.toString("hex"),
key.toString("hex"),
].join("$");
}
export function verifyPassword(password, stored) {
if (typeof stored !== "string") return false;
const [scheme, N, r, p, saltHex, keyHex] = stored.split("$");
if (scheme !== "scrypt") return false;
let candidate;
try {
candidate = scryptSync(
password.normalize("NFKC"),
Buffer.from(saltHex, "hex"),
Buffer.from(keyHex, "hex").length,
{ N: Number(N), r: Number(r), p: Number(p), maxmem: 256 * 1024 * 1024 },
);
} catch {
return false;
}
const expected = Buffer.from(keyHex, "hex");
if (candidate.length !== expected.length) return false;
return timingSafeEqual(candidate, expected);
}
/* ── Hashing helpers ─────────────────────────────────────────── */
const sha256 = (value) => createHash("sha256").update(value).digest("hex");
export function hashIp(ip) {
if (!ip) return null;
return sha256(`${IP_SALT}:${ip}`).slice(0, 32);
}
export function clientIp(c) {
// Trustworthy only because this service binds to 127.0.0.1 and
// nginx is the only thing that can reach it.
return c.req.header("x-forwarded-for")?.split(",")[0].trim() ?? null;
}
/* ── Sessions ────────────────────────────────────────────────── */
export function createSession(db, c, userId) {
const token = randomBytes(32).toString("base64url");
db.prepare(
`INSERT INTO sessions (token_hash, user_id, expires_at, user_agent, ip_hash)
VALUES (?, ?, datetime('now', ?), ?, ?)`,
).run(
sha256(token),
userId,
`+${SESSION_DAYS} days`,
c.req.header("user-agent")?.slice(0, 500) ?? null,
hashIp(clientIp(c)),
);
setCookie(c, COOKIE_NAME, token, {
httpOnly: true,
secure: SECURE_COOKIE,
// Lax, not Strict: Strict would drop the cookie when someone
// follows a link into /admin from elsewhere, which reads as
// being randomly signed out. Lax still blocks the cross-site
// POST that CSRF depends on.
sameSite: "Lax",
path: "/",
maxAge: SESSION_DAYS * 24 * 60 * 60,
});
return token;
}
export function destroySession(db, c) {
const token = getCookie(c, COOKIE_NAME);
if (token) {
db.prepare("DELETE FROM sessions WHERE token_hash = ?").run(sha256(token));
}
deleteCookie(c, COOKIE_NAME, { path: "/", secure: SECURE_COOKIE });
}
export function destroyAllSessionsFor(db, userId) {
db.prepare("DELETE FROM sessions WHERE user_id = ?").run(userId);
}
/* Returns the signed-in user, or null. Also slides the expiry
forward when the session is getting old. */
export function currentUser(db, c) {
const token = getCookie(c, COOKIE_NAME);
if (!token) return null;
const tokenHash = sha256(token);
const row = db
.prepare(
`SELECT s.id AS session_id,
s.expires_at AS expires_at,
u.id, u.email, u.name, u.role
FROM sessions s
JOIN admin_users u ON u.id = s.user_id
WHERE s.token_hash = ?
AND s.expires_at > datetime('now')
AND u.is_active = 1`,
)
.get(tokenHash);
if (!row) return null;
const refreshDue = db
.prepare("SELECT ? < datetime('now', ?) AS due")
.get(row.expires_at, `+${REFRESH_WITHIN_DAYS} days`);
if (refreshDue?.due) {
db.prepare(
`UPDATE sessions
SET expires_at = datetime('now', ?), last_seen_at = datetime('now')
WHERE id = ?`,
).run(`+${SESSION_DAYS} days`, row.session_id);
setCookie(c, COOKIE_NAME, token, {
httpOnly: true,
secure: SECURE_COOKIE,
sameSite: "Lax",
path: "/",
maxAge: SESSION_DAYS * 24 * 60 * 60,
});
}
return { id: row.id, email: row.email, name: row.name, role: row.role };
}
/* ── Middleware ──────────────────────────────────────────────── */
export async function requireAuth(c, next) {
const user = currentUser(c.get("db"), c);
if (!user) return c.json({ error: "Not signed in." }, 401);
c.set("user", user);
await next();
}
export function requireRole(...roles) {
return async (c, next) => {
const user = c.get("user");
if (!user || !roles.includes(user.role)) {
return c.json({ error: "Not allowed." }, 403);
}
await next();
};
}
/* ── Housekeeping ──────────────────────────────────────────────
Expired rows are already ignored by every query; this just
stops the table growing without bound.
───────────────────────────────────────────────────────────── */
export function startSessionSweeper(db, everyMs = 6 * 60 * 60 * 1000) {
const sweep = () => {
try {
db.prepare("DELETE FROM sessions WHERE expires_at <= datetime('now')").run();
} catch (err) {
console.error("session sweep failed", err);
}
};
sweep();
setInterval(sweep, everyMs).unref();
}

View file

@ -15,7 +15,14 @@ import { logger } from "hono/logger";
import { openDatabase, migrate } from "./db.js"; import { openDatabase, migrate } from "./db.js";
import { rateLimit } from "./rateLimit.js"; import { rateLimit } from "./rateLimit.js";
import content from "./routes/content.js"; import content from "./routes/content.js";
import people from "./routes/people.js";
import feedback from "./routes/feedback.js"; import feedback from "./routes/feedback.js";
import auth from "./routes/auth.js";
import admin from "./routes/admin.js";
import adminEntities from "./routes/admin-entities.js";
import { startSessionSweeper } from "./auth.js";
import { syncDescriptorsWithSchema } from "./admin-schema-sync.js";
import { ENTITIES } from "./admin-schema.js";
const HOST = process.env.HOST ?? "127.0.0.1"; const HOST = process.env.HOST ?? "127.0.0.1";
const PORT = Number(process.env.PORT ?? 3001); const PORT = Number(process.env.PORT ?? 3001);
@ -25,6 +32,7 @@ const DB_PATH = process.env.DB_PATH ?? "./ngu.db";
const db = await openDatabase(DB_PATH); const db = await openDatabase(DB_PATH);
const version = migrate(db); const version = migrate(db);
syncDescriptorsWithSchema(db, ENTITIES);syncDescriptorsWithSchema(db, ENTITIES);
console.log(`db ${DB_PATH} (${db.driverName}, schema v${version})`); console.log(`db ${DB_PATH} (${db.driverName}, schema v${version})`);
@ -44,11 +52,19 @@ app.get("/api/health", (c) =>
); );
app.route("/api", content); app.route("/api", content);
app.route("/api", people);
// Tighter limit on the write path than anything else gets. // Tighter limit on the write path than anything else gets.
app.use("/api/feedback", rateLimit({ windowMs: 60_000, max: 5 })); app.use("/api/feedback", rateLimit({ windowMs: 60_000, max: 5 }));
app.route("/api/feedback", feedback); app.route("/api/feedback", feedback);
app.use("/api/auth/login", rateLimit({ windowMs: 15 * 60_000, max: 10 }));
app.route("/api/auth", auth);
app.route("/api/admin", admin);
app.route("/api/admin", adminEntities);
startSessionSweeper(db);
app.notFound((c) => c.json({ error: "Not found" }, 404)); app.notFound((c) => c.json({ error: "Not found" }, 404));
app.onError((err, c) => { app.onError((err, c) => {

View file

@ -0,0 +1,55 @@
-- ═══════════════════════════════════════════════════════════════
-- 003 AUTHENTICATION
--
-- Two tables: who may sign in, and who currently is signed in.
--
-- There is no self-signup and no registration endpoint. Accounts
-- are created from the CLI, on the box, by someone with shell
-- access. For a handful of staff that's the right trade: no
-- invite flow, no email delivery, no password-reset surface for
-- anyone to attack.
-- ═══════════════════════════════════════════════════════════════
CREATE TABLE admin_users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
-- Stored lowercased. The application lowercases on every read
-- and write, so the UNIQUE index is genuinely case-insensitive
-- without depending on a collation.
email TEXT NOT NULL UNIQUE,
name TEXT,
-- Nullable so a Google-only account can exist later with no
-- password at all. A row with both can use either route in.
password_hash TEXT,
-- Google's stable subject id. Nullable, unique when present —
-- SQLite allows any number of NULLs in a unique index.
google_sub TEXT UNIQUE,
role TEXT NOT NULL DEFAULT 'admin'
CHECK (role IN ('admin', 'viewer')),
is_active INTEGER NOT NULL DEFAULT 1 CHECK (is_active IN (0, 1)),
last_login_at TEXT
) STRICT;
-- One row per active login. The cookie holds a random token; this
-- table holds only its SHA-256, so a database leak doesn't hand
-- anyone a working session.
CREATE TABLE sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
token_hash TEXT NOT NULL UNIQUE,
user_id INTEGER NOT NULL REFERENCES admin_users(id) ON DELETE CASCADE,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
last_seen_at TEXT NOT NULL DEFAULT (datetime('now')),
expires_at TEXT NOT NULL,
user_agent TEXT,
ip_hash TEXT
) STRICT;
CREATE INDEX sessions_user_idx ON sessions (user_id);
CREATE INDEX sessions_expiry_idx ON sessions (expires_at);

View file

@ -0,0 +1,7 @@
-- 004_award_org.sql
-- Who gave the award. SET NULL rather than CASCADE: retiring a
-- partner org shouldn't erase an award people have received.
ALTER TABLE awards
ADD COLUMN org_id TEXT REFERENCES organizations (id) ON DELETE SET NULL;
CREATE INDEX awards_org_idx ON awards (org_id, sort_order);

View file

@ -0,0 +1,28 @@
-- ═══════════════════════════════════════════════════════════════
-- Person bio and primary organization
--
-- bio is one run of prose, not orderable mixed content, so it does
-- not belong in content_blocks — whose owner_kind CHECK would need
-- a full table rebuild to accept 'person' anyway. Paragraphs are
-- blank-line separated and split at render time.
--
-- primary_org_id is nullable on purpose: plenty of people have no
-- home organization worth printing, and ON DELETE SET NULL means
-- deleting an org blanks the reference rather than blocking the
-- delete or leaving a dangling id behind.
--
-- Check the current version before renumbering this file:
-- PRAGMA user_version;
-- ═══════════════════════════════════════════════════════════════
ALTER TABLE people ADD COLUMN bio TEXT;
-- SQLite requires an added REFERENCES column to default to NULL,
-- which is what we want regardless.
ALTER TABLE people ADD COLUMN primary_org_id TEXT
REFERENCES organizations (id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS people_primary_org
ON people (primary_org_id);
PRAGMA user_version = 0; -- ← set to this migration's number

View file

@ -0,0 +1,45 @@
-- ═══════════════════════════════════════════════════════════════
-- v_org_leadership: add bio and primary organization
--
-- The view already carries the rules for who counts as current and
-- public. Adding the two columns the people tiles need keeps those
-- rules in one place instead of being restated by each route.
--
-- Additive only — attachLeadership does SELECT * and shapeLeader
-- picks fields by name, so existing callers are unaffected.
--
-- PRAGMA user_version; -- check before renumbering this file
-- ═══════════════════════════════════════════════════════════════
DROP VIEW IF EXISTS v_org_leadership;
CREATE VIEW v_org_leadership AS
SELECT
a.org_id,
a.team_id,
t.name AS team_name,
t.sort_order AS team_sort_order,
a.person_id,
a.title,
a.role,
a.is_owner,
a.sort_order,
p.display_name,
p.sort_name,
p.pronouns,
p.tagline,
p.photo,
p.public_email,
p.location_label,
p.bio,
o.id AS primary_org_id,
o.name AS primary_org_name
FROM affiliations a
JOIN people p ON p.id = a.person_id AND p.is_published = 1
LEFT JOIN teams t ON t.id = a.team_id
LEFT JOIN organizations o ON o.id = p.primary_org_id
WHERE a.is_public = 1
AND a.ended_on IS NULL
ORDER BY a.org_id, a.is_owner DESC, a.sort_order, p.sort_name;
PRAGMA user_version = 0; -- ← set to this migration's number

View file

@ -0,0 +1,111 @@
/* ═══════════════════════════════════════════════════════════════
ADMIN ENTITY ROUTES
GET /api/admin/options
GET /api/admin/:entity list, filtered and searched
GET /api/admin/:entity/:id row with side tables and children
POST /api/admin/:entity create
PATCH /api/admin/:entity/:id replace the row and its children
DELETE /api/admin/:entity/:id
Mounted alongside the existing admin router, which keeps the
feedback routes. Both sit under the same requireAuth.
:entity is matched against the descriptor map, never
interpolated into SQL from the URL — an unknown name is a 404
before anything touches the database.
═══════════════════════════════════════════════════════════════ */
import { Hono } from "hono";
import { requireAuth, requireRole } from "../auth.js";
import { ENTITIES, OPTION_QUERIES } from "../admin-schema.js";
import {
HttpError,
listRows,
readRow,
createRow,
updateRow,
deleteRow,
} from "../admin-crud.js";
const entities = new Hono();
entities.use("*", requireAuth);
const NO_STORE = { "Cache-Control": "no-store" };
function entityOr404(c) {
const entity = ENTITIES[c.req.param("entity")];
if (!entity) throw new HttpError(404, "Unknown entity.");
return entity;
}
/* Everything the select inputs need, in one request. Small enough
that paging it would cost more than it saves. */
entities.get("/options", (c) => {
const db = c.get("db");
const options = {};
for (const [key, sql] of Object.entries(OPTION_QUERIES)) {
options[key] = db.prepare(sql).all();
}
return c.json({ options }, 200, NO_STORE);
});
entities.get("/:entity", (c) => {
const entity = entityOr404(c);
const { rows, total } = listRows(c.get("db"), entity, c.req.query());
return c.json({ rows, total }, 200, NO_STORE);
});
entities.get("/:entity/:id", (c) => {
const entity = entityOr404(c);
return c.json({ row: readRow(c.get("db"), entity, c.req.param("id")) }, 200, NO_STORE);
});
entities.post("/:entity", requireRole("admin"), async (c) => {
const entity = entityOr404(c);
const row = createRow(c.get("db"), entity, await json(c));
console.log(`${entity.key} ${row[entity.idColumn]} created by ${c.get("user").email}`);
return c.json({ row }, 201, NO_STORE);
});
entities.patch("/:entity/:id", requireRole("admin"), async (c) => {
const entity = entityOr404(c);
const id = c.req.param("id");
const row = updateRow(c.get("db"), entity, id, await json(c));
console.log(`${entity.key} ${id} updated by ${c.get("user").email}`);
return c.json({ row }, 200, NO_STORE);
});
entities.delete("/:entity/:id", requireRole("admin"), (c) => {
const entity = entityOr404(c);
const id = c.req.param("id");
deleteRow(c.get("db"), entity, id);
console.log(`${entity.key} ${id} deleted by ${c.get("user").email}`);
return c.body(null, 204);
});
async function json(c) {
try {
return await c.req.json();
} catch {
throw new HttpError(400, "Expected a JSON body.");
}
}
/* Turn HttpError into a response here rather than letting the
app-level handler flatten everything to a 500. */
entities.onError((err, c) => {
if (err instanceof HttpError) {
return c.json(
err.fields ? { error: err.message, fields: err.fields } : { error: err.message },
err.status,
NO_STORE,
);
}
console.error(err);
return c.json({ error: "Something went wrong." }, 500);
});
export default entities;

172
server/src/routes/admin.js Normal file
View file

@ -0,0 +1,172 @@
/* ═══════════════════════════════════════════════════════════════
ADMIN ROUTES
Everything under /api/admin requires a session. Writes require
the 'admin' role; a 'viewer' can read and nothing else.
No response from here is cacheable, and none of it should ever
sit in a proxy.
═══════════════════════════════════════════════════════════════ */
import { Hono } from "hono";
import { requireAuth, requireRole } from "../auth.js";
const admin = new Hono();
admin.use("*", requireAuth);
const NO_STORE = { "Cache-Control": "no-store" };
const STATUSES = ["new", "read", "actioned", "archived", "spam"];
const MAX_LIMIT = 200;
const NOTE_LIMIT = 2000;
/* ── GET /api/admin/feedback ─────────────────────────────────────
?status=new one of STATUSES, or omitted for all
?type=broken feedback_type
?q=retreat substring of the message
?before=41 cursor: rows with a lower id than this
?limit=50
───────────────────────────────────────────────────────────── */
admin.get("/feedback", (c) => {
const db = c.get("db");
const { status, type, q, before, limit } = c.req.query();
const where = [];
const params = [];
if (STATUSES.includes(status)) {
where.push("status = ?");
params.push(status);
}
if (type) {
where.push("feedback_type = ?");
params.push(type.slice(0, 40));
}
if (q) {
where.push("message LIKE ?");
params.push(`%${q.slice(0, 100)}%`);
}
if (before && Number.isInteger(Number(before))) {
where.push("id < ?");
params.push(Number(before));
}
const take = Math.min(Number(limit) || 50, MAX_LIMIT);
const rows = db
.prepare(
`SELECT id, created_at, feedback_type, message, name, email,
page_path, section_id, status, admin_note
FROM feedback
${where.length ? `WHERE ${where.join(" AND ")}` : ""}
ORDER BY id DESC
LIMIT ?`,
)
.all(...params, take + 1); // one extra to detect a next page
const hasMore = rows.length > take;
const page = hasMore ? rows.slice(0, take) : rows;
// Counts are unfiltered on purpose: the tabs should show what's
// waiting overall, not what's left after the current filter.
const counts = Object.fromEntries(STATUSES.map((s) => [s, 0]));
for (const row of db
.prepare("SELECT status, COUNT(*) AS n FROM feedback GROUP BY status")
.all()) {
counts[row.status] = row.n;
}
return c.json(
{
feedback: page,
counts,
nextCursor: hasMore ? page[page.length - 1].id : null,
},
200,
NO_STORE,
);
});
/* ── PATCH /api/admin/feedback/:id ───────────────────────────────
{ status?, admin_note? } — either, both, partial.
───────────────────────────────────────────────────────────── */
admin.patch("/feedback/:id", requireRole("admin"), async (c) => {
const id = Number(c.req.param("id"));
if (!Number.isInteger(id)) return c.json({ error: "Bad id." }, 400);
let body;
try {
body = await c.req.json();
} catch {
return c.json({ error: "Expected a JSON body." }, 400);
}
const sets = [];
const params = [];
if (body.status !== undefined) {
if (!STATUSES.includes(body.status)) {
return c.json({ error: "Unknown status." }, 422);
}
sets.push("status = ?");
params.push(body.status);
}
if (body.admin_note !== undefined) {
const note = String(body.admin_note).trim().slice(0, NOTE_LIMIT);
sets.push("admin_note = ?");
params.push(note || null);
}
if (sets.length === 0) return c.json({ error: "Nothing to change." }, 400);
const result = db_update(c.get("db"), id, sets, params);
if (!result) return c.json({ error: "No such feedback." }, 404);
console.log(`feedback #${id} updated by ${c.get("user").email}`);
return c.json({ feedback: result }, 200, NO_STORE);
});
function db_update(db, id, sets, params) {
const changed = db
.prepare(`UPDATE feedback SET ${sets.join(", ")} WHERE id = ?`)
.run(...params, id);
if (changed.changes === 0) return null;
return db
.prepare(
`SELECT id, created_at, feedback_type, message, name, email,
page_path, section_id, status, admin_note
FROM feedback WHERE id = ?`,
)
.get(id);
}
/* ── DELETE /api/admin/feedback/:id ──────────────────────────────
Actually gone. Marking something 'spam' is the reversible
option and should be the habit; this is for the cases where
the content itself shouldn't stay on disk.
───────────────────────────────────────────────────────────── */
admin.delete("/feedback/:id", requireRole("admin"), (c) => {
const id = Number(c.req.param("id"));
if (!Number.isInteger(id)) return c.json({ error: "Bad id." }, 400);
const result = c
.get("db")
.prepare("DELETE FROM feedback WHERE id = ?")
.run(id);
if (result.changes === 0) return c.json({ error: "No such feedback." }, 404);
console.log(`feedback #${id} deleted by ${c.get("user").email}`);
return c.body(null, 204);
});
export default admin;

99
server/src/routes/auth.js Normal file
View file

@ -0,0 +1,99 @@
/* ═══════════════════════════════════════════════════════════════
AUTH ROUTES
POST /api/auth/login email + password → session cookie
POST /api/auth/logout deletes the session row and cookie
GET /api/auth/me who am I, or 401
Three deliberate choices:
Login is rate limited where it's mounted, and answers wrong
password and unknown account identically. Telling an attacker
which addresses exist is free reconnaissance.
A failed login still runs a hash. Otherwise the response time
itself says whether the account exists.
Nothing here creates accounts. See admin-cli.js.
═══════════════════════════════════════════════════════════════ */
import { Hono } from "hono";
import {
createSession,
destroySession,
currentUser,
hashPassword,
verifyPassword,
} from "../auth.js";
const auth = new Hono();
// A real hash to compare against when the account doesn't exist,
// so both paths cost the same. Computed once at import.
const DUMMY_HASH = hashPassword("this password is never correct");
const NO_STORE = { "Cache-Control": "no-store" };
auth.post("/login", async (c) => {
let body;
try {
body = await c.req.json();
} catch {
return c.json({ error: "Expected a JSON body." }, 400);
}
const email = String(body.email ?? "").trim().toLowerCase().slice(0, 254);
const password = String(body.password ?? "");
if (!email || !password) {
return c.json({ error: "Email and password are required." }, 400);
}
const db = c.get("db");
const user = db
.prepare(
`SELECT id, email, name, role, password_hash, is_active
FROM admin_users
WHERE email = ?`,
)
.get(email);
const ok =
user && user.is_active === 1
? verifyPassword(password, user.password_hash)
: (verifyPassword(password, DUMMY_HASH), false);
if (!ok) {
console.warn(`login failed for ${email}`);
return c.json({ error: "That email and password don't match." }, 401);
}
createSession(db, c, user.id);
db.prepare("UPDATE admin_users SET last_login_at = datetime('now') WHERE id = ?").run(
user.id,
);
console.log(`login ${user.email}`);
return c.json(
{ user: { id: user.id, email: user.email, name: user.name, role: user.role } },
200,
NO_STORE,
);
});
auth.post("/logout", (c) => {
destroySession(c.get("db"), c);
return c.body(null, 204);
});
auth.get("/me", (c) => {
const user = currentUser(c.get("db"), c);
if (!user) return c.json({ error: "Not signed in." }, 401, NO_STORE);
return c.json({ user }, 200, NO_STORE);
});
export default auth;

141
server/src/routes/people.js Normal file
View file

@ -0,0 +1,141 @@
/* ═══════════════════════════════════════════════════════════════
PEOPLE ROUTES — read-only, mounted under /api
GET /teams/:id/people current public members of a team
GET /people?ids=a,b,c named people, any order
The team route reads v_org_leadership, which already decides who
counts as current and public — affiliation still open, marked
public, person published. Restating those conditions here is how
they drift apart.
That view is affiliation-driven, so it can't serve the lookup
route: a person with no affiliations would vanish, and one with
several would appear more than once. The lookup reads people
directly and returns no title, because a bare slug names no seat.
Photos are filenames, as everywhere else in this API. The
component decides where they live.
═══════════════════════════════════════════════════════════════ */
import { Hono } from "hono";
import { asBool } from "../shape.js";
const people = new Hono();
const CACHE = "public, max-age=60, stale-while-revalidate=300";
const json = (c, body) => c.json(body, 200, { "Cache-Control": CACHE });
const marks = (n) => Array(n).fill("?").join(",");
/* How many slugs one request may name. Keeps the URL sane. */
const MAX_IDS = 50;
function shapePerson(row) {
return {
id: row.id ?? row.person_id,
name: row.display_name,
// Only the team route knows a seat; the lookup route doesn't.
title: row.title ?? null,
tagline: row.tagline,
pronouns: row.pronouns,
photo: row.photo,
location_label: row.location_label,
public_email: row.public_email,
org: row.primary_org_id
? { id: row.primary_org_id, name: row.primary_org_name }
: null,
bio: splitParagraphs(row.bio),
role: row.role ?? null,
is_owner: row.is_owner === undefined ? false : asBool(row.is_owner),
};
}
/* people.bio is one run of prose with blank lines between
paragraphs — unrelated to shape.js's paragraphs(), which turns
content_block rows into text. */
function splitParagraphs(text) {
if (!text) return null;
const parts = text
.split(/\n\s*\n/)
.map((part) => part.trim())
.filter(Boolean);
return parts.length ? parts : null;
}
/* ── One team's people ─────────────────────────────────────── */
people.get("/teams/:id/people", (c) => {
const db = c.get("db");
const id = c.req.param("id");
const team = db
.prepare(
`SELECT id, org_id, name, tagline, color, logo
FROM teams
WHERE id = ? AND is_published = 1`,
)
.get(id);
if (!team) return c.json({ error: "No such team" }, 404);
// The view orders by org first, which isn't what a single team
// wants, so the order is restated here.
const rows = db
.prepare(
`SELECT * FROM v_org_leadership
WHERE team_id = ?
ORDER BY is_owner DESC,
sort_order,
COALESCE(sort_name, display_name),
display_name`,
)
.all(id);
return json(c, { team, people: rows.map(shapePerson) });
});
/* ── People by id ──────────────────────────────────────────────
For hand-picked lists in a section. Order is the caller's, so
there's no ORDER BY here.
───────────────────────────────────────────────────────────── */
people.get("/people", (c) => {
const db = c.get("db");
const ids = [
...new Set(
(c.req.query("ids") ?? "")
.split(",")
.map((part) => part.trim())
.filter(Boolean),
),
].slice(0, MAX_IDS);
if (ids.length === 0) return json(c, { people: [] });
const rows = db
.prepare(
`SELECT p.id,
p.display_name,
p.pronouns,
p.photo,
p.tagline,
p.location_label,
p.public_email,
p.bio,
o.id AS primary_org_id,
o.name AS primary_org_name
FROM people p
LEFT JOIN organizations o ON o.id = p.primary_org_id
WHERE p.id IN (${marks(ids.length)})
AND p.is_published = 1`,
)
.all(...ids);
return json(c, { people: rows.map(shapePerson) });
});
export default people;

View file

@ -1,17 +1,31 @@
import { BrowserRouter, Routes, Route } from "react-router-dom"; import { BrowserRouter, Routes, Route, Outlet, Navigate } from "react-router-dom";
/*Components*/
import Layout from "./components/Layout.tsx"; import Layout from "./components/Layout.tsx";
/*Libraries*/
import { AuthProvider, RequireAuth } from "./lib/auth.tsx";
/*Primary Pages*/
import Home from "./pages/Home.tsx"; import Home from "./pages/Home.tsx";
import Retreats from "./pages/Retreats.tsx"; import Retreats from "./pages/Retreats.tsx";
import Community from "./pages/Community.tsx"; import Community from "./pages/Community.tsx";
import Leadership from "./pages/Leadership.tsx"; import Leadership from "./pages/Leadership.tsx";
import Resources from "./pages/Resources.tsx"; import Resources from "./pages/Resources.tsx";
/*Secondary Pages*/
import Feedback from "./pages/Feedback.tsx"; import Feedback from "./pages/Feedback.tsx";
import Giving from "./pages/Giving.tsx"; import Giving from "./pages/Giving.tsx";
/*Admin Pages*/
import AdminLayout from "./pages/admin/AdminLayout.tsx";
import AdminLogin from "./pages/admin/AdminLogin.tsx";
import AdminFeedback from "./pages/admin/AdminFeedback.tsx";
import EntityList from "./pages/admin/EntityList.tsx";
import EntityEdit from "./pages/admin/EntityEdit.tsx";
/*Ternary Pages*/
import Privacy from "./pages/Privacy.tsx"; import Privacy from "./pages/Privacy.tsx";
import Terms from "./pages/Terms.tsx"; import Terms from "./pages/Terms.tsx";
import NotFound from "./pages/NotFound.tsx"; import NotFound from "./pages/NotFound.tsx";
export default function App() { export default function App() {
@ -31,6 +45,17 @@ export default function App() {
<Route path="terms" element={<Terms />} /> <Route path="terms" element={<Terms />} />
<Route path="*" element={<NotFound />} /> <Route path="*" element={<NotFound />} />
</Route> </Route>
<Route element={<AuthProvider><Outlet /></AuthProvider>}>
<Route path="/admin/login" element={<AdminLogin />} />
<Route element={<RequireAuth />}>
<Route path="/admin" element={<AdminLayout />}>
<Route index element={<Navigate to="/admin/feedback" replace />} />
<Route path="feedback" element={<AdminFeedback />} />
<Route path=":entity" element={<EntityList />} />
<Route path=":entity/:id" element={<EntityEdit />} />
</Route>
</Route>
</Route>
</Routes> </Routes>
</BrowserRouter> </BrowserRouter>
); );

View file

@ -1,48 +1,370 @@
import { useEffect, useId, useMemo, useRef, useState } from "react"; import {
useEffect,
useId,
useMemo,
useRef,
useState,
type CSSProperties,
type HTMLAttributes,
} from "react";
import { get } from "../lib/api.js";
import "./PeopleTiles.css"; import "./PeopleTiles.css";
/** /**
* PeopleTiles — a horizontal, polaroid-style people list. * PeopleTiles — a horizontal, polaroid-style people list.
* *
* Drop into any section: * Supply data any of four ways:
* <PeopleTiles size="md" groups={staffGroups} /> *
* <PeopleTiles size="sm" people={volunteers} /> * <PeopleTiles size="lg" teams="ngu-board" />
* <PeopleTiles size="md" teams={["ngu-board", "nw-leadership"]} />
* <PeopleTiles size="sm" people={[
* { peopleslug: "john-doe", title: "Hospitality" },
* { peopleslug: "jane-doe" },
* ]} />
* <PeopleTiles size="sm" people={volunteers} overflow="scroll" />
*
* With `teams`, each team is fetched in the order given and split
* into a lead group and a members group by the affiliation's
* is_owner flag.
*
* With `peopleslug`, the person is fetched by id and any other key
* on the entry overrides what came back — so a title can be given
* per placement, and is blank when it isn't. A slug entry can sit
* beside a fully-written person in the same array.
* *
* Sizes * Sizes
* sm photo + name * sm photo + name
* md photo + name + title * md photo + name + title
* lg photo + name + title + expandable bio (pronouns, age, primary org above the bio) * lg photo + name + title, and a chevron on anyone who has
* something to expand: pronouns, home organization, location,
* public email or a bio. Not bio alone — the details panel is
* worth opening before anyone has written prose.
* *
* Group shape * Field names follow the API (is_owner, location_label), so a row
* { id, label?, note?, accent?, people: [person] } * from /api/teams/:id/people drops in unchanged.
*
* Person shape
* {
* id, name,
* title?, // "Regional Director"
* photo?, // "/people/jane-doe.jpg" — falls back to initials
* pronouns?, // "she/her"
* age?, // number, or use birthdate
* birthdate?, // "1998-04-12" — age is derived when `age` is absent
* org?, // "Grace Chapel" or { name, href }
* bio?, // string or string[] (paragraphs)
* accent?, // per-person override
* }
*/ */
const SIZE_FEATURES = { export interface Person {
sm: { title: false, bio: false }, id?: string | number;
md: { title: true, bio: false }, name: string;
lg: { title: true, bio: true }, title?: string | null;
tagline?: string | null;
photo?: string | null;
pronouns?: string | null;
location_label?: string | null;
public_email?: string | null;
org?: string | { id?: string; name: string; href?: string } | null;
bio?: string | string[] | null;
accent?: string;
role?: string | null;
is_owner?: boolean;
/** Accepted as an alias so hand-written entries can use either. */
isOwner?: boolean;
}
/** A person named by slug. Any other field overrides the record. */
export interface PersonRef extends Partial<Omit<Person, "name">> {
peopleslug: string;
name?: string;
}
export type PersonInput = Person | PersonRef;
export interface PeopleGroup {
id?: string;
label?: string;
note?: string;
accent?: string;
people: Person[];
}
export interface PeopleGroupInput extends Omit<PeopleGroup, "people"> {
people: PersonInput[];
}
export interface TeamSpec {
id: string;
/** Heading for the members group. Defaults to the team's name. */
label?: string;
/** Heading for the lead group. Defaults to the lead's own title. */
leadLabel?: string;
/** Set false to keep owners inline with everyone else. */
splitOwners?: boolean;
accent?: string;
}
export type TeamSource = string | TeamSpec;
export type PeopleTilesSize = "sm" | "md" | "lg";
export interface PeopleTilesProps
extends Omit<HTMLAttributes<HTMLDivElement>, "onSelect"> {
people?: PersonInput[];
groups?: PeopleGroupInput[];
teams?: TeamSource | TeamSource[];
size?: PeopleTilesSize;
scale?: number;
overflow?: "wrap" | "scroll";
align?: "start" | "center";
accent?: string;
tilt?: boolean;
/** How long a fetched team or person is reused, in ms. */
ttl?: number;
emptyMessage?: string;
loadingMessage?: string;
errorMessage?: string;
onExpand?: (person: Person | null, group: PeopleGroup | null) => void;
}
const SIZE_FEATURES: Record<PeopleTilesSize, { title: boolean; details: boolean }> = {
sm: { title: false, details: false },
md: { title: true, details: false },
lg: { title: true, details: true },
}; };
/* ── Data ──────────────────────────────────────────────────────
Fetching lives here rather than in every section, but the view
below stays pure — it only ever sees resolved groups, whatever
produced them.
───────────────────────────────────────────────────────────── */
export default function PeopleTiles({ export default function PeopleTiles({
teams,
groups,
people,
ttl = 5 * 60_000,
loadingMessage = "Loading…",
errorMessage = "Couldn't load this list right now.",
...view
}: PeopleTilesProps) {
const specs = useMemo(() => normalizeTeams(teams), [teams]);
const teamKey = specs.map((spec) => spec.id).join(",");
// Sorted so two sections naming the same people in a different
// order still hit the same cached request.
const slugs = useMemo(
() => (specs.length ? [] : collectSlugs(groups, people)),
[specs.length, groups, people],
);
const slugKey = slugs.join(",");
const [teamGroups, setTeamGroups] = useState<PeopleGroup[] | null>(null);
const [directory, setDirectory] = useState<Record<string, Person> | null>(null);
const [failed, setFailed] = useState(false);
useEffect(() => {
if (!specs.length) {
setTeamGroups(null);
return undefined;
}
let live = true;
setFailed(false);
Promise.all(
specs.map((spec) =>
get(`/teams/${spec.id}/people`, { ttl }).then((data: TeamResponse) => ({
spec,
data,
})),
),
)
.then((results) => {
if (!live) return;
// Order follows the order the teams were supplied in, not
// whichever request came back first.
setTeamGroups(
results.flatMap(({ spec, data }) => buildTeamGroups(spec, data, specs.length)),
);
})
.catch((err) => {
if (!live) return;
console.error("PeopleTiles: couldn't load teams", teamKey, err);
setFailed(true);
});
return () => {
live = false;
};
}, [teamKey, ttl, specs]);
useEffect(() => {
if (!slugKey) {
setDirectory(null);
return undefined;
}
let live = true;
setFailed(false);
get(`/people?ids=${encodeURIComponent(slugKey)}`, { ttl })
.then((data: { people: Person[] }) => {
if (!live) return;
const byId: Record<string, Person> = {};
for (const person of data.people) byId[String(person.id)] = person;
setDirectory(byId);
})
.catch((err) => {
if (!live) return;
console.error("PeopleTiles: couldn't load people", slugKey, err);
setFailed(true);
});
return () => {
live = false;
};
}, [slugKey, ttl]);
if (specs.length) {
if (failed) return <p className="pl__empty">{errorMessage}</p>;
if (!teamGroups) return <p className="pl__empty">{loadingMessage}</p>;
return <PeopleTilesView {...view} groups={teamGroups} />;
}
if (slugKey) {
if (failed) return <p className="pl__empty">{errorMessage}</p>;
if (!directory) return <p className="pl__empty">{loadingMessage}</p>;
return (
<PeopleTilesView
{...view}
groups={groups?.map((group) => ({
...group,
people: resolveAll(group.people, directory),
}))}
people={people ? resolveAll(people, directory) : undefined}
/>
);
}
return (
<PeopleTilesView
{...view}
groups={groups as PeopleGroup[] | undefined}
people={people as Person[] | undefined}
/>
);
}
interface TeamResponse {
team: { id: string; name: string; color?: string | null };
people: Person[];
}
function normalizeTeams(teams: PeopleTilesProps["teams"]): TeamSpec[] {
if (!teams) return [];
const list = Array.isArray(teams) ? teams : [teams];
return list
.map((entry) => (typeof entry === "string" ? { id: entry } : entry))
.filter((spec): spec is TeamSpec => Boolean(spec?.id));
}
function isRef(entry: PersonInput): entry is PersonRef {
return typeof (entry as PersonRef).peopleslug === "string";
}
function collectSlugs(
groups?: PeopleGroupInput[],
people?: PersonInput[],
): string[] {
const found = new Set<string>();
const scan = (list?: PersonInput[]) => {
for (const entry of list ?? []) {
if (entry && isRef(entry)) found.add(entry.peopleslug);
}
};
scan(people);
for (const group of groups ?? []) scan(group.people);
return [...found].sort();
}
/* The fetched record is the base; anything else on the entry wins,
including an explicit null — that's how a title is deliberately
left blank rather than inherited. */
function resolveAll(
list: PersonInput[],
directory: Record<string, Person>,
): Person[] {
const resolved: Person[] = [];
for (const entry of list) {
if (!entry) continue;
if (!isRef(entry)) {
resolved.push(entry);
continue;
}
const base = directory[entry.peopleslug];
if (!base) {
// Unpublished, deleted, or a typo in the slug. Leaving the
// tile out beats rendering a nameless placeholder.
console.warn(`PeopleTiles: no published person "${entry.peopleslug}"`);
continue;
}
const { peopleslug, ...overrides } = entry;
const merged: Person = { ...base };
for (const [key, value] of Object.entries(overrides)) {
if (value !== undefined) (merged as Record<string, unknown>)[key] = value;
}
resolved.push(merged);
}
return resolved;
}
function buildTeamGroups(
spec: TeamSpec,
data: TeamResponse,
teamCount: number,
): PeopleGroup[] {
const accent = spec.accent ?? data.team.color ?? undefined;
const name = spec.label ?? data.team.name;
const split = spec.splitOwners !== false;
const owners = split ? data.people.filter(owns) : [];
const rest = split ? data.people.filter((person) => !owns(person)) : data.people;
// A lead only reads as a lead when there's a body of people to
// stand apart from. All owners, or none, is just a list.
if (!owners.length || !rest.length) {
return [
{
id: data.team.id,
// One unlabelled team needs no heading; several always do.
label: teamCount > 1 || spec.label ? name : undefined,
accent,
people: data.people,
},
];
}
return [
{
id: `${data.team.id}-lead`,
label: spec.leadLabel ?? leadLabel(owners, name),
accent,
people: owners,
},
{ id: data.team.id, label: name, accent, people: rest },
];
}
function leadLabel(owners: Person[], teamName: string): string {
if (owners.length === 1 && titleOf(owners[0])) return titleOf(owners[0]) as string;
return `${teamName} lead${owners.length > 1 ? "s" : ""}`;
}
/* ── View ────────────────────────────────────────────────────── */
export function PeopleTilesView({
people, people,
groups, groups,
size = "md", size = "md",
scale = 1, scale = 1,
overflow = "wrap", // "wrap" | "scroll" overflow = "wrap",
align = "start", // "start" | "center" align = "start",
accent, accent,
tilt = false, tilt = false,
emptyMessage = "No one listed yet.", emptyMessage = "No one listed yet.",
@ -50,18 +372,24 @@ export default function PeopleTiles({
className = "", className = "",
style, style,
...rest ...rest
}: Omit<
PeopleTilesProps,
"teams" | "ttl" | "loadingMessage" | "errorMessage" | "people" | "groups"
> & {
people?: Person[];
groups?: PeopleGroup[];
}) { }) {
const baseId = useId().replace(/:/g, ""); const baseId = useId().replace(/:/g, "");
const [openKey, setOpenKey] = useState(null); const [openKey, setOpenKey] = useState<string | null>(null);
const rootRef = useRef(null); const rootRef = useRef<HTMLDivElement>(null);
const resolvedSize = SIZE_FEATURES[size] ? size : "md"; const resolvedSize: PeopleTilesSize = SIZE_FEATURES[size] ? size : "md";
const features = SIZE_FEATURES[resolvedSize]; const features = SIZE_FEATURES[resolvedSize];
const resolvedGroups = useMemo(() => { const resolvedGroups = useMemo<PeopleGroup[]>(() => {
const source = Array.isArray(groups) && groups.length const source = groups?.length
? groups ? groups
: Array.isArray(people) && people.length : people?.length
? [{ id: "all", people }] ? [{ id: "all", people }]
: []; : [];
@ -74,7 +402,7 @@ export default function PeopleTiles({
.filter((group) => group.people.length > 0); .filter((group) => group.people.length > 0);
}, [groups, people]); }, [groups, people]);
// Close the bio if the person it belongs to disappears from the data. // Close the panel if the person it belongs to disappears.
useEffect(() => { useEffect(() => {
if (!openKey) return; if (!openKey) return;
const stillThere = resolvedGroups.some((group) => const stillThere = resolvedGroups.some((group) =>
@ -87,21 +415,23 @@ export default function PeopleTiles({
return emptyMessage ? <p className="pl__empty">{emptyMessage}</p> : null; return emptyMessage ? <p className="pl__empty">{emptyMessage}</p> : null;
} }
const open = features.bio ? findByKey(resolvedGroups, openKey) : null; const open = features.details ? findByKey(resolvedGroups, openKey) : null;
function toggle(group, person, index) { function toggle(group: PeopleGroup, person: Person, index: number) {
const key = keyFor(group, person, index); const key = keyFor(group, person, index);
const next = openKey === key ? null : key; const next = openKey === key ? null : key;
setOpenKey(next); setOpenKey(next);
if (onExpand) onExpand(next ? person : null, next ? group : null); onExpand?.(next ? person : null, next ? group : null);
} }
function handleKeyDown(event) { function handleKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
if (event.key === "Escape" && openKey) { if (event.key === "Escape" && openKey) {
event.stopPropagation(); event.stopPropagation();
setOpenKey(null); setOpenKey(null);
const button = rootRef.current?.querySelector('.pl__tile[aria-expanded="true"]'); const button = rootRef.current?.querySelector<HTMLButtonElement>(
if (button) button.focus(); '.pl__tile[aria-expanded="true"]',
);
button?.focus();
} }
} }
@ -113,11 +443,13 @@ export default function PeopleTiles({
data-overflow={overflow} data-overflow={overflow}
data-align={align} data-align={align}
data-tilt={tilt ? "on" : "off"} data-tilt={tilt ? "on" : "off"}
style={{ style={
{
...(scale !== 1 ? { "--pl-scale": scale } : null), ...(scale !== 1 ? { "--pl-scale": scale } : null),
...(accent ? { "--pl-accent": accent } : null), ...(accent ? { "--pl-accent": accent } : null),
...style, ...style,
}} } as CSSProperties
}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
{...rest} {...rest}
> >
@ -126,7 +458,9 @@ export default function PeopleTiles({
<section <section
key={group.id} key={group.id}
className="pl__group" className="pl__group"
style={group.accent ? { "--pl-accent": group.accent } : undefined} style={
group.accent ? ({ "--pl-accent": group.accent } as CSSProperties) : undefined
}
aria-label={group.label || undefined} aria-label={group.label || undefined}
> >
{(group.label || group.note) && ( {(group.label || group.note) && (
@ -139,14 +473,18 @@ export default function PeopleTiles({
<ul className="pl__row"> <ul className="pl__row">
{group.people.map((person, index) => { {group.people.map((person, index) => {
const key = keyFor(group, person, index); const key = keyFor(group, person, index);
const expandable = features.bio && hasBio(person); const expandable = features.details && hasDetails(person);
const isOpen = expandable && openKey === key; const isOpen = expandable && openKey === key;
return ( return (
<li <li
key={key} key={key}
className="pl__item" className="pl__item"
style={person.accent ? { "--pl-accent": person.accent } : undefined} style={
person.accent
? ({ "--pl-accent": person.accent } as CSSProperties)
: undefined
}
> >
<Tile <Tile
person={person} person={person}
@ -165,13 +503,13 @@ export default function PeopleTiles({
</div> </div>
{open && ( {open && (
<BioPanel <DetailPanel
id={`${baseId}-bio`} id={`${baseId}-bio`}
person={open.person} person={open.person}
group={open.group} group={open.group}
onClose={() => { onClose={() => {
setOpenKey(null); setOpenKey(null);
if (onExpand) onExpand(null, null); onExpand?.(null, null);
}} }}
/> />
)} )}
@ -179,16 +517,31 @@ export default function PeopleTiles({
); );
} }
function Tile({ person, showTitle, expandable, isOpen, panelId, onToggle }) { function Tile({
person,
showTitle,
expandable,
isOpen,
panelId,
onToggle,
}: {
person: Person;
showTitle: boolean;
expandable: boolean;
isOpen: boolean;
panelId: string;
onToggle: () => void;
}) {
const title = titleOf(person);
const content = ( const content = (
<>
<span className="pl__frame"> <span className="pl__frame">
<span className="pl__photo"> <span className="pl__photo">
<Photo src={person.photo} name={person.name} /> <Photo src={photoSrc(person.photo)} name={person.name} />
</span> </span>
<span className="pl__caption"> <span className="pl__caption">
<span className="pl__name">{person.name}</span> <span className="pl__name">{person.name}</span>
{showTitle && person.title && <span className="pl__title">{person.title}</span>} {showTitle && title && <span className="pl__title">{title}</span>}
</span> </span>
{expandable && ( {expandable && (
<span className="pl__badge" aria-hidden="true"> <span className="pl__badge" aria-hidden="true">
@ -196,7 +549,6 @@ function Tile({ person, showTitle, expandable, isOpen, panelId, onToggle }) {
</span> </span>
)} )}
</span> </span>
</>
); );
if (!expandable) { if (!expandable) {
@ -212,12 +564,12 @@ function Tile({ person, showTitle, expandable, isOpen, panelId, onToggle }) {
onClick={onToggle} onClick={onToggle}
> >
{content} {content}
<span className="pl__sr">{isOpen ? "Hide bio" : "Read bio"}</span> <span className="pl__sr">{isOpen ? "Hide details" : "Read more"}</span>
</button> </button>
); );
} }
function Photo({ src, name }) { function Photo({ src, name }: { src?: string | null; name: string }) {
const [failed, setFailed] = useState(false); const [failed, setFailed] = useState(false);
useEffect(() => { useEffect(() => {
@ -244,10 +596,21 @@ function Photo({ src, name }) {
); );
} }
function BioPanel({ id, person, group, onClose }) { function DetailPanel({
const age = resolveAge(person); id,
person,
group,
onClose,
}: {
id: string;
person: Person;
group: PeopleGroup;
onClose: () => void;
}) {
const org = resolveOrg(person.org); const org = resolveOrg(person.org);
const title = titleOf(person);
const paragraphs = Array.isArray(person.bio) ? person.bio : [person.bio]; const paragraphs = Array.isArray(person.bio) ? person.bio : [person.bio];
const tint = person.accent || group?.accent;
return ( return (
<div <div
@ -255,20 +618,19 @@ function BioPanel({ id, person, group, onClose }) {
className="pl__bio" className="pl__bio"
role="region" role="region"
aria-label={`About ${person.name}`} aria-label={`About ${person.name}`}
style={person.accent || group?.accent ? { "--pl-accent": person.accent || group.accent } : undefined} style={tint ? ({ "--pl-accent": tint } as CSSProperties) : undefined}
> >
<div className="pl__bio-head"> <div className="pl__bio-head">
<div> <div>
<p className="pl__bio-name">{person.name}</p> <p className="pl__bio-name">{person.name}</p>
{person.title && <p className="pl__bio-title">{person.title}</p>} {title && <p className="pl__bio-title">{title}</p>}
</div> </div>
<button type="button" className="pl__close" onClick={onClose}> <button type="button" className="pl__close" onClick={onClose}>
<span className="pl__sr">Close bio</span> <span className="pl__sr">Close details</span>
<span aria-hidden="true">×</span> <span aria-hidden="true">×</span>
</button> </button>
</div> </div>
{(person.pronouns || age != null || org) && (
<dl className="pl__facts"> <dl className="pl__facts">
{person.pronouns && ( {person.pronouns && (
<div className="pl__fact"> <div className="pl__fact">
@ -276,12 +638,6 @@ function BioPanel({ id, person, group, onClose }) {
<dd>{person.pronouns}</dd> <dd>{person.pronouns}</dd>
</div> </div>
)} )}
{age != null && (
<div className="pl__fact">
<dt>Age</dt>
<dd>{age}</dd>
</div>
)}
{org && ( {org && (
<div className="pl__fact"> <div className="pl__fact">
<dt>Home organization</dt> <dt>Home organization</dt>
@ -296,8 +652,21 @@ function BioPanel({ id, person, group, onClose }) {
</dd> </dd>
</div> </div>
)} )}
</dl> {person.location_label && (
<div className="pl__fact">
<dt>Based in</dt>
<dd>{person.location_label}</dd>
</div>
)} )}
{person.public_email && (
<div className="pl__fact">
<dt>Email</dt>
<dd>
<a href={`mailto:${person.public_email}`}>{person.public_email}</a>
</dd>
</div>
)}
</dl>
{paragraphs.filter(Boolean).map((paragraph, index) => ( {paragraphs.filter(Boolean).map((paragraph, index) => (
<p key={index} className="pl__bio-text"> <p key={index} className="pl__bio-text">
@ -323,13 +692,13 @@ function Chevron() {
); );
} }
/* helpers ------------------------------------------------------------- */ /* ── Helpers ─────────────────────────────────────────────────── */
function keyFor(group, person, index) { function keyFor(group: PeopleGroup, person: Person, index: number): string {
return `${group.id}:${person.id ?? person.name ?? index}`; return `${group.id}:${person.id ?? person.name ?? index}`;
} }
function findByKey(groups, key) { function findByKey(groups: PeopleGroup[], key: string | null) {
if (!key) return null; if (!key) return null;
for (const group of groups) { for (const group of groups) {
for (let index = 0; index < group.people.length; index += 1) { for (let index = 0; index < group.people.length; index += 1) {
@ -340,12 +709,36 @@ function findByKey(groups, key) {
return null; return null;
} }
function hasBio(person) { /* The seat they hold here, or what they're called when there is no
if (Array.isArray(person.bio)) return person.bio.some(Boolean); seat — the same fallback content.js applies to leadership rows. */
return Boolean(person.bio); function titleOf(person: Person): string | null {
return person.title || person.tagline || null;
} }
function initials(name = "") { function owns(person: Person): boolean {
return Boolean(person.is_owner ?? person.isOwner);
}
/* Anything the panel would have to show. Gating on bio alone left
every tile flat until someone wrote prose. */
function hasDetails(person: Person): boolean {
if (Array.isArray(person.bio) ? person.bio.some(Boolean) : Boolean(person.bio)) {
return true;
}
return Boolean(
person.pronouns || resolveOrg(person.org) || person.location_label || person.public_email,
);
}
/* Database rows carry a bare filename; a hand-written entry may
give a path or a full URL. Both should work. */
function photoSrc(photo?: string | null): string | null {
if (!photo) return null;
if (/^(https?:|\/|data:)/.test(photo)) return photo;
return `/people/${photo}`;
}
function initials(name = ""): string {
return name return name
.trim() .trim()
.split(/\s+/) .split(/\s+/)
@ -355,21 +748,9 @@ function initials(name = "") {
.toUpperCase(); .toUpperCase();
} }
function resolveAge(person) { function resolveOrg(org: Person["org"]) {
if (typeof person.age === "number") return person.age;
if (!person.birthdate) return null;
const born = new Date(person.birthdate);
if (Number.isNaN(born.getTime())) return null;
const now = new Date();
let age = now.getFullYear() - born.getFullYear();
const monthDelta = now.getMonth() - born.getMonth();
if (monthDelta < 0 || (monthDelta === 0 && now.getDate() < born.getDate())) age -= 1;
return age >= 0 ? age : null;
}
function resolveOrg(org) {
if (!org) return null; if (!org) return null;
if (typeof org === "string") return { name: org }; if (typeof org === "string") return { name: org, href: undefined };
if (!org.name) return null; if (!org.name) return null;
return org; return org;
} }

View file

@ -0,0 +1,418 @@
/* ═══════════════════════════════════════════════════════════════
ADMIN FORM PRIMITIVES
Field renders one input from a manifest entry. Repeater renders
an ordered collection of them, and nests one level for content
blocks and their items.
Ordering is array position — the server writes sort_order from
the index — so moving a row is a splice, not a number to
hand-edit. Rows can be dragged by the handle or moved with the
arrow buttons; the arrows are the keyboard path and stay whether
or not a pointer is in use.
A row added and never filled in is dropped by the server rather
than rejected, which depends on spec.blank seeding nothing the
server doesn't also declare as a column default. If you give a
blank row a starting value here, add the matching default: to
that column in the server's admin-schema.js or the row will be
saved as real input.
Two options change the box itself rather than what goes in it:
prefix fixed text inside the box, left of the cursor. The
value it decorates is only the part after it, so
the caller joins the two. Used by composed slugs,
where the prefix is a fact about another field
rather than something to retype.
readOnly shown, selectable, not editable. Deliberately not
`disabled`: a disabled control reads as switched
off and drops out of the tab order, whereas an
immutable id is settled fact you still want to be
able to read and copy.
═══════════════════════════════════════════════════════════════ */
import { useRef, useState } from "react";
const input =
"w-full rounded-lg border border-[#4a6b72]/25 bg-white px-3 py-2 text-sm text-[#26454c] " +
"outline-none transition-colors focus:border-[#138ba0] focus:ring-2 focus:ring-[#138ba0]/25";
/* Same box, but lit by the real input nested inside it. */
const inputShell =
"flex w-full items-center rounded-lg border border-[#4a6b72]/25 bg-white px-3 py-2 text-sm " +
"text-[#26454c] transition-colors focus-within:border-[#138ba0] focus-within:ring-2 " +
"focus-within:ring-[#138ba0]/25";
const inputError = "border-[#b3261e] focus:border-[#b3261e] focus:ring-[#b3261e]/20";
const shellError =
"border-[#b3261e] focus-within:border-[#b3261e] focus-within:ring-[#b3261e]/20";
/* Reads as settled fact rather than as an empty box someone forgot
to fill in. */
const inputLocked =
"w-full rounded-lg border border-[#4a6b72]/20 bg-[#f6fbfc] px-3 py-2 text-sm " +
"text-[#4a6b72] outline-none cursor-default focus:border-[#4a6b72]/40";
/* ── Dotted paths ────────────────────────────────────────────── */
export function getPath(object, path) {
return path.split(".").reduce((value, key) => value?.[key], object);
}
export function setPath(object, path, value) {
const [head, ...rest] = path.split(".");
if (rest.length === 0) return { ...object, [head]: value };
return { ...object, [head]: setPath(object?.[head] ?? {}, rest.join("."), value) };
}
/* ── Field ───────────────────────────────────────────────────── */
export function Field({ field, value, row, options, error, onChange }) {
const id = `f-${field.path.replace(/\./g, "-")}`;
const widget = field.widget ?? "text";
const locked = Boolean(field.readOnly);
let list = null;
let orphaned = false;
if (widget === "select") {
list = field.optionsFrom
? (options?.[field.optionsFrom] ?? []).map((o) => [o.id, o.label, o])
: (field.options ?? []).map((o) =>
Array.isArray(o) ? [o[0], o[1]] : [o, o],
);
if (field.filterBy && row) {
list = list.filter(([, , raw]) => !raw || field.filterBy(raw, row));
}
// A stored value with no matching option renders as the blank
// choice, which reads as "nobody set this" and saves as a
// deliberate clear. It usually means the row it pointed at was
// deleted, so keep it on screen and say so.
orphaned =
value != null &&
value !== "" &&
!list.some(([optionId]) => String(optionId) === String(value));
}
const common = {
id,
className: `${input} ${error ? inputError : ""}`,
value: value ?? "",
onChange: (e) => onChange(e.target.value),
};
return (
<div className={field.full ? "sm:col-span-2" : ""}>
<label htmlFor={id} className="block text-sm font-medium text-[#26454c]">
{field.label}
{field.required && !locked && <span className="ml-1 text-[#b3261e]">*</span>}
</label>
<div className="mt-1.5">
{widget === "checkbox" ? (
<label className="flex items-center gap-2 text-sm text-[#4a6b72]">
<input
id={id}
type="checkbox"
checked={value === 1 || value === true}
disabled={locked}
onChange={(e) => onChange(e.target.checked ? 1 : 0)}
className="h-4 w-4 rounded border-[#4a6b72]/40 text-[#138ba0] focus:ring-[#138ba0]/40 disabled:opacity-50"
/>
{field.help ?? "Yes"}
</label>
) : widget === "textarea" ? (
<textarea
{...common}
rows={4}
readOnly={locked}
className={`${locked ? inputLocked : common.className} resize-y`}
/>
) : widget === "select" ? (
// A select has no readOnly, so this one really does have
// to be disabled — there's no way to keep it focusable
// and still refuse a new choice.
<select
{...common}
disabled={locked}
className={`${locked ? inputLocked : common.className} ${
orphaned && !locked ? inputError : ""
}`}
>
<option value="">{field.blankLabel ?? "— choose —"}</option>
{orphaned && <option value={value}>{value} — no longer exists</option>}
{list.map(([id2, label]) => (
<option key={id2} value={id2}>
{label}
</option>
))}
</select>
) : widget === "color" ? (
<div className="flex gap-2">
<input
type="color"
value={/^#[0-9a-f]{6}$/i.test(value ?? "") ? value : "#138ba0"}
disabled={locked}
onChange={(e) => onChange(e.target.value)}
className="h-9 w-12 shrink-0 rounded border border-[#4a6b72]/25 bg-white disabled:opacity-50"
/>
<input
{...common}
readOnly={locked}
placeholder="#138ba0"
className={locked ? inputLocked : common.className}
/>
</div>
) : field.prefix && !locked ? (
// The span is not a form control, so it can't be typed
// into, tabbed to, or selected by dragging through the
// field. Clicking it focuses the input, which is what
// makes the two read as one box.
<div
className={`${inputShell} ${error ? shellError : ""}`}
onClick={() => document.getElementById(id)?.focus()}
>
<span
aria-hidden="true"
className={`shrink-0 select-none ${
field.prefixPending ? "text-[#4a6b72]/45" : "text-[#4a6b72]"
}`}
>
{field.prefix}
</span>
<input
id={id}
type="text"
value={value ?? ""}
onChange={(e) => onChange(e.target.value)}
placeholder={field.placeholder}
className="w-full border-0 bg-transparent p-0 text-[#26454c] outline-none placeholder:text-[#4a6b72]/45"
/>
</div>
) : (
<input
type={widget === "number" ? "number" : widget === "date" ? "date" : "text"}
step={widget === "number" ? "any" : undefined}
{...common}
readOnly={locked}
aria-readonly={locked || undefined}
className={locked ? inputLocked : common.className}
/>
)}
</div>
{error ? (
<p className="mt-1 text-xs text-[#b3261e]">{error}</p>
) : orphaned && !locked ? (
<p className="mt-1 text-xs text-[#b3261e]">
This points at something that has been deleted. Pick a replacement before saving.
</p>
) : (
field.help &&
widget !== "checkbox" && (
<p className="mt-1 text-xs text-[#4a6b72]">{field.help}</p>
)
)}
</div>
);
}
export function FieldGrid({ children }) {
return <div className="grid gap-4 sm:grid-cols-2">{children}</div>;
}
/* ── Repeater ────────────────────────────────────────────────── */
export function Repeater({ spec, rows, options, errors, errorPrefix, onChange }) {
const list = rows ?? [];
// Which row is in flight, and which one it's currently over.
// Both are per-Repeater, which is what keeps a drag inside a
// nested collection from being accepted by the outer one.
const [dragIndex, setDragIndex] = useState(null);
const [overIndex, setOverIndex] = useState(null);
const rowRefs = useRef([]);
const update = (index, next) =>
onChange(list.map((row, i) => (i === index ? next : row)));
const move = (index, delta) => {
const target = index + delta;
if (target < 0 || target >= list.length) return;
const next = [...list];
[next[index], next[target]] = [next[target], next[index]];
onChange(next);
};
const relocate = (from, to) => {
if (from === to || from == null || to == null) return;
const next = [...list];
const [moved] = next.splice(from, 1);
next.splice(to, 0, moved);
onChange(next);
};
const endDrag = () => {
setDragIndex(null);
setOverIndex(null);
};
return (
<section className="mt-8">
<div className="flex items-center justify-between">
<h3 className="text-base font-semibold text-[#26454c]">{spec.label}</h3>
<button
type="button"
onClick={() => onChange([...list, { ...spec.blank }])}
className="rounded-full border border-[#138ba0] px-3 py-1 text-sm font-medium text-[#138ba0] transition-colors hover:bg-[#eef9fb]"
>
{spec.addLabel ?? "Add"}
</button>
</div>
{spec.note && <p className="mt-1 text-sm text-[#4a6b72]">{spec.note}</p>}
{list.length === 0 && <p className="mt-2 text-sm text-[#4a6b72]">None yet.</p>}
<div className="mt-3 space-y-3">
{list.map((row, index) => {
const dragging = dragIndex === index;
const over = overIndex === index && dragIndex !== null && !dragging;
return (
<div
key={index}
ref={(el) => {
rowRefs.current[index] = el;
}}
onDragOver={(e) => {
// A null dragIndex means the drag started in some
// other collection — leave it to whoever owns it.
if (dragIndex === null) return;
e.preventDefault();
e.stopPropagation();
e.dataTransfer.dropEffect = "move";
if (overIndex !== index) setOverIndex(index);
}}
onDrop={(e) => {
if (dragIndex === null) return;
e.preventDefault();
e.stopPropagation();
relocate(dragIndex, index);
endDrag();
}}
className={
"rounded-xl border bg-[#f6fbfc] p-4 transition-colors " +
(over
? "border-[#138ba0] ring-2 ring-[#138ba0]/25 "
: "border-[#4a6b72]/20 ") +
(dragging ? "opacity-50" : "")
}
>
<div className="mb-3 flex items-center gap-2">
<span
draggable
onDragStart={(e) => {
e.stopPropagation();
setDragIndex(index);
e.dataTransfer.effectAllowed = "move";
// Firefox won't start a drag without payload.
e.dataTransfer.setData("text/plain", String(index));
// Drag the whole row, not just the handle.
const el = rowRefs.current[index];
if (el) e.dataTransfer.setDragImage(el, 16, 16);
}}
onDragEnd={endDrag}
title="Drag to reorder"
aria-hidden="true"
className="cursor-grab select-none px-1 text-[#4a6b72]/60 active:cursor-grabbing"
>
⠿
</span>
<span className="text-sm font-medium text-[#26454c]">
{spec.title ? spec.title(row, options) : `#${index + 1}`}
</span>
<div className="ml-auto flex gap-1">
<IconButton
label="Move up"
onClick={() => move(index, -1)}
disabled={index === 0}
>
↑
</IconButton>
<IconButton
label="Move down"
onClick={() => move(index, 1)}
disabled={index === list.length - 1}
>
↓
</IconButton>
<IconButton
label="Remove"
danger
onClick={() => onChange(list.filter((_, i) => i !== index))}
>
✕
</IconButton>
</div>
</div>
<FieldGrid>
{spec.fields.map((field) => (
<Field
key={field.path}
field={field}
row={row}
value={row[field.path]}
options={options}
error={errors?.[`${errorPrefix}${index}.${field.path}`]}
onChange={(value) => update(index, { ...row, [field.path]: value })}
/>
))}
</FieldGrid>
{(spec.children ?? []).map((nested) => (
<div key={nested.key} className="mt-4 border-t border-[#4a6b72]/15 pt-2">
<Repeater
spec={nested}
rows={row[nested.key]}
options={options}
errors={errors}
errorPrefix={`${errorPrefix}${index}.${nested.key}.`}
onChange={(value) => update(index, { ...row, [nested.key]: value })}
/>
</div>
))}
</div>
);
})}
</div>
</section>
);
}
function IconButton({ label, onClick, danger, disabled, children }) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
aria-label={label}
title={label}
className={
"h-7 w-7 rounded-full border text-sm leading-none transition-colors " +
(disabled
? "cursor-default border-[#4a6b72]/20 text-[#4a6b72]/40"
: danger
? "border-[#b3261e]/30 text-[#b3261e] hover:bg-[#fdf3f2]"
: "border-[#4a6b72]/30 text-[#4a6b72] hover:bg-white")
}
>
{children}
</button>
);
}

51
src/data/feedbackTypes.js Normal file
View file

@ -0,0 +1,51 @@
/* ═══════════════════════════════════════════════════════════════
FEEDBACK TYPES
Used by the public form (to render the tiles) and the admin
triage view (to label them). The ids must match TYPES in
server/src/routes/feedback.js — that list stays separate because
the server isn't in this workspace, and it's the thing that
decides what's storable.
'general' isn't offered anywhere; it's the server's fallback for
a type it doesn't recognise, so old rows still read sensibly.
═══════════════════════════════════════════════════════════════ */
export const FEEDBACK_TYPES = [
{
id: "broken",
label: "Something's broken",
hint: "A link, image, or button that doesn't work",
},
{
id: "confusing",
label: "Hard to use",
hint: "Something you couldn't find or follow",
},
{
id: "outdated",
label: "Wrong or missing info",
hint: "Old dates, typos, an event that isn't listed",
},
{
id: "request",
label: "Feature request",
hint: "Something you'd like the site to do",
},
{
id: "praise",
label: "Kind words",
hint: "Tell us what's working well",
},
{
id: "other",
label: "Something else",
hint: "Anything that doesn't fit the boxes above",
},
];
const BY_ID = new Map(FEEDBACK_TYPES.map((t) => [t.id, t.label]));
export function feedbackTypeLabel(id) {
return BY_ID.get(id) ?? (id === "general" ? "Unsorted" : id);
}

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

View file

@ -1,63 +1,6 @@
import PageShell from "../components/PageShell.tsx"; import PageShell from "../components/PageShell.tsx";
import PeopleTiles from "../components/PeopleTiles.tsx"; import PeopleTiles from "../components/PeopleTiles.tsx";
// Shape reference for <PeopleTiles />. Swap for an API/SQLite fetch when ready —
// the component only cares about the shape, not where it came from.
export const leadershipGroups = [
{
id: "director",
label: "Retreat Director",
accent: "#138ba0",
people: [
{
id: 1,
name: "John Doe",
title: "Retreat Director",
photo: "/people/jordan-ellis.jpg",
pronouns: "he/him",
birthdate: "1994-03-08",
org: { name: "Unity of Des Moines", href: "https://example.org" },
bio: [
"John has coordinated Midwest retreats since 2019 and now oversees chapter launches across five states.",
"He runs the monthly leader call and is the first stop for chapters figuring out their first event.",
],
},
],
},
{
id: "team",
label: "Retreat Team",
people: [
{
id: 2,
name: "Priya Raman",
title: "Events Lead",
photo: "/people/priya-raman.jpg",
pronouns: "she/her",
age: 27,
org: "Unity Chicago",
bio: "Priya plans the summer retreat schedule and handles venue contracts.",
},
{
id: 3,
name: "Sam Okafor",
title: "Communications",
pronouns: "they/them",
age: 24,
org: "Unity of Milwaukee",
bio: "Sam writes the regional newsletter and keeps chapter pages current.",
},
],
},
];
export const board = [
{ id: 10, name: "Jack Doe", title: "NGU Board President" },
{ id: 11, name: "Jane Doe", title: "NGU Board Treasurer" },
{ id: 12, name: "Rev. Miranda Koberg", title: "Minister | NGU Board Secretary" },
];
const SECTIONS = [ const SECTIONS = [
{ {
id: "board-staff", id: "board-staff",
@ -67,7 +10,7 @@ const SECTIONS = [
background: "#eef9fb", background: "#eef9fb",
content: ( content: (
<div className="max-w-6xl mx-auto px-6"> <div className="max-w-6xl mx-auto px-6">
<PeopleTiles size="sm" people={board} overflow="scroll" /> <PeopleTiles size="lg" teams={{ id: "ngu-board",}} />
</div> </div>
), ),
}, },
@ -79,7 +22,7 @@ const SECTIONS = [
background: "#ffffff", background: "#ffffff",
content: ( content: (
<div className="max-w-6xl mx-auto px-6"> <div className="max-w-6xl mx-auto px-6">
<PeopleTiles size="lg" groups={leadershipGroups} /> <PeopleTiles size="lg" teams="ngu-retreat-team" />
</div> </div>
), ),
}, },

View file

@ -0,0 +1,315 @@
/* ═══════════════════════════════════════════════════════════════
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.
Every read passes ttl: 0. The api cache exists for public
content that changes weekly; a triage queue two people are
working at the same time is the opposite case.
═══════════════════════════════════════════════════════════════ */
import { useCallback, useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { get, patch, ApiError } from "../../lib/api.js";
import { isUnauthorized, useAuth } from "../../lib/auth.tsx";
import { feedbackTypeLabel } from "../../data/feedbackTypes.js";
const STATUSES = ["new", "read", "actioned", "archived", "spam"];
const STATUS_STYLE = {
new: "bg-[#138ba0] text-white",
read: "bg-[#eef9fb] text-[#138ba0]",
actioned: "bg-[#eaf3e2] text-[#4a6b2f]",
archived: "bg-[#4a6b72]/10 text-[#4a6b72]",
spam: "bg-[#fdf3f2] text-[#b3261e]",
};
// created_at is UTC in 'YYYY-MM-DD HH:MM:SS' form, which Safari
// won't parse without the T and the Z.
function formatDate(value) {
const date = new Date(`${value.replace(" ", "T")}Z`);
return date.toLocaleString(undefined, {
dateStyle: "medium",
timeStyle: "short",
});
}
function locationOf(row) {
if (!row.page_path) return "Not page-specific";
return row.section_id ? `${row.page_path} #${row.section_id}` : row.page_path;
}
/* ── One submission ──────────────────────────────────────────── */
function FeedbackCard({ row, onChange, canWrite }) {
const [note, setNote] = useState(row.admin_note ?? "");
const [busy, setBusy] = useState(false);
const [error, setError] = useState(null);
const noteDirty = note !== (row.admin_note ?? "");
async function save(changes) {
setBusy(true);
setError(null);
try {
const data = await patch(`/admin/feedback/${row.id}`, changes);
onChange(data.feedback);
} catch (err) {
setError(err instanceof ApiError ? err.message : "Couldn't save that.");
} finally {
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">
<span className="font-semibold text-[#26454c]">
{feedbackTypeLabel(row.feedback_type)}
</span>
<span
className={`rounded-full px-2.5 py-0.5 text-xs font-medium ${
STATUS_STYLE[row.status] ?? ""
}`}
>
{row.status}
</span>
<span className="text-[#4a6b72]">{locationOf(row)}</span>
<span className="ml-auto text-xs text-[#4a6b72]">
#{row.id} · {formatDate(row.created_at)}
</span>
</div>
<p className="mt-4 whitespace-pre-wrap text-[#26454c]">{row.message}</p>
<p className="mt-4 text-sm text-[#4a6b72]">
{row.name || row.email ? (
<>
{row.name && <span>{row.name}</span>}
{row.name && row.email && " · "}
{row.email && (
<a
href={`mailto:${row.email}?subject=Your%20NGU%20site%20feedback`}
className="text-[#138ba0] underline underline-offset-2"
>
{row.email}
</a>
)}
</>
) : (
<span className="italic">Sent anonymously</span>
)}
</p>
{canWrite && (
<div className="mt-5 border-t border-[#4a6b72]/15 pt-4">
<div className="flex flex-wrap items-center gap-3">
<label
htmlFor={`status-${row.id}`}
className="text-sm font-medium text-[#26454c]"
>
Status
</label>
<select
id={`status-${row.id}`}
value={row.status}
disabled={busy}
onChange={(e) => save({ status: e.target.value })}
className="rounded-lg border border-[#4a6b72]/25 bg-white px-3 py-1.5 text-sm text-[#26454c] outline-none focus:border-[#138ba0] focus:ring-2 focus:ring-[#138ba0]/25"
>
{STATUSES.map((s) => (
<option key={s} value={s}>
{s}
</option>
))}
</select>
{error && <span className="text-sm text-[#b3261e]">{error}</span>}
</div>
<textarea
rows={2}
value={note}
disabled={busy}
placeholder="Internal note — who's handling it, what was done"
onChange={(e) => setNote(e.target.value)}
className="mt-3 w-full resize-y rounded-lg border border-[#4a6b72]/25 bg-white px-3 py-2 text-sm text-[#26454c] outline-none focus:border-[#138ba0] focus:ring-2 focus:ring-[#138ba0]/25"
/>
{noteDirty && (
<button
type="button"
disabled={busy}
onClick={() => save({ admin_note: note })}
className="mt-2 rounded-full bg-[#138ba0] px-4 py-1.5 text-sm font-semibold text-white transition-colors hover:bg-[#0f7183] disabled:bg-[#4a6b72]/25"
>
{busy ? "Saving…" : "Save note"}
</button>
)}
</div>
)}
</article>
);
}
/* ── The page ────────────────────────────────────────────────── */
export default function AdminFeedback() {
const { user } = useAuth();
const navigate = useNavigate();
const [status, setStatus] = useState("new");
const [query, setQuery] = useState("");
const [search, setSearch] = useState(""); // applied, not typed
const [rows, setRows] = useState([]);
const [counts, setCounts] = useState({});
const [cursor, setCursor] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const canWrite = user?.role === "admin";
const load = useCallback(
async (before = null) => {
setLoading(true);
setError(null);
const params = new URLSearchParams();
if (status !== "all") params.set("status", status);
if (search) params.set("q", search);
if (before) params.set("before", String(before));
try {
const data = await get(`/admin/feedback?${params}`, { ttl: 0 });
setRows((prev) => (before ? [...prev, ...data.feedback] : data.feedback));
setCounts(data.counts);
setCursor(data.nextCursor);
} catch (err) {
if (isUnauthorized(err)) {
// Session expired while the page was open.
navigate("/admin/login", { replace: true });
return;
}
setError(
err instanceof ApiError ? err.message : "Couldn't reach the server.",
);
} finally {
setLoading(false);
}
},
[status, search, navigate],
);
useEffect(() => {
load();
}, [load]);
function replaceRow(updated) {
setRows((prev) =>
prev
.map((row) => (row.id === updated.id ? updated : row))
// A row that no longer matches the filter drops out, so
// marking something 'read' clears it from the 'new' queue.
.filter((row) => status === "all" || row.status === status),
);
setCounts((prev) => ({ ...prev })); // counts refresh on next load
}
const tabs = [
{ id: "all", label: "All" },
...STATUSES.map((s) => ({ id: s, label: s, count: counts[s] })),
];
return (
<div>
<h1 className="text-3xl font-bold text-[#138ba0]">Feedback</h1>
<p className="mt-2 text-sm text-[#4a6b72]">
{canWrite
? "Everything submitted through the site form."
: "Read-only: your account can't change statuses or notes."}
</p>
{/* Filters */}
<div className="mt-6 flex flex-wrap items-center gap-2">
{tabs.map((tab) => (
<button
key={tab.id}
type="button"
onClick={() => setStatus(tab.id)}
className={
"rounded-full px-4 py-1.5 text-sm transition-colors " +
(status === tab.id
? "bg-[#138ba0] font-semibold text-white"
: "border border-[#4a6b72]/25 text-[#4a6b72] hover:border-[#138ba0]/50")
}
>
{tab.label}
{tab.count ? ` (${tab.count})` : ""}
</button>
))}
<div className="ml-auto flex gap-2">
<input
type="search"
value={query}
placeholder="Search messages"
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") setSearch(query.trim());
}}
className="rounded-full border border-[#4a6b72]/25 bg-white px-4 py-1.5 text-sm text-[#26454c] outline-none focus:border-[#138ba0] focus:ring-2 focus:ring-[#138ba0]/25"
/>
<button
type="button"
onClick={() => setSearch(query.trim())}
className="rounded-full border border-[#4a6b72]/25 px-4 py-1.5 text-sm text-[#4a6b72] transition-colors hover:border-[#138ba0]/50"
>
Search
</button>
</div>
</div>
{/* Results */}
{error && (
<p
role="alert"
className="mt-6 rounded-xl border border-[#b3261e]/30 bg-[#fdf3f2] px-4 py-3 text-sm text-[#b3261e]"
>
{error}
</p>
)}
{!loading && rows.length === 0 && !error && (
<p className="mt-10 text-[#4a6b72]">
Nothing here. {status === "new" ? "The queue is clear." : "Try another filter."}
</p>
)}
<div className="mt-6 space-y-4">
{rows.map((row) => (
<FeedbackCard
key={row.id}
row={row}
canWrite={canWrite}
onChange={replaceRow}
/>
))}
</div>
{loading && <p className="mt-6 text-sm text-[#4a6b72]">Loading…</p>}
{cursor && !loading && (
<button
type="button"
onClick={() => load(cursor)}
className="mt-6 rounded-full border border-[#138ba0] px-5 py-2 font-semibold text-[#138ba0] transition-colors hover:bg-[#eef9fb]"
>
Load older
</button>
)}
</div>
);
}

View file

@ -0,0 +1,160 @@
/* ═══════════════════════════════════════════════════════════════
ADMIN LAYOUT
Bare on purpose. No PageShell, no announcement banner, no
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.
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.
═══════════════════════════════════════════════════════════════ */
import { useCallback, useEffect, useMemo, useState } from "react";
import { 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;
export default function AdminLayout() {
const { user, logout } = useAuth();
const navigate = useNavigate();
const { pathname } = useLocation();
const active = NAV.find(
(item) =>
matches(pathname, item.to) ||
(item.children ?? []).some((child) => matches(pathname, child.to)),
);
const activeChild = (active?.children ?? []).find((child) =>
matches(pathname, child.to),
);
// What the page below has published about itself — a record
// name, or null on a list. setDetail is stable so publishing
// can't loop.
const [detail, setDetail] = useState(null);
const stableSet = useCallback((value) => setDetail(value), []);
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]);
async function handleLogout() {
await logout();
navigate("/admin/login", { replace: true });
}
const subnav = active?.children ?? [];
return (
<div className="min-h-screen 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>
<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>
<button
type="button"
onClick={handleLogout}
className="rounded-full border border-[#4a6b72]/30 px-4 py-1.5 font-medium transition-colors hover:bg-[#eef9fb] hover:text-[#138ba0]"
>
Sign out
</button>
</div>
</div>
{/* Subnav. Only drawn where there is something to draw, so
Events and People don't get an empty grey strip. */}
{subnav.length > 0 && (
<div className="border-t border-[#138ba0]/10 bg-[#f6fbfc]">
<div className="mx-auto flex max-w-5xl flex-wrap gap-6 px-6 py-2.5 text-sm">
{subnav.map((child) => (
<NavLink
key={child.to}
to={child.to}
className={
child === activeChild
? "font-semibold text-[#138ba0]"
: "text-[#4a6b72] transition-colors hover:text-[#138ba0]"
}
>
{child.label}
</NavLink>
))}
</div>
</div>
)}
</header>
<main className="mx-auto max-w-5xl px-6 py-10">
<AdminTitleContext.Provider value={titleContext}>
<Outlet />
</AdminTitleContext.Provider>
</main>
</div>
);
}

View file

@ -0,0 +1,142 @@
/* ═══════════════════════════════════════════════════════════════
ADMIN LOGIN
Deliberately outside PageShell and the site nav. This isn't a
page of the website; it's the door to the back office, and it
shouldn't carry a banner, a subnav, or a footer site map.
The Google button is stubbed and disabled until the OAuth
routes exist. It's here so the layout doesn't change when it
starts working.
═══════════════════════════════════════════════════════════════ */
import { useState } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import { useAuth } from "../../lib/auth.tsx";
import { ApiError } from "../../lib/api.js";
const GOOGLE_ENABLED = false;
const fieldClass =
"w-full rounded-xl border border-[#4a6b72]/25 bg-white px-4 py-3 text-[#26454c] " +
"outline-none transition-colors focus:border-[#138ba0] focus:ring-2 focus:ring-[#138ba0]/25";
export default function AdminLogin() {
const { login } = useAuth();
const navigate = useNavigate();
const location = useLocation();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState(null);
const [busy, setBusy] = useState(false);
const destination = location.state?.from?.pathname ?? "/admin/feedback";
async function handleSubmit(event) {
event.preventDefault();
if (busy) return;
setBusy(true);
setError(null);
try {
await login(email.trim(), password);
navigate(destination, { replace: true });
} catch (err) {
setError(
err instanceof ApiError
? err.message
: "Couldn't reach the server. Try again in a moment.",
);
setPassword("");
setBusy(false);
}
}
return (
<div className="flex min-h-screen items-center justify-center bg-[#eef9fb] px-6 py-16">
<div className="w-full max-w-sm">
<h1 className="text-3xl font-bold text-[#138ba0]">NGU admin</h1>
<p className="mt-2 text-sm text-[#4a6b72]">
Sign in to read and triage site feedback.
</p>
<form
onSubmit={handleSubmit}
noValidate
className="mt-8 rounded-2xl border border-[#138ba0]/20 bg-white p-6"
>
<label
htmlFor="admin-email"
className="block text-sm font-medium text-[#26454c]"
>
Email
</label>
<input
id="admin-email"
type="email"
autoComplete="username"
autoFocus
value={email}
onChange={(e) => setEmail(e.target.value)}
className={`mt-2 ${fieldClass}`}
/>
<label
htmlFor="admin-password"
className="mt-5 block text-sm font-medium text-[#26454c]"
>
Password
</label>
<input
id="admin-password"
type="password"
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className={`mt-2 ${fieldClass}`}
/>
{error && (
<p
role="alert"
className="mt-5 rounded-xl border border-[#b3261e]/30 bg-[#fdf3f2] px-4 py-3 text-sm text-[#b3261e]"
>
{error}
</p>
)}
<button
type="submit"
disabled={busy || !email || !password}
className="mt-6 w-full rounded-full bg-[#138ba0] px-6 py-3 font-semibold text-white transition-colors hover:bg-[#0f7183] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#138ba0]/40 disabled:cursor-not-allowed disabled:bg-[#4a6b72]/25"
>
{busy ? "Signing in…" : "Sign in"}
</button>
{GOOGLE_ENABLED && (
<>
<div className="my-6 flex items-center gap-3 text-xs text-[#4a6b72]">
<span className="h-px flex-1 bg-[#4a6b72]/20" />
or
<span className="h-px flex-1 bg-[#4a6b72]/20" />
</div>
<a
href="/api/auth/google"
className="block rounded-full border border-[#4a6b72]/30 px-6 py-3 text-center font-semibold text-[#26454c] transition-colors hover:bg-[#eef9fb]"
>
Continue with Google
</a>
</>
)}
</form>
<p className="mt-6 text-center text-xs text-[#4a6b72]">
Accounts are created on the server. Ask whoever runs the box.
</p>
</div>
</div>
);
}

View file

@ -0,0 +1,422 @@
/* ═══════════════════════════════════════════════════════════════
ADMIN — ENTITY EDIT
Create and edit for every entity in the manifest. The form holds
the whole nested object — parent row, side tables, child
collections — and PATCH sends the lot. The server replaces
children wholesale, so what you see here is exactly what will
exist afterwards.
Nothing is written until Save, which is what makes removing a
repeater row safe: leaving without saving undoes it. That only
holds if leaving is hard to do by accident, hence the dirty
tracking and the two guards below.
A field the form never sets is left out of the payload entirely,
and the server lets the column's own DEFAULT apply. So a blank
new-record form is deliberate, not lazy — writing "" into every
field is what used to turn a default into a constraint failure.
updated_at rides along untouched. If someone else saved while
this page was open the server answers 409 rather than letting
one of you quietly overwrite the other. Entities with no
updated_at column simply never get one, and the 409 path stays
dormant for them.
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
primary key, so every org's 'Board' would collide — which is
why the array form exists and teams uses it.
═══════════════════════════════════════════════════════════════ */
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
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 { Field, FieldGrid, Repeater, getPath, setPath } from "../../components/admin/fields.tsx";
/* A foreign key refusing to budge is the most common way a save or
delete fails here, and SQLite's own wording explains nothing to
whoever is filling in the form. */
function friendly(message, singular) {
if (/FOREIGN KEY constraint failed/i.test(message ?? "")) {
return `Something still points at this ${singular}. Reassign or remove those first.`;
}
return message;
}
export default function EntityEdit() {
const { entity: entityKey, id } = useParams();
const manifest = ADMIN_ENTITIES[entityKey];
const navigate = useNavigate();
const { user } = useAuth();
const isNew = id === "new";
const canWrite = user?.role === "admin";
// 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
// same paths the heading uses.
const slugPaths = Array.isArray(manifest?.slugFrom)
? manifest.slugFrom
: [manifest?.slugFrom].filter(Boolean);
// Everything before the last path is a qualifier: a fact about
// another field rather than something to type. It renders as
// fixed text inside the slug box, and the editable part is only
// what follows it.
const qualifierPaths = slugPaths.slice(0, -1);
// Empty until every qualifier is chosen, because half a prefix
// would be saved into an id that then never matches.
const prefixOf = (source) => {
if (qualifierPaths.length === 0) return "";
const parts = qualifierPaths.map((path) => getPath(source, path));
if (parts.some((part) => !part)) return "";
return `${slugify(parts.join(" "))}-`;
};
// What to show greyed out before then: org_id becomes "org-".
const prefixHint = qualifierPaths.length
? `${qualifierPaths.map((path) => path.replace(/_id$/, "")).join("-")}-`
: "";
const tailOf = (source) => {
const prefix = prefixOf(source);
const value = source?.id ?? "";
return prefix && value.startsWith(prefix) ? value.slice(prefix.length) : value;
};
// The heading wants the specific half, not the qualifier: a team
// page reads "Board", not "northwest Board".
const headingPath = slugPaths[slugPaths.length - 1];
const [form, setForm] = useState(null);
const [options, setOptions] = useState({});
const [errors, setErrors] = useState({});
const [message, setMessage] = useState(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [slugTouched, setSlugTouched] = useState(false);
// The last state the server confirmed. Everything else compares
// against this to decide whether there's anything to lose.
const baseline = useRef(null);
const load = useCallback(async () => {
if (!manifest) return;
setLoading(true);
setErrors({});
try {
const opts = await get("/admin/options", { ttl: 60_000 });
setOptions(opts.options);
if (isNew) {
// Blank children arrays matter: an absent key means "don't
// 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: "" };
for (const child of manifest.children ?? []) blank[child.key] = [];
setForm(blank);
baseline.current = JSON.stringify(blank);
} else {
const data = await get(`/admin/${manifest.key}/${id}`, { ttl: 0 });
setForm(data.row);
baseline.current = JSON.stringify(data.row);
}
setMessage(null);
} catch (err) {
if (isUnauthorized(err)) return navigate("/admin/login", { replace: true });
setMessage({
tone: "error",
text: err instanceof ApiError ? err.message : "Couldn't load that.",
});
} finally {
setLoading(false);
}
}, [manifest, id, isNew, navigate]);
useEffect(() => {
load();
}, [load]);
const dirty = useMemo(
() => Boolean(form) && JSON.stringify(form) !== baseline.current,
[form],
);
// The tab says what's on screen; the layout adds the section and
// the site name. Null while loading, so it reads "Teams | NGU
// Admin CMS" for the half-second before the record arrives
// rather than flashing a slug.
useAdminDetail(
!manifest
? null
: isNew
? `New ${manifest.singular}`
: form
? getPath(form, headingPath) || form.id
: null,
);
// Closing the tab or hitting the browser back button skips React
// Router entirely, so the only hook available is this one.
useEffect(() => {
if (!dirty) return undefined;
const warn = (event) => {
event.preventDefault();
event.returnValue = "";
};
window.addEventListener("beforeunload", warn);
return () => window.removeEventListener("beforeunload", warn);
}, [dirty]);
if (!manifest) return <p className="text-[#4a6b72]">No such thing to edit.</p>;
if (loading || !form) return <p className="text-[#4a6b72]">Loading…</p>;
/* ── Heading ───────────────────────────────────────────────── */
const heading = getPath(form, headingPath) || form.id;
const children = manifest.children ?? [];
/* ── Actions ───────────────────────────────────────────────── */
const leave = (to) => {
if (dirty && !window.confirm("Leave without saving? Your changes will be lost.")) return;
navigate(to);
};
const change = (path, value) => {
setForm((prev) => {
let next = setPath(prev, path, value);
// Recompose the id whenever one of its sources moves. The
// prefix always follows the organization — picking a
// different one has to change the slug, or it would claim a
// team belongs somewhere it doesn't. The tail only follows
// the name until someone types over it.
if (isNew && slugPaths.includes(path)) {
const tail = slugTouched
? tailOf(prev)
: slugify(getPath(next, headingPath) ?? "");
next = { ...next, id: `${prefixOf(next)}${tail}` };
}
return next;
});
// Clear this field's error as soon as it's touched; leaving a
// stale red outline on a field the user just fixed reads as a
// save that didn't take.
setErrors((prev) => (prev[path] ? { ...prev, [path]: undefined } : prev));
};
async function save() {
setSaving(true);
setErrors({});
setMessage(null);
try {
const data = isNew
? await post(`/admin/${manifest.key}`, form)
: await patch(`/admin/${manifest.key}/${id}`, form);
setForm(data.row);
baseline.current = JSON.stringify(data.row);
setMessage({ tone: "ok", text: "Saved." });
if (isNew) navigate(`/admin/${manifest.key}/${data.row.id}`, { replace: true });
} catch (err) {
if (isUnauthorized(err)) return navigate("/admin/login", { replace: true });
if (err instanceof ApiError) {
// 409 means the updated_at we're holding is stale. Every
// further save will fail the same way until the page is
// reloaded, so offer that rather than just saying no.
if (err.status === 409) {
setMessage({ tone: "error", text: err.message, recover: "reload" });
} else {
setErrors(err.fields ?? {});
setMessage({
tone: "error",
text: err.fields
? "Some fields need attention."
: friendly(err.message, manifest.singular),
});
}
} else {
setMessage({ tone: "error", text: "Couldn't reach the server." });
}
} finally {
setSaving(false);
}
}
async function remove() {
if (!window.confirm(`Delete ${heading}? Its links, blocks and roles go with it.`)) return;
try {
await del(`/admin/${manifest.key}/${id}`);
baseline.current = JSON.stringify(form); // nothing left to warn about
navigate(`/admin/${manifest.key}`, { replace: true });
} catch (err) {
setMessage({
tone: "error",
text:
err instanceof ApiError
? friendly(err.message, manifest.singular)
: "Couldn't delete that.",
});
}
}
const visible = (when) => !when || getPath(form, when.path) === when.value;
return (
<div className="pb-24">
<button
type="button"
onClick={() => leave(`/admin/${manifest.key}`)}
className="text-sm text-[#4a6b72] hover:text-[#138ba0]"
>
← {manifest.label}
</button>
<h1 className="mt-2 text-3xl font-bold text-[#138ba0]">
{isNew ? `New ${manifest.singular}` : heading}
</h1>
{/* Slug */}
<div className="mt-6 rounded-2xl border border-[#138ba0]/20 bg-white p-5">
<Field
field={{
path: "id",
label: manifest.idLabel,
prefix: isNew && qualifierPaths.length ? prefixOf(form) || prefixHint : undefined,
prefixPending: isNew && !prefixOf(form),
placeholder: isNew && qualifierPaths.length ? "board" : undefined,
readOnly: !isNew,
help: !isNew
? "Fixed once created — links and content blocks reference it."
: qualifierPaths.length
? "The prefix comes from the organization. Type the rest."
: "Lowercase, hyphens, no spaces. Can't be changed later.",
}}
value={isNew ? tailOf(form) : form.id}
error={errors.id}
onChange={(value) => {
setSlugTouched(true);
change("id", `${prefixOf(form)}${slugify(value)}`);
}}
/>
{!isNew && form.updated_at && (
<p className="mt-2 text-xs text-[#4a6b72]">
Last saved {form.updated_at}
</p>
)}
</div>
{/* Field groups */}
{manifest.groups.filter((group) => visible(group.when)).map((group) => (
<section
key={group.legend}
className="mt-6 rounded-2xl border border-[#138ba0]/20 bg-white p-5"
>
<h2 className="text-lg font-semibold text-[#26454c]">{group.legend}</h2>
{group.note && <p className="mt-1 text-sm text-[#4a6b72]">{group.note}</p>}
<div className="mt-4">
<FieldGrid>
{group.fields.map((field) => (
<Field
key={field.path}
field={field}
value={getPath(form, field.path)}
options={options}
error={errors[field.path]}
onChange={(value) => change(field.path, value)}
/>
))}
</FieldGrid>
</div>
</section>
))}
{/* Child collections. Awards own none, so the panel would be
an empty white box — skip it rather than render it. */}
{children.some((child) => visible(child.when)) && (
<div className="mt-6 rounded-2xl border border-[#138ba0]/20 bg-white p-5">
{children
.filter((child) => visible(child.when))
.map((child) => (
<Repeater
key={child.key}
spec={child}
rows={form[child.key]}
options={options}
errors={errors}
errorPrefix={`${child.key}.`}
onChange={(rows) => setForm((prev) => ({ ...prev, [child.key]: rows }))}
/>
))}
</div>
)}
{/* Sticky action bar */}
<div className="fixed inset-x-0 bottom-0 border-t border-[#138ba0]/20 bg-white/95 backdrop-blur">
<div className="mx-auto flex max-w-5xl flex-wrap items-center gap-4 px-6 py-3">
{canWrite ? (
<>
<button
type="button"
onClick={save}
disabled={saving || !dirty}
className="rounded-full bg-[#138ba0] px-6 py-2 font-semibold text-white transition-colors hover:bg-[#0f7183] disabled:bg-[#4a6b72]/25"
>
{saving ? "Saving…" : isNew ? "Create" : "Save changes"}
</button>
{!isNew && (
<button
type="button"
onClick={remove}
className="rounded-full border border-[#b3261e]/40 px-4 py-2 text-sm font-medium text-[#b3261e] transition-colors hover:bg-[#fdf3f2]"
>
Delete
</button>
)}
</>
) : (
<span className="text-sm text-[#4a6b72]">
Read-only: your account can't save changes.
</span>
)}
{dirty && !saving && (
<span className="text-sm text-[#4a6b72]">Unsaved changes</span>
)}
{message && (
<span
role="status"
className={`flex items-center gap-2 text-sm ${
message.tone === "ok" ? "text-[#138ba0]" : "text-[#b3261e]"
}`}
>
{message.text}
{message.recover === "reload" && (
<button
type="button"
onClick={load}
className="rounded-full border border-[#b3261e]/40 px-3 py-1 text-xs font-medium"
>
Reload
</button>
)}
</span>
)}
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,181 @@
/* ═══════════════════════════════════════════════════════════════
ADMIN — ENTITY LIST
One component for organizations, events and people. The :entity
route param picks the manifest; nothing here knows what a
chapter or a retreat is.
═══════════════════════════════════════════════════════════════ */
import { useCallback, useEffect, useState } from "react";
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";
export default function EntityList() {
const { entity: entityKey } = useParams();
const manifest = ADMIN_ENTITIES[entityKey];
const navigate = useNavigate();
const { user } = useAuth();
const [params, setParams] = useSearchParams();
const [rows, setRows] = useState([]);
const [options, setOptions] = useState({});
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [query, setQuery] = useState(params.get("q") ?? "");
const canWrite = user?.role === "admin";
const load = useCallback(async () => {
if (!manifest) return;
setLoading(true);
setError(null);
try {
const [list, opts] = await Promise.all([
get(`/admin/${manifest.key}?${params}`, { ttl: 0 }),
get("/admin/options", { ttl: 60_000 }),
]);
setRows(list.rows);
setOptions(opts.options);
} catch (err) {
if (isUnauthorized(err)) return navigate("/admin/login", { replace: true });
setError(err instanceof ApiError ? err.message : "Couldn't reach the server.");
} finally {
setLoading(false);
}
}, [manifest, params, navigate]);
useEffect(() => {
load();
}, [load]);
if (!manifest) {
return <p className="text-[#4a6b72]">No such thing to edit.</p>;
}
function setParam(key, value) {
const next = new URLSearchParams(params);
if (value) next.set(key, value);
else next.delete(key);
setParams(next, { replace: true });
}
function labelFor(filter, value) {
if (filter.optionsFrom) {
return (options[filter.optionsFrom] ?? []).map((o) => [o.id, o.label]);
}
return filter.options.map((o) => (Array.isArray(o) ? o : [o, o]));
}
return (
<div>
<div className="flex flex-wrap items-center gap-4">
<h1 className="text-3xl font-bold text-[#138ba0]">{manifest.label}</h1>
{canWrite && (
<Link
to={`/admin/${manifest.key}/new`}
className="ml-auto rounded-full bg-[#138ba0] px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-[#0f7183]"
>
New {manifest.singular}
</Link>
)}
</div>
{/* Filters */}
<div className="mt-6 flex flex-wrap items-end gap-3">
{manifest.list.filters.map((filter) => (
<label key={filter.key} className="text-xs text-[#4a6b72]">
<span className="block">{filter.label}</span>
<select
value={params.get(filter.key) ?? ""}
onChange={(e) => setParam(filter.key, e.target.value)}
className="mt-1 rounded-lg border border-[#4a6b72]/25 bg-white px-3 py-1.5 text-sm text-[#26454c] outline-none focus:border-[#138ba0]"
>
<option value="">All</option>
{labelFor(filter).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
</label>
))}
<div className="ml-auto flex gap-2">
<input
type="search"
value={query}
placeholder="Search"
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && setParam("q", query.trim())}
className="rounded-full border border-[#4a6b72]/25 bg-white px-4 py-1.5 text-sm text-[#26454c] outline-none focus:border-[#138ba0]"
/>
<button
type="button"
onClick={() => setParam("q", query.trim())}
className="rounded-full border border-[#4a6b72]/25 px-4 py-1.5 text-sm text-[#4a6b72] hover:border-[#138ba0]/50"
>
Search
</button>
</div>
</div>
{error && (
<p
role="alert"
className="mt-6 rounded-xl border border-[#b3261e]/30 bg-[#fdf3f2] px-4 py-3 text-sm text-[#b3261e]"
>
{error}
</p>
)}
{/* Rows */}
<div className="mt-6 overflow-x-auto rounded-2xl border border-[#138ba0]/20 bg-white">
<table className="w-full text-left text-sm">
<thead className="border-b border-[#4a6b72]/15 text-xs text-[#4a6b72]">
<tr>
{manifest.list.columns.map((column) => (
<th key={column.key} className="px-4 py-3 font-medium">
{column.label}
</th>
))}
<th className="px-4 py-3 font-medium">Slug</th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr
key={row.id}
className="cursor-pointer border-b border-[#4a6b72]/10 last:border-0 hover:bg-[#f6fbfc]"
onClick={() => navigate(`/admin/${manifest.key}/${row.id}`)}
>
{manifest.list.columns.map((column) => (
<td
key={column.key}
className={`px-4 py-3 ${
column.primary ? "font-medium text-[#26454c]" : "text-[#4a6b72]"
}`}
>
{column.widget === "bool"
? row[column.key]
? "Yes"
: "—"
: row[column.key] || "—"}
</td>
))}
<td className="px-4 py-3 font-mono text-xs text-[#4a6b72]">{row.id}</td>
</tr>
))}
</tbody>
</table>
{!loading && rows.length === 0 && (
<p className="px-4 py-8 text-center text-[#4a6b72]">Nothing matches.</p>
)}
{loading && <p className="px-4 py-8 text-center text-[#4a6b72]">Loading…</p>}
</div>
</div>
);
}

View file

@ -14,6 +14,7 @@
import { useState } from "react"; import { useState } from "react";
import { post, ApiError } from "../../lib/api.js"; import { post, ApiError } from "../../lib/api.js";
import { PAGE_LINKS, PAGE_SECTIONS } from "../../navConfig.js"; import { PAGE_LINKS, PAGE_SECTIONS } from "../../navConfig.js";
import { FEEDBACK_TYPES } from "../../data/feedbackTypes.js";
const ACCENT = "#138ba0"; const ACCENT = "#138ba0";
const MUTED = "#4a6b72"; const MUTED = "#4a6b72";
@ -25,39 +26,6 @@ const SITE_WIDE = "site";
// Sentinel for "this page, but not one section of it". // Sentinel for "this page, but not one section of it".
const WHOLE_PAGE = ""; const WHOLE_PAGE = "";
// Ids must match TYPES in server/src/routes/feedback.js.
const FEEDBACK_TYPES = [
{
id: "broken",
label: "Something's broken",
hint: "A link, image, or button that doesn't work",
},
{
id: "confusing",
label: "Hard to use",
hint: "Something you couldn't find or follow",
},
{
id: "outdated",
label: "Wrong or missing info",
hint: "Old dates, typos, an event that isn't listed",
},
{
id: "request",
label: "Feature request",
hint: "Something you'd like the site to do",
},
{
id: "praise",
label: "Kind words",
hint: "Tell us what's working well",
},
{
id: "other",
label: "Something else",
hint: "Anything that doesn't fit the boxes above",
},
];
/* ── Shared field chrome ─────────────────────────────────────── */ /* ── Shared field chrome ─────────────────────────────────────── */