Phase 10 — Admin Role & Settings (ADMIN-01/02/03) #17

Merged
luckberg merged 25 commits from gsd/phase-10-admin-role-settings into main 2026-06-13 17:10:47 -04:00
Owner

Phase 10 — Admin Role & Settings (ADMIN-01/02/03)

Role-gated admin surface to rotate member Fastmail app passwords and designate the shared calendar, on a new v1.1 DB foundation. 4 plans, executed as a sequential wave chain.

What shipped

  • 10-01 — v1.1 DB foundation: additive generate+migrate migration (0001_famous_mad_thinker.sql) adding users.is_admin, member_credentials.provider_type + unique(user_id), calendar_events.reminder_lead_minutes, and an app_config table. Dev-bypass user id=1 seeded admin in e2e global-setup.
  • 10-02 — Server admin primitives (TDD): DB-backed requireAdmin middleware, first-login-wins is_admin bootstrap, /api/me exposing isAdmin + needsProviderSetup (dev-bypass + OIDC paths).
  • 10-03 — Admin API surface (TDD): adminRouter (requireAdmin-gated) members/credential-rotation/calendars/shared-designation; member self-service POST /api/me/credential; one shared validateEncryptAndStoreCredential helper (400-no-echo, session-userId only); broker resync helpers promoted to exports.
  • 10-04 — PWA admin surfaces: gated /admin route + conditional nav (AppNav/BottomTabBar), shared CredentialSheet, SetupBanner, e2e/admin.spec.ts. Client isAdmin is UX-only; server 403 is the boundary (D-03).

Code review + fix

Code review (10-REVIEW.md) found 1 BLOCKER (CR-01), fixed in this PR (2f347cb): PUT /api/admin/calendars/:id/shared cleared the current shared calendar then set the target in two non-transactional UPDATEs without checking the target exists — a bad/stale id silently wiped the family shared lane. Now verifies the target inside a transaction and returns 404 otherwise (regression test added, RED→GREEN).

Verification

  • gsd-verifier: 12/12 must-haves PASS.
  • Local: api 271/271, pwa unit 191/191, e2e admin.spec.ts 5/5 (desktop Chromium); typecheck (both apps) + builds clean; lint / format:check / md:lint / frozen-lockfile all green.
  • Live dev MariaDB confirmed to hold all v1.1 columns/table via real query (not just tsc).

Follow-ups (deferred, non-blocking)

  • WR-01 first-login-wins bootstrap is a non-atomic count-then-insert race — deferred to Phase 12, which reworks this bootstrap.
  • WR-02 /api/me coalesces missing OIDC iss/sub to '' (defense-in-depth).
  • WR-03 the additive unique(user_id) would error 1062 on a prod env already holding duplicate credential rows (deploy-time check).
  • Audit gap (IN-01): apps/api/src/broker/credentialSync.ts and apps/pwa/src/components/CredentialSheet.tsx sit in a sandbox-denied dir and were not directly read in review; their encrypt-at-rest + no-echo contracts are covered by passing integration tests. Recommend a crypto re-audit with full read access.
  • Migration deviation (10-01): local db:migrate hit a legacy __drizzle_migrations journal-hash mismatch (local dev-DB artifact), so DDL was applied directly + the hash recorded; fresh CI DB applies 0000+0001 cleanly.

🤖 Generated with Claude Code

