v1.4 - added admin page and auth
This commit is contained in:
parent
5efdafbb97
commit
1f0aa3078f
29 changed files with 5264 additions and 217 deletions
614
src/lib/adminSchema.js
Normal file
614
src/lib/adminSchema.js
Normal 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
30
src/lib/adminTitle.tsx
Normal 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]);
|
||||
}
|
||||
|
|
@ -100,3 +100,15 @@ export function post(path, data) {
|
|||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
export function patch(path, data) {
|
||||
return request(path, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
export function del(path) {
|
||||
return request(path, { method: "DELETE" });
|
||||
}
|
||||
|
|
|
|||
98
src/lib/auth.tsx
Normal file
98
src/lib/auth.tsx
Normal 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 />;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue