v1.4 - added admin page and auth

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

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