## Phase 10 — Admin Role & Settings (ADMIN-01/02/03) Role-gated admin surface to rotate member Fastmail app passwords and designate the shared calendar, on a new v1.1 DB foundation. 4 plans, executed as a sequential wave chain. ### What shipped - **10-01 — v1.1 DB foundation:** additive generate+migrate migration (`0001_famous_mad_thinker.sql`) adding `users.is_admin`, `member_credentials.provider_type` + `unique(user_id)`, `calendar_events.reminder_lead_minutes`, and an `app_config` table. Dev-bypass user id=1 seeded admin in e2e global-setup. - **10-02 — Server admin primitives (TDD):** DB-backed `requireAdmin` middleware, first-login-wins `is_admin` bootstrap, `/api/me` exposing `isAdmin` + `needsProviderSetup` (dev-bypass + OIDC paths). - **10-03 — Admin API surface (TDD):** `adminRouter` (requireAdmin-gated) members/credential-rotation/calendars/shared-designation; member self-service `POST /api/me/credential`; one shared `validateEncryptAndStoreCredential` helper (400-no-echo, session-userId only); broker resync helpers promoted to exports. - **10-04 — PWA admin surfaces:** gated `/admin` route + conditional nav (AppNav/BottomTabBar), shared `CredentialSheet`, `SetupBanner`, `e2e/admin.spec.ts`. Client `isAdmin` is UX-only; server 403 is the boundary (D-03). ### Code review + fix Code review (10-REVIEW.md) found **1 BLOCKER (CR-01)**, fixed in this PR (2f347cb): `PUT /api/admin/calendars/:id/shared` cleared the current shared calendar then set the target in two non-transactional UPDATEs **without checking the target exists** — a bad/stale id silently wiped the family shared lane. Now verifies the target inside a transaction and returns 404 otherwise (regression test added, RED→GREEN). ### Verification - gsd-verifier: **12/12 must-haves PASS**. - Local: api 271/271, pwa unit 191/191, e2e admin.spec.ts 5/5 (desktop Chromium); typecheck (both apps) + builds clean; lint / format:check / md:lint / frozen-lockfile all green. - Live dev MariaDB confirmed to hold all v1.1 columns/table via real query (not just tsc). ### Follow-ups (deferred, non-blocking) - **WR-01** first-login-wins bootstrap is a non-atomic count-then-insert race — deferred to Phase 12, which reworks this bootstrap. - **WR-02** `/api/me` coalesces missing OIDC iss/sub to '' (defense-in-depth). - **WR-03** the additive `unique(user_id)` would error 1062 on a prod env already holding duplicate credential rows (deploy-time check). - **Audit gap (IN-01):** `apps/api/src/broker/credentialSync.ts` and `apps/pwa/src/components/CredentialSheet.tsx` sit in a sandbox-denied dir and were not directly read in review; their encrypt-at-rest + no-echo contracts are covered by passing integration tests. Recommend a crypto re-audit with full read access. - **Migration deviation (10-01):** local `db:migrate` hit a legacy `__drizzle_migrations` journal-hash mismatch (local dev-DB artifact), so DDL was applied directly + the hash recorded; fresh CI DB applies 0000+0001 cleanly. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
luckberg added 24 commits 2026-06-13 15:49:43 -04:00
- users.isAdmin: boolean NOT NULL DEFAULT false (first-login-wins admin flag, D-01)
- memberCredentials.providerType: varchar(64) NOT NULL DEFAULT 'caldav' (generic provider discriminator, D-04)
- memberCredentials: UNIQUE(user_id) constraint for one-credential-per-member + upsert support (D-05)
- calendarEvents.reminderLeadMinutes: int nullable (created now, consumed by Phase 11)
- appConfig table: key VARCHAR PK, value TEXT, updated_at (setup_complete consumed by Phase 12)
- Generated via drizzle-kit generate from updated schema.ts
- SQL is additive-only: CREATE TABLE app_config + ALTER TABLE ADD COLUMN (3x) + ADD CONSTRAINT UNIQUE
- Applied to live dev MariaDB via direct SQL execution (drizzle-kit migrate journal hash mismatch
  with legacy migration tracking; DDL applied + hash recorded in __drizzle_migrations manually)
