/* ═══════════════════════════════════════════════════════════════ 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. The "New X" link follows the same rule as EntityEdit's save button: atLeast(user, "editor"), matching requireRole("editor") on POST /api/admin/:entity. Drawing it is not permission — the server decides — it only avoids offering a click that 403s, and avoids hiding one that wouldn't. ═══════════════════════════════════════════════════════════════ */ import { useCallback, useEffect, useState } from "react"; import { Link, useNavigate, useParams, useSearchParams } from "react-router-dom"; import { get, ApiError } from "../../lib/api.js"; import { isUnauthorized, useAuth } from "../../lib/auth.tsx"; import { ADMIN_ENTITIES } from "../../lib/adminSchema.js"; import { atLeast } from "../../lib/roles.ts"; export default function EntityList() { const { entity: entityKey } = useParams(); 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") ?? ""); // Minimum rank, never equality. POST /api/admin/:entity is gated // at "editor", so anyone from editor upward may create — and the // equality test this replaced hid the button from superadmins as // well as editors, which is the failure mode an == against a // ladder always produces once a rank is added above it. const canWrite = atLeast(user, "editor"); const load = useCallback(async () => { if (!manifest) return; 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
No such thing to edit.
; } 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 ({error}
)} {/* Rows */}| {column.label} | ))}Slug |
|---|---|
| {column.widget === "bool" ? row[column.key] ? "Yes" : "—" : row[column.key] || "—"} | ))}{row.id} |
Nothing matches.
)} {loading &&Loading…
}