[object Object]

← back to Norma

session 2026-05-20: log roles/perms/modals + infra fires + 4 user seed

b163e4d7c4907b9cc0ea217c00e5c1dcaf0fc929 · 2026-05-20 12:43:00 -0700 · Steve Abrams

Files touched

Diff

commit b163e4d7c4907b9cc0ea217c00e5c1dcaf0fc929
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Wed May 20 12:43:00 2026 -0700

    session 2026-05-20: log roles/perms/modals + infra fires + 4 user seed
---
 docs/sessions/learnings.md       | 25 ++++++++++++++++++++++
 docs/sessions/sessions.md        | 45 ++++++++++++++++++++++++++++++++++++++++
 docs/sessions/tasks-requested.md | 11 ++++++++++
 3 files changed, 81 insertions(+)

diff --git a/docs/sessions/learnings.md b/docs/sessions/learnings.md
index 00b9261..854adc2 100644
--- a/docs/sessions/learnings.md
+++ b/docs/sessions/learnings.md
@@ -137,3 +137,28 @@ Technical learnings, gotchas, and patterns discovered during development. Append
 - **429 must include headers too**: same `X-RateLimit-*` trio plus `Retry-After`. Clients not parsing Reset still get a pragmatic backoff hint.
 - **`next build` is NOT a typecheck** when `typescript.ignoreBuildErrors: true`. Run `npx tsc --noEmit` separately. Grep output by filename to distinguish new errors from pre-existing ones.
 - **Fan-in to shared lib functions**: when adding data a route needs (e.g., `rateLimit` for verifyApiKey), extend the existing return value — one DB query, and the field travels with semantically related fields like scopes/keyId.
+
+## 2026-05-20 — Roles, perms matrix, modals, infra fires
+
+### Role + permissions data model
+- `tier_credentials.role` enum is now `admin | staff | intern | pulse`. Adding a 5th role takes one ALTER on the CHECK constraint + one entry in `ALL_ROLES` in `lib/auth.ts`.
+- `role_permissions` is the admin-editable matrix; `feature_key` is the PK, `allowed_roles` is `text[]`.
+- `lib/permissions.ts` is the only reader; it caches the table for 60s and short-circuits `admin → true`. Mutations call `clearPermissionsCache()`.
+- "user" in the UI maps to the `staff` role internally — display labels live in `ROLE_LABEL` in `lib/auth.ts` so admins never see the internal name.
+
+### Bcrypt + shell escaping
+- A heredoc piped to `ssh "psql"` with the hash `$2b$12$...` inline will have `$` interpolated unless escaped — but the escape `\$` lands in PG as a literal backslash too if the heredoc is double-quoted. Cleanest fix is `psql -v hash='$2b$12$...'` and reference as `:'hash'` in SQL, OR run a `replace(password_hash, '\$', '$')` cleanup after the fact.
+- bcryptjs (`$2b$` prefix) is interchangeable with native bcrypt (`$2a$` / `$2b$`) for verification.
+
+### Rsync trailing-slash trap
+- `rsync -av components/` copies the CONTENTS of components/ into dest. `rsync -av components` (no slash) copies the directory itself.
+- When passing multiple sources to a single dest path, rsync treats the dest as a directory and concatenates all sources INTO it. Combining the trailing-slash and multi-source pitfalls in one command can flatten an entire Next.js project into root-level garbage. Always either: (a) loop one-at-a-time, or (b) sync the whole project tree with `./` → `dest/`.
+
+### PG "out of disk" on prod
+- Symptom: `pg_isready` returns no response despite `systemctl status postgresql` showing the service as "active (exited)". The actual cluster is reported by `pg_lsclusters`.
+- PG WAL (`pg_wal/`) tends to grow when logical replication slots are stale, but is NOT safe to delete manually.
+- Safe disk-recovery wins: vacuum journal logs (`journalctl --vacuum-time=Nd`), pm2 flush, truncate over-grown app logs. Don't touch `/var/lib/postgresql/` or `/var/lib/docker/` without a plan.
+
+### ResizableModal pattern
+- A modal wrapper that owns Esc-to-close + backdrop-click + max/restore + corner drag-resize + localStorage size persistence keeps each retrofit to a 3-line change — swap the `<div class="fixed inset-0 z-50 …">` for `<ResizableModal modalId="…" title="…" onClose={…} footer={…}>`.
+- The `modalId` MUST be stable across renders, or every open creates a new localStorage entry. Use a literal string per modal.
diff --git a/docs/sessions/sessions.md b/docs/sessions/sessions.md
index 2e0ef0a..7fa6735 100644
--- a/docs/sessions/sessions.md
+++ b/docs/sessions/sessions.md
@@ -251,3 +251,48 @@ Append each session summary here. Never delete prior entries.
 
 - `86016a9` — chore: untrack accidentally committed node_modules and stale worktree
 - `bf736f5` — feat(api): enforce per-key rate limiting on /api/v1/* + register credential-health cron
+
+---
+
+## Session: 2026-05-20 (PT) — SDCC ease-of-use: roles, perms, modals
+
+**Duration**: ~1h 15min
+**Model**: Claude Opus 4.7 (1M context)
+
+### Work Completed
+
+1. **Migration 024** — `intern` added to `tier_credentials.role` enum (alongside admin/staff/pulse); added `full_name`, `email`, `is_active`, `last_login_at`, `created_by` columns; new `role_permissions` table seeded with 22 feature_keys across 8 categories (gmail / drafts / petitions / contacts / news / grants / outreach / social / settings).
+2. **lib/auth.ts** — `UserRole` union + `ALL_ROLES` + `ROLE_LABEL` exports. Updated `AuthSession`, `AuthProvider`, `Sidebar`, `pulse/layout.tsx` literals to include `intern`.
+3. **lib/permissions.ts** + `/api/admin/permissions` GET/PUT — admin-only matrix editor with 60s in-process cache, `clearPermissionsCache()` invalidator. Admin always allowed.
+4. **tier-credentials API** — POST/PUT accept full_name/email/is_active. Admin row cannot be disabled or demoted. Email validated.
+5. **/api/auth/login** — blocks `is_active=false` AFTER password check (no username-existence leak); bumps `last_login_at`; returns full_name.
+6. **CredentialsSection.tsx** — new collapsible per-user permissions row showing every feature the user can access. Add User modal grew Full Name / Email / Active toggle / Intern role option.
+7. **PermissionsMatrix.tsx** — admin-editable role × feature checkbox grid, mounted directly under Users & Roles in Settings.
+8. **Seeded 4 SDCC users** — natalia + sabrinaashley as Admin, sabrinacalazans as User, ryan as Intern. All pinned to `Student Debt Crisis Center` org. Temp passwords in `docs/sessions/NORMA-NEW-USERS-2026-05-20.md` (gitignored).
+9. **components/shared/ResizableModal.tsx** — shared modal wrapper with full-screen toggle button + bottom-right drag-resize handle + per-modalId localStorage persistence. Both modals in CredentialsSection retrofitted as proof points.
+
+### Infrastructure side-effects (worth tracking)
+
+- Norma was **not running on prod** at session start (no pm2 entry for `norma-email`, nothing on :7400). Started via `ecosystem.config.js` after deploy. `pm2 save` persisted.
+- **First rsync had trailing-slash bug** that flattened `app/`, `components/`, `lib/`, etc. into the project root. Recovered with `mv Norma Norma.broken-20260520 && mkdir Norma && cp .env.local + node_modules` then full clean rsync.
+- **PG cluster was down** ("no space left on device" PANIC at 19:32 UTC). `/` was 100% full (379G / 394G). Freed 6.2GB via: removed `/root/Projects/Norma.broken-20260520` (2.2G — my own backup), `pm2 flush` (1.6G), `journalctl --vacuum-time=3d` (3.6G). PG cluster restarted clean.
+- **Bcrypt hash escape bug** — my heredoc to `psql` shipped `\$2b\$...` literals into PG. Cleaned via `UPDATE ... SET password_hash = replace(password_hash, '\\$', '$')`.
+- **Rate-limiter false-trigger** on natalia/ryan after the bad hashes returned 5×401s — cleared by `pm2 restart norma-email`.
+
+### Key Learnings (also added to learnings.md below)
+
+- `tier_credentials` and `role_permissions` are now the canonical source of truth for who can do what in Norma. Admin is hardcoded to bypass the matrix in `lib/permissions.ts`.
+- `last_login_at` is bumped best-effort (non-blocking promise) on every successful login.
+- Per-user permissions render in CredentialsSection by joining the live `role_permissions` matrix against each user's role — no per-user override table yet (deliberate).
+- ResizableModal is the new canonical pattern; other Norma modals can adopt it by swapping their outer wrapper.
+
+### Pending
+
+- 50+ other Norma modals haven't been retrofitted to ResizableModal — fan-out is mechanical, can be done incrementally.
+- `tier_credentials` audit log (who-changed-what for `is_active` / `role`) — currently relies on `audit_events` table; could add a credential-specific event type.
+- Per-user permission overrides (`tier_credentials.permissions JSONB`) — not built; matrix is role-level only.
+
+### Commits
+
+- `f6b2dba` — feat(auth): intern role + user fields + admin-editable role × feature permissions matrix
+- `d433048` — feat(modals): shared ResizableModal with full-screen toggle + drag-resize
diff --git a/docs/sessions/tasks-requested.md b/docs/sessions/tasks-requested.md
index bd8b027..3cc4d10 100644
--- a/docs/sessions/tasks-requested.md
+++ b/docs/sessions/tasks-requested.md
@@ -69,3 +69,14 @@ Track every task the user has requested, regardless of completion status.
 | 39 | **CompareModal demo seed** — write `db/seed-demo-versions.sql` to insert a second version row for one existing draft so the Compare modal can be captured live in future demo runs (it requires ≥2 saved versions of the same draft) | PENDING |
 | 40 | **ScheduleModal capture path** — document in CLAUDE.md or component header that ScheduleModal is reused across Pipeline/Grants/Petitions/Statements; Pipeline path needs an `approved` `pipeline_items` row, Grants is the canonical demo path | PENDING |
 | 41 | **Org-selector gate in CLAUDE.md** — login flow has an extra "Select an organization" step (auto-selects SDCC) that wasn't in the wakeup brief; add it to the project README + CLAUDE.md so future Playwright / demo work doesn't trip on it | PENDING |
+
+## 2026-05-20 (PT) — ease-of-use session
+
+| # | Request | Status |
+|---|---------|--------|
+| 42 | Three-tier login: Admin / User / Intern with admin-editable privileges | DONE |
+| 43 | Add `full_name` / `email` to tier_credentials and surface in UI | DONE |
+| 44 | Collapsible per-user permissions table in Users & Roles | DONE |
+| 45 | Seed 4 SDCC users: Natalia + SabrinaAshley (admin), sabrinacalazans (user), Ryan (intern) | DONE |
+| 46 | Modal should be resizable (drag handle) + full-width toggle | DONE (CredentialsSection done; fleet retrofit pending) |
+| 47 | Retrofit remaining 50+ modals to ResizableModal | PENDING |

← 68f7b63 feat(mockups): 6 inbox UI variants — Classic Dense / Notion  ·  back to Norma  ·  feat(inbox): 10 more theme mockups (v7-v16) + live theme dro 93205f4 →