- Verified: MIGRATION OK via live DB SHOW COLUMNS / SHOW TABLES query
- No DROP/TRUNCATE statements in generated SQL (grep returns 0)
- INSERT INTO users (id=1, is_admin=true) ON DUPLICATE KEY UPDATE is_admin=true (idempotent)
- Supplies placeholder non-null oidc_iss='dev-bypass', oidc_sub='dev-user-1', color='#4A90D9'
- requireAdmin (Plan 02) does a DB lookup for the bypass user; without this seed it would 403
- Existing calendar/event/list seeds unchanged (INSERT IGNORE INTO calendars, Seeded Test Event)
- 403 for non-admin user (is_admin=false in DB)
- next() called for admin user (is_admin=true in DB)
- 403 when no user on context (no DB query)
- 403 when context user spoofs isAdmin=true but DB has is_admin=false (T-10-04)
- reads users.isAdmin from DB (never trusts context user's isAdmin claim)
- 403 with { error: 'Forbidden' } for non-admins and missing user
- side-effect import of devBypass.js for ContextVariableMap augmentation
- bypass path skips OIDC only, not the DB check (T-10-04/T-10-05)
- first user with zero admins → is_admin=true in INSERT values
- subsequent user with admin present → is_admin=false in INSERT values
- existing user re-upsert → is_admin unchanged (early-return path, no insert)
- update existing color tests to accommodate new 4-select flow order
- zero-admin COUNT check before INSERT: first user gets is_admin=true
- subsequent users (admin already exists) get is_admin=false
- existing-user early-return path unchanged (is_admin not modified)
- Phase-12 hook comment: tighten to first login after app_config.setup_complete
- adds 'import { sql }' from drizzle-orm
- dev-bypass path: isAdmin from DB (not hardcoded), needsProviderSetup from member_credentials
- needsProviderSetup=true when no member_credentials row exists
- needsProviderSetup=false when member_credentials row exists
- dev-bypass path: DB lookup for users.isAdmin (T-10-05 bypass skips OIDC not DB)
- OIDC path: same resolveAdminAndSetupStatus helper after upsertUser
- needsProviderSetup: true when no member_credentials row, false when one exists
- no /api/me/credential POST added here (Plan 03)
- Add export keyword to loadClientForUser (line 271)
- Add export keyword to triggerTargetedResync (line 302)
- Function bodies unchanged (no behavior change, no node-cron reintroduced)
- Enables credentialSync.ts shared helper to call post-credential full-member sync
RED phase: all admin.test.ts tests fail (404 — routes/mounts not yet created).
Tests cover:
- T-10-08 Pitfall 9: 403 for non-admin on every /api/admin/* route
- T-10-09 Pitfall 7: 400 with no echoed password for all credential failure modes
  (PROPFIND/auth failure, createFastmailClient throw, network error, schema mismatch)
- T-10-11: valid credential stores encrypted (AES-256-GCM), not plaintext
- ADMIN-02: PUT /api/admin/calendars/:id/shared — exclusive is_shared=1
- T-10-12 Pitfall 6: POST /api/me/credential ignores body userId, writes to session user
- D-07: non-admin member can POST /api/me/credential (no requireAdmin on self-service)
credentialSync.ts:
- validateEncryptAndStoreCredential(userId, email, appPassword, providerType) — single
  shared validate→encrypt→store→initial-sync path used by BOTH admin and self-service
- createFastmailClient + fetchCalendars wrapped in ONE try/catch: any failure throws
  CredentialValidationError (routes map to { error: 'Invalid request' } 400)
- appPassword never logged or echoed (T-10-10)
- encryptPassword (AES-256-GCM) applied before DB write (T-10-11)
- fire-and-forget initial sync via loadClientForUser + syncCalendar (Pitfall 5)

admin.ts:
- adminRouter.use('*', requireAdmin) FIRST (Pitfall 9 / T-10-08)
- GET /members: users LEFT JOIN member_credentials → hasCredential boolean
- POST /credentials: noEchoHook + validateEncryptAndStoreCredential (T-10-09)
- GET /calendars: calendar list (UI-SPEC Surface 5)
- PUT /calendars/:id/shared: exclusive is_shared update (ADMIN-02, D-06)

index.ts:
- app.route('/api/admin', adminRouter) mounted in route block

me.ts:
- POST /credential: member self-service, always currentUserId (Pitfall 6 / T-10-12)
- meCredentialSchema (no userId field), meNoEchoHook, calls shared helper
- All 17 new admin tests pass; 270 total pass; tsc --noEmit clean
- Add isAdmin and needsProviderSetup to MeUser interface (D-03 UX gating)
- Add AdminMember, SaveCredentialPayload, AdminCalendar, SaveMyCredentialPayload types
- Add fetchAdminMembers, saveCredential, fetchAdminCalendars, setSharedCalendar fetchers
- Add saveMyCredential (self-service, no userId field — T-10-12/Pitfall 6)
- All fetchers use credentials:'include', redirect:'manual', handleAuthResponse
- Password never logged or stored beyond in-flight request body (T-10-15)
- CredentialSheet: admin-rotate/admin-add/self-service modes, role=dialog, aria-modal
- Password field type=password autoComplete=new-password, never pre-filled (T-10-16)
- Fastmail link target=_blank rel=noopener noreferrer (UI-SPEC Surface 3)
- Loader2 spinner + CalDAV failure copy on mutation error
- Success invalidates ['admin','members'] + ['me'] → SetupBanner unmounts
- Escape closes, focus returns to trigger (a11y)
- SetupBanner: renders on needsProviderSetup=true only, role=status aria-live=polite
- KeyRound icon, 'Set up your calendar' heading, 'Set up now' CTA (no X/dismiss)
- Success-only dismissal: ['me'] invalidation is the ONLY code path to hide the banner
- All styling via var(--token); 44px touch targets throughout
- AdminPage: Admin Settings heading, MEMBERS section (avatar+status+action), SHARED CALENDAR radio group + two-tap Save + empty state
- App.tsx: /admin route gated by meQuery.data.user.isAdmin (loading gate prevents flash), SetupBanner mounted above content, BottomTabBar + AppNav receive isAdmin
- AppNav.tsx: ShieldCheck Admin nav entry rendered only when isAdmin=true (D-03 UX gating)
- BottomTabBar.tsx: ShieldCheck Admin tab rendered only when isAdmin=true (D-03 UX gating)
- e2e/admin.spec.ts: 5 assertions across 3 profiles (15 total tests) — admin sees nav+page+members, non-admin: no nav entry + /admin redirects to /calendar
- All 15 e2e tests pass (iphone/pixel/desktop); production build clean
- Run prettier on all new/modified PWA files (CredentialSheet, SetupBanner, AdminPage, admin.spec.ts)
- Remove unnecessary 'as React.RefObject<HTMLElement | null>' casts flagged by @typescript-eslint/no-unnecessary-type-assertion
- Format pre-existing API files from Plans 02/03 (me.ts, user.test.ts, requireAdmin.test.ts, me.test.ts)
- All 270 API tests + 191 PWA vitest tests pass; lint/typecheck/build clean
- SUMMARY.md for Plan 04 (AdminPage, CredentialSheet, SetupBanner, nav gating)
- STATE.md: plan advanced to last, progress 100%, decisions recorded
- ROADMAP.md: Phase 10 marked Complete (4/4 summaries)
PUT /api/admin/calendars/:id/shared cleared the current shared calendar then
set the target in two non-transactional UPDATEs without checking the target
exists — a bad/stale id wiped the family shared lane and still returned ok.
Verify the target inside a transaction; return 404 when absent. Adds a
regression test (RED→GREEN).
docs(phase-10): evolve PROJECT.md after phase completion
CI / changes (pull_request) Successful in 2s
CI / fast-checks (pull_request) Successful in 1m25s
CI / api (pull_request) Successful in 1m3s
CI / harness (pull_request) Successful in 4m17s
CI / security (pull_request) Successful in 39s
CI / gate (pull_request) Successful in 1s
2067b8c91c
luckberg added 1 commit 2026-06-13 16:50:09 -04:00
Merge branch 'main' into gsd/phase-10-admin-role-settings
CI / changes (pull_request) Successful in 2s
CI / fast-checks (pull_request) Successful in 1m26s
CI / api (pull_request) Successful in 1m3s
CI / harness (pull_request) Successful in 4m11s
CI / security (pull_request) Successful in 39s
CI / gate (pull_request) Successful in 1s
f2e228360a
luckberg merged commit 6f566f23a2 into main 2026-06-13 17:10:47 -04:00
luckberg deleted branch gsd/phase-10-admin-role-settings 2026-06-13 17:10:47 -04:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: luckberg/familysync#17