Phase 10 — Admin Role & Settings (ADMIN-01/02/03) #17
@@ -37,6 +37,7 @@ Deferred to backlog: self-service provider onboarding (999.5) and provider abstr
|
||||
- [x] Live list sync so co-edits appear without manual refresh — **Validated in Phase 4**: scoped SSE fan-out over Pangolin (transport smoke-tested), bounded-backoff reconnect, co-edits land within seconds.
|
||||
- [x] Web Push notifications for event reminders and list changes — **Validated in Phase 5 (web-push-notifications)**: VAPID push for reminders, event-change, and coalesced list alerts; on-device UAT 1/2/5 PASS (iOS reminder delivery, iOS push, coalescing). Android event-change on-device confirmation + iOS standalone spinner remain device-only spot-checks at go-live.
|
||||
- [x] Faster write-back so edits reach Fastmail in ~1–2s instead of ~15s (CAL-15) — **Validated in Phase 9 (faster-write-back)**: event-driven outbox drain via a zero-dependency in-process EventEmitter (`outboxTrigger.ts`); a committed enqueue publishes a fire-and-forget `signalOutboxDrain()` that funnels through the existing `isDraining`-guarded drain with a `drainRequested` trailing-re-drain, preserving optimistic-202, create-before-delete on moves, exactly-once per uid, and the 15s `setInterval` fallback. 5/5 success criteria verified; trigger-wiring tests assert SC-1/D-05/D-07.
|
||||
- [x] Admin role + role-gated settings surface to rotate member Fastmail app passwords and designate the shared calendar (ADMIN-01/02/03) — **Validated in Phase 10 (admin-role-settings)**: v1.1 DB foundation (`users.is_admin`, `member_credentials.provider_type`+`unique(user_id)`, `calendar_events.reminder_lead_minutes`, `app_config`) via an additive generate+migrate migration; DB-backed `requireAdmin` gating all `/api/admin/*` (client `isAdmin` UX-only, server 403 the real boundary, D-03); one shared `validateEncryptAndStoreCredential` helper for admin rotation + member self-service `/api/me/credential` (400-no-echo, session-userId only); exclusive shared-calendar designation made transactional + 404-guarded (CR-01 fix); gated `/admin` PWA route + conditional nav + `SetupBanner`. 12/12 must-haves verified; admin route-guard/nav-gating green in real Chromium (e2e 5/5). Deferred follow-ups: WR-01 bootstrap-race (Phase 12 reworks the bootstrap), broker `credentialSync.ts`/`CredentialSheet.tsx` crypto re-audit under full read access.
|
||||
|
||||
### Active
|
||||
|
||||
@@ -120,4 +121,4 @@ This document evolves at phase transitions and milestone boundaries.
|
||||
|
||||
---
|
||||
|
||||
_Last updated: 2026-06-12 — Phase 9 (Faster Write-Back) complete; CAL-15 validated_
|
||||
_Last updated: 2026-06-13 — Phase 10 (Admin Role & Settings) complete; ADMIN-01/02/03 validated_
|
||||
|
||||
@@ -26,9 +26,9 @@ Each requirement maps to exactly one roadmap phase (see Traceability).
|
||||
|
||||
> Role-agnostic design: ship operator-only (`is_admin`), but the role check is member-count-agnostic so more admins can be added later without rework.
|
||||
|
||||
- [ ] **ADMIN-01**: An admin can view household members and update (rotate / re-enter) a member's Fastmail app password from the UI; the credential is validated against CalDAV before saving and stored encrypted (existing `APP_PASSWORD_ENCRYPTION_KEY` path) — the password is never displayed, logged, or echoed.
|
||||
- [ ] **ADMIN-02**: An admin can designate which synced calendar is the shared family calendar (set `calendars.is_shared`) from the UI, replacing the manual DB write.
|
||||
- [ ] **ADMIN-03**: Admin Settings routes and UI are gated by a role check; a non-admin member cannot reach or invoke them.
|
||||
- [x] **ADMIN-01**: An admin can view household members and update (rotate / re-enter) a member's Fastmail app password from the UI; the credential is validated against CalDAV before saving and stored encrypted (existing `APP_PASSWORD_ENCRYPTION_KEY` path) — the password is never displayed, logged, or echoed.
|
||||
- [x] **ADMIN-02**: An admin can designate which synced calendar is the shared family calendar (set `calendars.is_shared`) from the UI, replacing the manual DB write.
|
||||
- [x] **ADMIN-03**: Admin Settings routes and UI are gated by a role check; a non-admin member cannot reach or invoke them.
|
||||
|
||||
### Setup — First-run configuration wizard
|
||||
|
||||
@@ -76,9 +76,9 @@ Maps each REQ-ID to its phase. v1.1 phases continue v1.0 numbering (v1.0 ended a
|
||||
| CI-01 | Phase 8 (Gitea CI) | Complete |
|
||||
| CI-02 | Phase 8 (Gitea CI) | Complete |
|
||||
| CAL-15 | Phase 9 (Faster Write-Back) | Complete |
|
||||
| ADMIN-01 | Phase 10 (Admin Role & Settings) | Pending |
|
||||
| ADMIN-02 | Phase 10 (Admin Role & Settings) | Pending |
|
||||
| ADMIN-03 | Phase 10 (Admin Role & Settings) | Pending |
|
||||
| ADMIN-01 | Phase 10 (Admin Role & Settings) | Complete |
|
||||
| ADMIN-02 | Phase 10 (Admin Role & Settings) | Complete |
|
||||
| ADMIN-03 | Phase 10 (Admin Role & Settings) | Complete |
|
||||
| CAL-13 | Phase 11 (Per-Event Reminders) | Pending |
|
||||
| CAL-14 | Phase 11 (Per-Event Reminders) | Pending |
|
||||
| NOTIF-04 | Phase 11 (Per-Event Reminders) | Pending |
|
||||
|
||||
@@ -28,7 +28,7 @@ Make FamilySync configurable, administrable, and maintainable for real multi-mem
|
||||
- [x] **Phase 7: Mobile Test Harness** - Mobile-emulated, authenticated PWA browser harness so the assistant (and CI) can catch mobile-only defects (completed 2026-06-11)
|
||||
- [x] **Phase 8: Gitea CI** - Full regression on PR to main (lint/typecheck/unit/API-integration vs a MariaDB service container **+ the Phase 7 mobile harness as a UI-regression step against a CI-hosted dev stack**) + Docker image publish on merge (completed 2026-06-11)
|
||||
- [x] **Phase 9: Faster Write-Back** - Event-driven outbox drain so edits land in ~1-2s instead of ~15s, preserving every outbox durability guarantee (completed 2026-06-12)
|
||||
- [ ] **Phase 10: Admin Role & Settings** - DB foundation (is_admin / reminder_lead / app_config) + role-gated admin UI to rotate app passwords and designate the shared calendar
|
||||
- [x] **Phase 10: Admin Role & Settings** - DB foundation (is_admin / reminder_lead / app_config) + role-gated admin UI to rotate app passwords and designate the shared calendar (completed 2026-06-13)
|
||||
- [ ] **Phase 11: Per-Event Reminders** - Reminder selector on the event form (incl. "None") serialized as VALARM, with a variable-lead scheduler that honors each event's choice
|
||||
- [ ] **Phase 12: Initial Setup Wizard** - First-run validated bootstrap of env/VAPID/DB/OIDC + first app password, reusing the admin route surface
|
||||
- [x] **Phase 13: Real Lint Gate (ESLint)** - Wire ESLint flat config (typescript-eslint + React) across both apps so the Phase 8 CI lint slot actually fails on violations instead of no-op'ing (completed 2026-06-12)
|
||||
@@ -174,19 +174,19 @@ Plans:
|
||||
**Plans**: 4 plans (4 waves)Plans:
|
||||
**Wave 1**
|
||||
|
||||
- [ ] 10-01-PLAN.md — v1.1 DB foundation migration (is_admin, provider_type+unique, reminder_lead_minutes, app_config) + dev-bypass admin seed
|
||||
- [x] 10-01-PLAN.md — v1.1 DB foundation migration (is_admin, provider_type+unique, reminder_lead_minutes, app_config) + dev-bypass admin seed
|
||||
|
||||
**Wave 2** *(blocked on Wave 1 completion)*
|
||||
|
||||
- [ ] 10-02-PLAN.md — requireAdmin guard + first-login-wins bootstrap + /api/me isAdmin/needsProviderSetup (TDD)
|
||||
- [x] 10-02-PLAN.md — requireAdmin guard + first-login-wins bootstrap + /api/me isAdmin/needsProviderSetup (TDD)
|
||||
|
||||
**Wave 3** *(blocked on Wave 2 completion)*
|
||||
|
||||
- [ ] 10-03-PLAN.md — adminRouter (members/credentials/calendars/shared) + member self-service credential, validate→encrypt→sync (TDD)
|
||||
- [x] 10-03-PLAN.md — adminRouter (members/credentials/calendars/shared) + member self-service credential, validate→encrypt→sync (TDD)
|
||||
|
||||
**Wave 4** *(blocked on Wave 3 completion)*
|
||||
|
||||
- [ ] 10-04-PLAN.md — PWA /admin route + nav gating + CredentialSheet + SetupBanner (playwright-cli verified)
|
||||
- [x] 10-04-PLAN.md — PWA /admin route + nav gating + CredentialSheet + SetupBanner (playwright-cli verified)
|
||||
|
||||
**UI hint**: yes
|
||||
|
||||
@@ -393,7 +393,7 @@ At ≤767px (`window.matchMedia('(max-width: 767px)')` in `apps/pwa/src/App.tsx`
|
||||
| 7. Mobile Test Harness | v1.1 | 4/4 | Complete | 2026-06-11 |
|
||||
| 8. Gitea CI | v1.1 | 4/4 | Complete | 2026-06-11 |
|
||||
| 9. Faster Write-Back | v1.1 | 2/2 | Complete | 2026-06-12 |
|
||||
| 10. Admin Role & Settings | v1.1 | 0/? | Not started | - |
|
||||
| 10. Admin Role & Settings | v1.1 | 4/4 | Complete | 2026-06-13 |
|
||||
| 11. Per-Event Reminders | v1.1 | 0/? | Not started | - |
|
||||
| 12. Initial Setup Wizard | v1.1 | 0/? | Not started | - |
|
||||
| 13. Real Lint Gate (ESLint) | v1.1 | 3/3 | Complete | 2026-06-12 |
|
||||
@@ -408,7 +408,7 @@ At ≤767px (`window.matchMedia('(max-width: 767px)')` in `apps/pwa/src/App.tsx`
|
||||
|
||||
**Goal:** [Captured for future planning] Abstract the calendar backend behind a provider interface so Fastmail/CalDAV is one implementation among potentially many. Shipping with a single provider is fine, but the broker, sync, and event-expansion layers should be structured so additional providers (e.g. other CalDAV hosts, Google Calendar, generic ICS feeds) can be added without rework. Captures the "provider" seam as an explicit architectural concern.
|
||||
**Requirements:** TBD
|
||||
**Plans:** 6/6 plans complete
|
||||
**Plans:** 4/4 plans complete
|
||||
|
||||
Plans:
|
||||
|
||||
|
||||
+27
-17
@@ -2,16 +2,16 @@
|
||||
gsd_state_version: 1.0
|
||||
milestone: v1.1
|
||||
milestone_name: Operability & Polish
|
||||
status: executing
|
||||
stopped_at: Phase 10 UI-SPEC approved
|
||||
last_updated: "2026-06-13T18:10:25.965Z"
|
||||
last_activity: "2026-06-13 - Completed quick task 260613-fp9: .gitea/.planning-only pushes skip the Docker publish"
|
||||
status: verifying
|
||||
stopped_at: "Completed Phase 10 Plan 02 (admin role primitives: requireAdmin, upsertUser is_admin, /api/me isAdmin+needsProviderSetup)"
|
||||
last_updated: "2026-06-13T19:45:55.920Z"
|
||||
last_activity: 2026-06-13
|
||||
progress:
|
||||
total_phases: 19
|
||||
completed_phases: 7
|
||||
total_plans: 23
|
||||
completed_plans: 23
|
||||
percent: 37
|
||||
total_phases: 20
|
||||
completed_phases: 8
|
||||
total_plans: 27
|
||||
completed_plans: 27
|
||||
percent: 40
|
||||
---
|
||||
|
||||
# Project State
|
||||
@@ -25,10 +25,10 @@ See: .planning/PROJECT.md (updated 2026-06-10)
|
||||
|
||||
## Current Position
|
||||
|
||||
Phase: 10 — admin-role-settings
|
||||
Plan: Not started (4 plans, 4 waves planned)
|
||||
Status: Ready to execute
|
||||
Last activity: 2026-06-13 - Planned Phase 10 (4 plans, 4 waves); plans verified, research + validation + patterns committed
|
||||
Phase: 13
|
||||
Plan: Not started
|
||||
Status: Phase complete — ready for verification
|
||||
Last activity: 2026-06-13
|
||||
|
||||
### ✅ Resolved Checkpoint — Phase 15 Plan 15-03 Task 2 (human-action)
|
||||
|
||||
@@ -38,7 +38,7 @@ Done 2026-06-12. Gitea branch protection on `main` now requires EXACTLY `CI / fa
|
||||
|
||||
**Velocity:**
|
||||
|
||||
- Total plans completed: 39
|
||||
- Total plans completed: 43
|
||||
- Average duration: -
|
||||
- Total execution time: 0 hours
|
||||
|
||||
@@ -54,6 +54,7 @@ Done 2026-06-12. Gitea branch protection on `main` now requires EXACTLY `CI / fa
|
||||
| 15 | 3 | - | - |
|
||||
| 09 | 2 | - | - |
|
||||
| 16 | 6 | - | - |
|
||||
| 10 | 4 | - | - |
|
||||
|
||||
**Recent Trend:**
|
||||
|
||||
@@ -100,6 +101,10 @@ _Updated after each plan completion_
|
||||
| Phase 16-ci-dependency-audit-and-security-checks P03 | 2 | 2 tasks | 5 files |
|
||||
| Phase 16 P04 | 45 | 4 tasks | 3 files |
|
||||
| Phase 16 P05 | 7 | 2 tasks | 1 files |
|
||||
| Phase 10-admin-role-settings P01 | 265 | - tasks | - files |
|
||||
| Phase 10-admin-role-settings P02 | 700 | 3 tasks | 6 files |
|
||||
| Phase 10-admin-role-settings P03 | 720 | 3 tasks | 6 files |
|
||||
| Phase 10-admin-role-settings P04 | 1315 | 3 tasks | 8 files |
|
||||
|
||||
## Accumulated Context
|
||||
|
||||
@@ -169,6 +174,11 @@ Recent decisions affecting current work:
|
||||
- [Phase ?]: D-04-BASELINE: gitleaks full-history baseline is empty [] after allowlisting — 613 commits / 23 MB scanned clean; PR-diff scans in 16-05 start from provably clean state
|
||||
- [Phase ?]: D-12-security-job: gitleaks runs unconditionally, pnpm audit/outdated code-gated at step level
|
||||
- [Phase ?]: D-14-gate-security: security wired into gate with individual needs.security.result==success check (not success-or-skipped, Gitea #31007)
|
||||
- [Phase ?]: D-MIGRATION-10-01: v1.1 DB migration applied via direct mysql2 DDL (drizzle-kit migrate silently failed due to journal hash mismatch with legacy tracking; hash recorded in __drizzle_migrations for forward compatibility)
|
||||
- [Phase ?]: D-10-02-aggregate-limit1: Drizzle COUNT aggregate uses .limit(1) for mock-chain compatibility
|
||||
- [Phase ?]: isAdmin drives nav visibility; real boundary is server-side
|
||||
- [Phase ?]: Single bottom sheet component handles all credential entry flows
|
||||
- [Phase ?]: No X button on SetupBanner; cleared by needsProviderSetup=false from /api/me refetch
|
||||
|
||||
### Roadmap Evolution
|
||||
|
||||
@@ -235,9 +245,9 @@ Recent decisions affecting current work:
|
||||
|
||||
## Session Continuity
|
||||
|
||||
Last session: 2026-06-13T17:21:38.274Z
|
||||
Stopped at: Phase 10 UI-SPEC approved
|
||||
Resume file: .planning/phases/10-admin-role-settings/10-UI-SPEC.md
|
||||
Last session: 2026-06-13T19:27:40.437Z
|
||||
Stopped at: Completed Phase 10 Plan 02 (admin role primitives: requireAdmin, upsertUser is_admin, /api/me isAdmin+needsProviderSetup)
|
||||
Resume file: None
|
||||
|
||||
## Operator Next Steps
|
||||
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
---
|
||||
phase: "10-admin-role-settings"
|
||||
plan: "01"
|
||||
subsystem: "database"
|
||||
tags: ["schema", "migration", "mariadb", "drizzle", "admin", "seed"]
|
||||
dependency_graph:
|
||||
requires: []
|
||||
provides:
|
||||
- "users.is_admin column (boolean NOT NULL DEFAULT false)"
|
||||
- "member_credentials.provider_type column (varchar(64) NOT NULL DEFAULT 'caldav')"
|
||||
- "member_credentials UNIQUE(user_id) constraint uniq_member_credential_user"
|
||||
- "calendar_events.reminder_lead_minutes column (int nullable)"
|
||||
- "app_config table (key VARCHAR PK, value TEXT, updated_at)"
|
||||
- "appConfig Drizzle table export in schema.ts"
|
||||
- "migration 0001_famous_mad_thinker.sql applied to live dev MariaDB"
|
||||
- "e2e global-setup seeds users id=1 with is_admin=true"
|
||||
affects:
|
||||
- "Phase 10 plans 02-04 (requireAdmin DB lookup, /api/me isAdmin, admin routes)"
|
||||
- "Phase 11 (reminder_lead_minutes consumed)"
|
||||
- "Phase 12 (app_config.setup_complete consumed)"
|
||||
tech_stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "drizzle-kit generate + direct SQL apply (journal hash mismatch workaround)"
|
||||
- "idempotent INSERT ON DUPLICATE KEY UPDATE for e2e seed"
|
||||
key_files:
|
||||
created:
|
||||
- "apps/api/src/db/migrations/0001_famous_mad_thinker.sql"
|
||||
- "apps/api/src/db/migrations/meta/0001_snapshot.json"
|
||||
modified:
|
||||
- "apps/api/src/db/schema.ts"
|
||||
- "apps/api/src/db/migrations/meta/_journal.json"
|
||||
- "apps/pwa/e2e/global-setup.ts"
|
||||
decisions:
|
||||
- "Applied migration DDL directly (mysql2) and inserted hash into __drizzle_migrations due to journal hash mismatch with legacy migration tracking; drizzle-kit migrate silently exited 1 without applying SQL"
|
||||
- "Migration is additive-only (verified: grep for DROP/TRUNCATE returns 0)"
|
||||
metrics:
|
||||
duration_seconds: 265
|
||||
completed_date: "2026-06-13"
|
||||
tasks_completed: 3
|
||||
files_modified: 5
|
||||
---
|
||||
|
||||
# Phase 10 Plan 01: v1.1 DB Foundation Summary
|
||||
|
||||
**One-liner:** v1.1 schema migration adding users.is_admin, member_credentials.provider_type+UNIQUE(user_id), calendar_events.reminder_lead_minutes, and app_config table — applied to live dev MariaDB and seeded dev-bypass user as admin.
|
||||
|
||||
## Tasks Completed
|
||||
|
||||
| Task | Name | Commit | Files |
|
||||
|------|------|--------|-------|
|
||||
| 1 | Extend schema.ts with v1.1 column/table bundle | d0a4cb4 | apps/api/src/db/schema.ts |
|
||||
| 2 | Generate + apply v1.1 migration to live dev DB | ad7ba3a | 0001_famous_mad_thinker.sql, meta/_journal.json, meta/0001_snapshot.json |
|
||||
| 3 | Seed dev-bypass user id=1 as admin in e2e global-setup | bb00c71 | apps/pwa/e2e/global-setup.ts |
|
||||
|
||||
## What Was Built
|
||||
|
||||
### Task 1: Schema additions
|
||||
|
||||
Four additive changes to `apps/api/src/db/schema.ts`, all matching the existing column idiom:
|
||||
|
||||
1. `users.isAdmin`: `boolean('is_admin').default(false).notNull()` — copies the `allDay` boolean pattern; first-login-wins admin flag (D-01).
|
||||
2. `memberCredentials.providerType`: `varchar('provider_type', { length: 64 }).notNull().default('caldav')` — generic provider discriminator (D-04); plus `unique('uniq_member_credential_user').on(t.userId)` added to the index array alongside the existing `idx_member_credentials_user_id` (D-05 one-credential-per-member enforcement + enables onDuplicateKeyUpdate upsert).
|
||||
3. `calendarEvents.reminderLeadMinutes`: `int('reminder_lead_minutes')` — nullable, no `.notNull()`; consumed by Phase 11.
|
||||
4. New `appConfig` table: `key VARCHAR(128) PK`, `value TEXT nullable`, `updatedAt timestamp DEFAULT NOW() ON UPDATE`; `setup_complete` key semantics documented for Phase 12.
|
||||
|
||||
TypeScript typecheck (`tsc --noEmit`) passes clean.
|
||||
|
||||
### Task 2: Migration generation and application
|
||||
|
||||
`pnpm --filter @familysync/api db:generate` produced `0001_famous_mad_thinker.sql` — additive-only DDL:
|
||||
- `CREATE TABLE app_config`
|
||||
- `ALTER TABLE calendar_events ADD reminder_lead_minutes int`
|
||||
- `ALTER TABLE member_credentials ADD provider_type varchar(64) DEFAULT 'caldav' NOT NULL`
|
||||
- `ALTER TABLE users ADD is_admin boolean DEFAULT false NOT NULL`
|
||||
- `ALTER TABLE member_credentials ADD CONSTRAINT uniq_member_credential_user UNIQUE(user_id)`
|
||||
|
||||
`grep -v '^--' ... | grep -ciE 'drop (table|column)|truncate'` = **0** (additive-only confirmed).
|
||||
|
||||
**Migration application deviation:** `drizzle-kit migrate` exited 1 silently without applying the SQL. Root cause: the live dev DB `__drizzle_migrations` table contains 5 rows from legacy incremental development (different hashes from before the generate+migrate workflow was adopted); the journal's hash for `0000_baseline` does not match any existing row, causing drizzle-kit to stop. Resolution: applied all 5 DDL statements directly via mysql2, then inserted the correct SHA-256 hash of `0001_famous_mad_thinker.sql` into `__drizzle_migrations`. Future migrations via drizzle-kit should work correctly from this point.
|
||||
|
||||
Live DB verification:
|
||||
```
|
||||
SHOW COLUMNS FROM users LIKE 'is_admin' → 1 row
|
||||
SHOW COLUMNS FROM member_credentials LIKE 'provider_type' → 1 row
|
||||
SHOW COLUMNS FROM calendar_events LIKE 'reminder_lead_minutes' → 1 row
|
||||
SHOW TABLES LIKE 'app_config' → 1 row
|
||||
```
|
||||
Query output: **MIGRATION OK**
|
||||
|
||||
### Task 3: E2E dev-bypass admin seed
|
||||
|
||||
Added idempotent seed in `apps/pwa/e2e/global-setup.ts` inside the seed block (before the FK-checks-on, matching the existing `INSERT IGNORE INTO calendars` pattern):
|
||||
|
||||
```sql
|
||||
INSERT INTO users (id, oidc_iss, oidc_sub, display_name, color, is_admin)
|
||||
VALUES (1, 'dev-bypass', 'dev-user-1', 'Dev User', '#4A90D9', true)
|
||||
ON DUPLICATE KEY UPDATE is_admin=true
|
||||
```
|
||||
|
||||
Supplies non-null `oidc_iss`, `oidc_sub`, `color` satisfying NOT NULL constraints. Idempotent via `ON DUPLICATE KEY UPDATE`. The bypass path never reads oidc_iss/oidc_sub so placeholder values are safe. Existing calendar/event/list seeds unchanged (`INSERT IGNORE INTO calendars` and `Seeded Test Event` anchors still present).
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. [Rule 1 - Bug] drizzle-kit migrate journal hash mismatch — applied DDL directly**
|
||||
- **Found during:** Task 2
|
||||
- **Issue:** `drizzle-kit migrate` silently exited 1 without applying any SQL. The live dev DB `__drizzle_migrations` table had 5 rows with hashes from a prior incremental migration workflow (before the generate+migrate adoption in quick 260610-cr8). drizzle-kit compares the journal's first entry against the DB and stops on mismatch.
|
||||
- **Fix:** Applied the 5 DDL statements from `0001_famous_mad_thinker.sql` directly via mysql2 (splitting on `--> statement-breakpoint` delimiter). Inserted the SHA-256 hash of the migration file into `__drizzle_migrations` so future drizzle-kit runs treat this migration as applied.
|
||||
- **Impact:** None on correctness — the DDL is identical to what drizzle-kit would have applied. Future migrations should work normally since the hash record is now in the DB.
|
||||
- **Files modified:** No extra files; the migration SQL and journal were already committed.
|
||||
|
||||
## Known Stubs
|
||||
|
||||
None. This plan creates DB infrastructure only; no UI stubs.
|
||||
|
||||
## Threat Flags
|
||||
|
||||
None. No new network endpoints, auth paths, or trust-boundary changes in this plan. The migration is schema-only; the e2e seed is guarded by the existing `NODE_ENV === 'production'` and `DEV_AUTH_BYPASS !== 'true'` fail-closed guards.
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- `apps/api/src/db/schema.ts` contains `is_admin`, `provider_type`, `uniq_member_credential_user`, `reminder_lead_minutes`, `appConfig` export: verified
|
||||
- `apps/api/src/db/migrations/0001_famous_mad_thinker.sql` exists and committed: verified (ad7ba3a)
|
||||
- Live DB MIGRATION OK query: PASSED
|
||||
- `grep -c is_admin apps/pwa/e2e/global-setup.ts` = 3 (≥1): verified
|
||||
- `pnpm --filter @familysync/api exec tsc --noEmit` exits 0: verified
|
||||
- Commits d0a4cb4, ad7ba3a, bb00c71 in git log: verified
|
||||
@@ -0,0 +1,131 @@
|
||||
---
|
||||
phase: "10-admin-role-settings"
|
||||
plan: "02"
|
||||
subsystem: "api-auth"
|
||||
tags: ["requireAdmin", "admin-role", "middleware", "upsertUser", "first-login-wins", "me-api", "tdd"]
|
||||
dependency_graph:
|
||||
requires:
|
||||
- "users.is_admin column (10-01)"
|
||||
- "member_credentials table with UNIQUE(user_id) (10-01)"
|
||||
- "app_config table (10-01)"
|
||||
provides:
|
||||
- "requireAdmin MiddlewareHandler (DB-backed role enforcement, T-10-04/T-10-05)"
|
||||
- "first-login-wins is_admin bootstrap in upsertUser (D-01)"
|
||||
- "isAdmin + needsProviderSetup on /api/me response (D-03)"
|
||||
affects:
|
||||
- "Phase 10 Plan 03 (adminRouter mounts requireAdmin)"
|
||||
- "Phase 10 Plan 04 (PWA nav gating reads isAdmin from /api/me)"
|
||||
- "Phase 12 (first-login-wins hook point documented for setup_complete tightening)"
|
||||
tech_stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "MiddlewareHandler inline export (requireAdmin pattern, not factory function)"
|
||||
- "sql<number> COUNT(*) with .limit(1) for scalar aggregate in Drizzle"
|
||||
- "resolveAdminAndSetupStatus helper — two sequential DB selects in a route"
|
||||
- "TDD RED→GREEN: 6 RED commits → 3 GREEN commits"
|
||||
key_files:
|
||||
created:
|
||||
- "apps/api/src/lib/requireAdmin.ts"
|
||||
- "apps/api/tests/lib/requireAdmin.test.ts"
|
||||
modified:
|
||||
- "apps/api/src/auth/user.ts"
|
||||
- "apps/api/tests/auth/user.test.ts"
|
||||
- "apps/api/src/routes/me.ts"
|
||||
- "apps/api/tests/routes/me.test.ts"
|
||||
decisions:
|
||||
- "sql<number> COUNT(*) with .limit(1) — not .limit() on Drizzle aggregate; scalar aggregate needs explicit limit for mock-chain compatibility and Drizzle's select-where pattern"
|
||||
- "resolveAdminAndSetupStatus extracted as a shared helper in me.ts — used by both bypass and OIDC paths to avoid duplication"
|
||||
- "requireAdmin is an inline MiddlewareHandler constant, not a factory function — applied as adminRouter.use('*', requireAdmin)"
|
||||
metrics:
|
||||
duration_seconds: 700
|
||||
completed_date: "2026-06-13"
|
||||
tasks_completed: 3
|
||||
files_modified: 6
|
||||
---
|
||||
|
||||
# Phase 10 Plan 02: Admin Role Primitives Summary
|
||||
|
||||
**One-liner:** DB-backed `requireAdmin` MiddlewareHandler, first-login-wins `is_admin` bootstrap in `upsertUser`, and `/api/me` extended with `isAdmin` + `needsProviderSetup` — all TDD-verified with 22 tests.
|
||||
|
||||
## Tasks Completed
|
||||
|
||||
| Task | Name | Commits | Files |
|
||||
|------|------|---------|-------|
|
||||
| 1 | requireAdmin middleware (RED→GREEN) | 9217930 (RED), f9c70ab (GREEN) | requireAdmin.ts, requireAdmin.test.ts |
|
||||
| 2 | First-login-wins is_admin bootstrap in upsertUser (RED→GREEN) | 9e1507f (RED), 72e0140 (GREEN) | user.ts, user.test.ts |
|
||||
| 3 | Extend /api/me with isAdmin + needsProviderSetup (RED→GREEN) | e5889df (RED), 1adff61 (GREEN) | me.ts, me.test.ts |
|
||||
|
||||
## What Was Built
|
||||
|
||||
### Task 1: requireAdmin middleware
|
||||
|
||||
`apps/api/src/lib/requireAdmin.ts` exports `requireAdmin: MiddlewareHandler`:
|
||||
- Reads `c.get('user')?.id`; if no id → 403 `{ error: 'Forbidden' }` immediately (no DB query)
|
||||
- Queries `db.select({ isAdmin: users.isAdmin }).from(users).where(eq(users.id, userId)).limit(1)`
|
||||
- If `!row?.isAdmin` → 403; else `await next()`
|
||||
- Side-effect import of `../auth/devBypass.js` carries the ContextVariableMap augmentation
|
||||
- Never reads `isAdmin` from the context user object — DB is the sole authority (T-10-04)
|
||||
- The dev-auth bypass skips OIDC; requireAdmin still hits the DB for every request (T-10-05)
|
||||
- No `console.log` of user object or credentials (T-10-07)
|
||||
|
||||
4 test cases covering: non-admin DB row → 403, admin DB row → next(), no user → 403 (no DB call), spoofed `isAdmin: true` on context but non-admin DB row → 403.
|
||||
|
||||
### Task 2: First-login-wins is_admin bootstrap in upsertUser
|
||||
|
||||
`apps/api/src/auth/user.ts` extended before the INSERT block:
|
||||
- Added `import { sql } from 'drizzle-orm'`
|
||||
- Zero-admin COUNT check: `db.select({ count: sql<number>\`COUNT(*)\` }).from(users).where(eq(users.isAdmin, true)).limit(1)`
|
||||
- `shouldBeAdmin = Number(count) === 0`
|
||||
- INSERT `.values({ ..., isAdmin: shouldBeAdmin })` — first user when zero admins → `is_admin=true`; subsequent users → `is_admin=false`
|
||||
- Existing-user early-return path unchanged (no `is_admin` modification on re-upsert)
|
||||
- Phase-12 hook comment: "Phase 12 tightens to: first user after app_config.setup_complete"
|
||||
|
||||
3 new test cases + existing tests updated for the new 4-select call sequence (identity lookup → used-colors → admin COUNT → re-fetch).
|
||||
|
||||
### Task 3: /api/me extended with isAdmin + needsProviderSetup
|
||||
|
||||
`apps/api/src/routes/me.ts` extended with:
|
||||
- `resolveAdminAndSetupStatus(userId)` helper — two DB selects:
|
||||
1. `users.isAdmin` via `db.select({ isAdmin: users.isAdmin }).from(users).where(eq(users.id, userId)).limit(1)`
|
||||
2. `memberCredentials.id` via `db.select({ id: memberCredentials.id }).from(memberCredentials).where(eq(memberCredentials.userId, userId)).limit(1)`
|
||||
- Returns `{ isAdmin: row?.isAdmin ?? false, needsProviderSetup: !cred }`
|
||||
- Dev-bypass path: now calls `resolveAdminAndSetupStatus(devUser.id)` — DB-backed, not hardcoded (T-10-05)
|
||||
- OIDC path: calls `resolveAdminAndSetupStatus(user.id)` after `upsertUser`
|
||||
- Response: `{ user: { id, displayName, color, isAdmin, needsProviderSetup } }` on both paths
|
||||
- No `/api/me/credential` POST added (Plan 03)
|
||||
|
||||
3 new test cases: isAdmin from DB (not hardcoded), needsProviderSetup=true (no cred), needsProviderSetup=false (cred exists).
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. [Rule 1 - Bug] Drizzle aggregate mock chaining — added .limit(1) to COUNT query**
|
||||
- **Found during:** Task 2 (GREEN phase)
|
||||
- **Issue:** The COUNT query `const [{ count }] = await db.select({...}).from(users).where(...)` was awaiting the `.where()` return directly. In mocked tests, `makeSelectChain.where()` returns the chain object (not a Promise), so destructuring `[{ count }]` failed with "is not iterable".
|
||||
- **Fix:** Added `.limit(1)` to the COUNT query, making it terminate at `.limit()` which returns a Promise in the mock (consistent with all other select patterns in this codebase).
|
||||
- **Files modified:** `apps/api/src/auth/user.ts` (`.limit(1)` on COUNT query)
|
||||
- **Commit:** 72e0140
|
||||
|
||||
## Known Stubs
|
||||
|
||||
None. This plan is API-only (no UI components). All DB queries are real and fully implemented.
|
||||
|
||||
## Threat Flags
|
||||
|
||||
None new beyond the plan's threat model. All T-10-04/T-10-05/T-10-06/T-10-07 mitigations implemented:
|
||||
- T-10-04: requireAdmin reads `users.is_admin` from DB, never trusts context user's `isAdmin`
|
||||
- T-10-05: Both requireAdmin and /api/me do DB lookups even on the dev-bypass path
|
||||
- T-10-06: isAdmin on /api/me is documented UX-only; Plan 03's requireAdmin is the server boundary
|
||||
- T-10-07: No `console.log` of user object or credentials in any modified file
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- `apps/api/src/lib/requireAdmin.ts` exists and exports `requireAdmin`: PASS
|
||||
- `grep -q "users.isAdmin" apps/api/src/lib/requireAdmin.ts`: PASS
|
||||
- `grep -q "isAdmin: shouldBeAdmin" apps/api/src/auth/user.ts`: PASS
|
||||
- `grep -q "needsProviderSetup" apps/api/src/routes/me.ts`: PASS
|
||||
- `grep -q "memberCredentials" apps/api/src/routes/me.ts`: PASS
|
||||
- All 22 tests pass (requireAdmin: 4, user: 10, me: 8): PASS
|
||||
- `pnpm --filter @familysync/api exec tsc --noEmit` exits 0: PASS
|
||||
- Commits 9217930, f9c70ab, 9e1507f, 72e0140, e5889df, 1adff61 in git log: PASS
|
||||
@@ -0,0 +1,137 @@
|
||||
---
|
||||
phase: "10-admin-role-settings"
|
||||
plan: "03"
|
||||
subsystem: "api-admin"
|
||||
tags: ["admin", "credentials", "caldav", "encryption", "tdd", "requireAdmin", "self-service", "no-echo", "pitfall-7", "pitfall-9"]
|
||||
dependency_graph:
|
||||
requires:
|
||||
- "users.is_admin column (10-01)"
|
||||
- "member_credentials.UNIQUE(user_id) constraint (10-01)"
|
||||
- "requireAdmin MiddlewareHandler (10-02)"
|
||||
- "isAdmin + needsProviderSetup on /api/me (10-02)"
|
||||
- "loadClientForUser + triggerTargetedResync in outboxWorker (this plan, Task 1)"
|
||||
provides:
|
||||
- "export loadClientForUser from outboxWorker.ts"
|
||||
- "export triggerTargetedResync from outboxWorker.ts"
|
||||
- "validateEncryptAndStoreCredential(userId, email, appPassword, providerType) in credentialSync.ts"
|
||||
- "CredentialValidationError typed failure signal in credentialSync.ts"
|
||||
- "adminRouter with requireAdmin guard-first, GET /members, POST /credentials, GET /calendars, PUT /calendars/:id/shared"
|
||||
- "app.route('/api/admin', adminRouter) mount in index.ts"
|
||||
- "POST /api/me/credential member-scoped self-service endpoint"
|
||||
- "Integration tests: admin.test.ts (17 test cases)"
|
||||
affects:
|
||||
- "Phase 10 Plan 04 (PWA admin UI consumes these routes)"
|
||||
- "Phase 12 (Setup Wizard reuses /api/admin/credentials and validateEncryptAndStoreCredential)"
|
||||
tech_stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "Shared validate→encrypt→store→sync helper (credentialSync.ts) imported by both admin and me routes"
|
||||
- "noEchoHook on zValidator for credential routes — returns { error: 'Invalid request' } only"
|
||||
- "CredentialValidationError typed exception for all PROPFIND/auth failure modes"
|
||||
- "adminRouter.use('*', requireAdmin) as first statement (Pitfall 9)"
|
||||
- "exclusive is_shared update via two sequential Drizzle UPDATEs (Pattern 7)"
|
||||
- "fire-and-forget initial-sync via loadClientForUser + syncCalendar per davCal"
|
||||
- "TDD RED (test(10-03)) → GREEN (feat(10-03)) commit discipline"
|
||||
key_files:
|
||||
created:
|
||||
- "apps/api/src/broker/credentialSync.ts"
|
||||
- "apps/api/src/routes/admin.ts"
|
||||
- "apps/api/tests/routes/admin.test.ts"
|
||||
modified:
|
||||
- "apps/api/src/broker/outboxWorker.ts"
|
||||
- "apps/api/src/routes/me.ts"
|
||||
- "apps/api/src/index.ts"
|
||||
decisions:
|
||||
- "credentialSync.ts wraps BOTH createFastmailClient AND fetchCalendars in ONE try/catch — any failure from either is a CredentialValidationError; routes map to generic 400 (all failure modes indistinguishable per Pitfall 7)"
|
||||
- "fire-and-forget initial sync uses davCalendars from the PROPFIND step if available, falling back to loadClientForUser + fetchCalendars — avoids a second PROPFIND round-trip when cals are already known"
|
||||
- "noEchoHook returns { error: 'Invalid request' } 400 — never result.error (which contains .received = submitted password)"
|
||||
- "POST /api/me/credential meCredentialSchema excludes userId field (Pitfall 6 / T-10-12) — resolveUserId from session only"
|
||||
- "adminRouter.use('*', requireAdmin) is first executable statement after export const adminRouter = new Hono()"
|
||||
metrics:
|
||||
duration_seconds: 720
|
||||
completed_date: "2026-06-13"
|
||||
tasks_completed: 3
|
||||
files_modified: 6
|
||||
---
|
||||
|
||||
# Phase 10 Plan 03: Admin API Surface + Shared Credential Helper Summary
|
||||
|
||||
**One-liner:** Single shared `validateEncryptAndStoreCredential` helper (CalDAV PROPFIND + AES-256-GCM encrypt + upsert + fire-and-forget sync) consumed by `adminRouter` (requireAdmin-first, ADMIN-01/02/03) and `/api/me/credential` self-service (D-07, member-scoped).
|
||||
|
||||
## Tasks Completed
|
||||
|
||||
| Task | Name | Commits | Files |
|
||||
|------|------|---------|-------|
|
||||
| 1 | Promote broker resync helpers to exports | ac36e10 | apps/api/src/broker/outboxWorker.ts |
|
||||
| 2 RED | Write failing tests for admin surface | 037a7ed | apps/api/tests/routes/admin.test.ts |
|
||||
| 2+3 GREEN | credentialSync helper + adminRouter + /api/me/credential | d2f6d5d | credentialSync.ts, admin.ts, index.ts, me.ts |
|
||||
|
||||
## What Was Built
|
||||
|
||||
### Task 1: Promote broker resync helpers to exports
|
||||
|
||||
Added `export` keyword to `loadClientForUser` (line 271) and `triggerTargetedResync` (line 302) in `outboxWorker.ts`. Function bodies are byte-for-byte unchanged — only the visibility changed. The outbox drain cycle and `setInterval` scheduling are untouched. No `node-cron` reintroduced.
|
||||
|
||||
### Task 2+3: RED → GREEN
|
||||
|
||||
**RED:** `apps/api/tests/routes/admin.test.ts` created with 17 test cases covering:
|
||||
- T-10-08 (Pitfall 9): GET /api/admin/members, POST /credentials, GET /calendars, PUT /calendars/:id/shared all return 403 for non-admin
|
||||
- T-10-09 (Pitfall 7): POST /api/admin/credentials with PROPFIND auth failure, createFastmailClient throw, network error → all return 400 `{ error: 'Invalid request' }` with the submitted password string absent from the response
|
||||
- T-10-09: zValidator schema failure → same generic 400, no Zod .received echo
|
||||
- T-10-11: valid credential → 200, stored AES-256-GCM encrypted (not plaintext)
|
||||
- ADMIN-02: PUT /api/admin/calendars/:id/shared → exactly one calendar has is_shared=1
|
||||
- T-10-12 (Pitfall 6): POST /api/me/credential with body userId for another user → credential written only to session user
|
||||
- D-07: non-admin member can POST /api/me/credential (no requireAdmin required)
|
||||
|
||||
All 17 tests confirmed RED (404/assertion failures) before implementation.
|
||||
|
||||
**GREEN:**
|
||||
|
||||
`apps/api/src/broker/credentialSync.ts` — shared helper:
|
||||
1. `createFastmailClient(email, appPassword)` + `await client.fetchCalendars()` in ONE try/catch → any failure throws `CredentialValidationError` (typed; no password detail in the exception)
|
||||
2. `encryptPassword(appPassword)` → AES-256-GCM JSON ciphertext
|
||||
3. `db.insert(memberCredentials).onDuplicateKeyUpdate(...)` → upsert (UNIQUE(user_id) from 10-01)
|
||||
4. Fire-and-forget: `loadClientForUser(userId)` → `syncCalendar(...)` per davCal
|
||||
|
||||
`apps/api/src/routes/admin.ts`:
|
||||
- `export const adminRouter = new Hono()` immediately followed by `adminRouter.use('*', requireAdmin)` (Pitfall 9)
|
||||
- Side-effect import of `../auth/devBypass.js` for ContextVariableMap
|
||||
- `GET /members`: users LEFT JOIN member_credentials → `{ members: [{ id, displayName, color, hasCredential }] }`
|
||||
- `POST /credentials`: `zValidator('json', credentialSchema, noEchoHook)` → `validateEncryptAndStoreCredential(body.userId, ...)` → 200 or 400/503
|
||||
- `GET /calendars`: `{ calendars: [{ id, displayName, isShared }] }`
|
||||
- `PUT /calendars/:id/shared`: clear all `is_shared=true`, set target → 200
|
||||
|
||||
`apps/api/src/index.ts`: `app.route('/api/admin', adminRouter)` added after existing route block.
|
||||
|
||||
`apps/api/src/routes/me.ts`:
|
||||
- `POST /credential` added with `meCredentialSchema` (no userId field — Pitfall 6)
|
||||
- `meNoEchoHook` identical pattern to admin noEchoHook
|
||||
- Handler: `resolveUserId(c)` from session → `validateEncryptAndStoreCredential(currentUserId, ...)` — body userId ignored
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
None — plan executed exactly as written.
|
||||
|
||||
## Known Stubs
|
||||
|
||||
None. All routes are fully implemented with real DB and real CalDAV integration (mocked in tests). No placeholder data.
|
||||
|
||||
## Threat Flags
|
||||
|
||||
No new threat surface beyond the plan's threat model. All T-10-08 through T-10-13 mitigations implemented:
|
||||
- T-10-08: adminRouter.use('*', requireAdmin) guard-first
|
||||
- T-10-09: noEchoHook + CredentialValidationError → generic 400
|
||||
- T-10-10: no console.log of body/password in admin.ts, me.ts, or credentialSync.ts
|
||||
- T-10-11: encryptPassword applied before DB write; tests assert encrypted != plaintext
|
||||
- T-10-12: /api/me/credential uses currentUserId from session exclusively; test proves other user's row is untouched
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- `grep -n "adminRouter.use" apps/api/src/routes/admin.ts` shows `.use('*', requireAdmin)` at line 41 (first executable statement after router creation on line 37): PASS
|
||||
- `grep "app.route('/api/admin'" apps/api/src/index.ts` confirms mount: PASS
|
||||
- `grep "validateEncryptAndStoreCredential" apps/api/src/routes/admin.ts` shows import + call: PASS
|
||||
- `grep "validateEncryptAndStoreCredential" apps/api/src/routes/me.ts` shows import + call: PASS
|
||||
- `grep "createFastmailClient" apps/api/src/routes/admin.ts apps/api/src/routes/me.ts` — only in a comment (not in route executable code): PASS
|
||||
- 270/270 tests pass (27 test files): PASS
|
||||
- `pnpm --filter @familysync/api exec tsc --noEmit` exits 0: PASS
|
||||
- Commits ac36e10 (Task 1), 037a7ed (RED), d2f6d5d (GREEN) in git log: PASS
|
||||
@@ -0,0 +1,200 @@
|
||||
---
|
||||
phase: "10-admin-role-settings"
|
||||
plan: "04"
|
||||
subsystem: "pwa-admin-ui"
|
||||
tags: ["admin-ui", "route-guard", "nav-gating", "credential-sheet", "self-service", "setup-banner", "e2e", "playwright", "isAdmin", "needsProviderSetup"]
|
||||
dependency_graph:
|
||||
requires:
|
||||
- "isAdmin + needsProviderSetup on /api/me (10-02)"
|
||||
- "adminRouter endpoints: GET /members, POST /credentials, GET /calendars, PUT /calendars/:id/shared (10-03)"
|
||||
- "POST /api/me/credential self-service endpoint (10-03)"
|
||||
provides:
|
||||
- "MeUser.isAdmin + MeUser.needsProviderSetup in apps/pwa/src/api/client.ts"
|
||||
- "fetchAdminMembers, saveCredential, fetchAdminCalendars, setSharedCalendar, saveMyCredential in client.ts"
|
||||
- "apps/pwa/src/routes/AdminPage.tsx — gated /admin page (MEMBERS + SHARED CALENDAR)"
|
||||
- "apps/pwa/src/components/CredentialSheet.tsx — shared admin-rotate/admin-add/self-service bottom sheet"
|
||||
- "apps/pwa/src/components/SetupBanner.tsx — needsProviderSetup onboarding banner (success-only dismissal)"
|
||||
- "/admin Route in App.tsx (isAdmin gate + loading gate)"
|
||||
- "conditional Admin nav entry (ShieldCheck) in AppNav.tsx + BottomTabBar.tsx"
|
||||
- "apps/pwa/e2e/admin.spec.ts — 15 e2e assertions (3 profiles x 5 tests)"
|
||||
affects:
|
||||
- "Phase 11 (per-event reminders may reuse SetupBanner pattern)"
|
||||
- "Phase 12 (setup wizard reuses CredentialSheet for initial credential setup)"
|
||||
tech_stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "React Query ['admin','members'] + ['admin','calendars'] for admin data fetching"
|
||||
- "['me'] invalidation from CredentialSheet.onSuccess → SetupBanner unmounts (success-only dismissal)"
|
||||
- "meQuery.isLoading gate on /admin Route (prevents flash-of-redirect)"
|
||||
- "page.route('/api/me', ...) route-mock pattern for non-admin e2e assertions"
|
||||
- "waitForURL for redirect assertions in e2e (not just nav visibility)"
|
||||
key_files:
|
||||
created:
|
||||
- "apps/pwa/src/routes/AdminPage.tsx"
|
||||
- "apps/pwa/src/components/CredentialSheet.tsx"
|
||||
- "apps/pwa/src/components/SetupBanner.tsx"
|
||||
- "apps/pwa/e2e/admin.spec.ts"
|
||||
modified:
|
||||
- "apps/pwa/src/api/client.ts"
|
||||
- "apps/pwa/src/App.tsx"
|
||||
- "apps/pwa/src/components/AppNav.tsx"
|
||||
- "apps/pwa/src/components/BottomTabBar.tsx"
|
||||
decisions:
|
||||
- "meQuery.isLoading gate on /admin Route: renders <div aria-hidden> while loading, then isAdmin check fires — prevents flash of admin content for non-admins and prevents null-element stalling redirect"
|
||||
- "waitForURL (not just nav visibility) in redirect e2e test — the Navigate fires asynchronously after meQuery resolves, so checking pathname immediately after goto can race ahead of the redirect"
|
||||
- "Pre-existing Plan 02/03 prettier violations in API test files fixed as part of CI gate compliance (format:check was failing at workspace root)"
|
||||
- "Type assertions for RefObject removed — @typescript-eslint/no-unnecessary-type-assertion flagged them; TS already accepted the types without cast"
|
||||
metrics:
|
||||
duration_seconds: 1315
|
||||
completed_date: "2026-06-13"
|
||||
tasks_completed: 3
|
||||
files_modified: 8
|
||||
---
|
||||
|
||||
# Phase 10 Plan 04: React PWA Admin Surfaces Summary
|
||||
|
||||
**One-liner:** React PWA admin surfaces — isAdmin/needsProviderSetup client types, five typed admin fetchers, gated `/admin` route (AdminPage + CredentialSheet + SetupBanner), conditional ShieldCheck nav entries, and 15 e2e assertions across 3 device profiles all green.
|
||||
|
||||
## Tasks Completed
|
||||
|
||||
| Task | Name | Commits | Files |
|
||||
|------|------|---------|-------|
|
||||
| 1 | Extend client.ts — MeUser fields + admin/self-service fetchers | bfe1eff | apps/pwa/src/api/client.ts |
|
||||
| 2 | CredentialSheet + SetupBanner components | 2c2c71e | CredentialSheet.tsx, SetupBanner.tsx |
|
||||
| 3 | /admin route + AdminPage + conditional nav entries + e2e spec | 7808426 | AdminPage.tsx, App.tsx, AppNav.tsx, BottomTabBar.tsx, admin.spec.ts |
|
||||
| fix | Prettier format + type assertion cleanup | 79fe3e0 | 8 files (PWA + API) |
|
||||
|
||||
## What Was Built
|
||||
|
||||
### Task 1: Extend client.ts
|
||||
|
||||
`apps/pwa/src/api/client.ts` extended with:
|
||||
- `MeUser.isAdmin: boolean` — UX gating flag (D-03; server enforces 403 on /api/admin/*)
|
||||
- `MeUser.needsProviderSetup: boolean` — drives SetupBanner display
|
||||
- `AdminMember`, `SaveCredentialPayload`, `AdminCalendar`, `SaveMyCredentialPayload` types matching Plan-03 shapes
|
||||
- `fetchAdminMembers()` → GET /api/admin/members
|
||||
- `saveCredential(payload)` → POST /api/admin/credentials (includes userId — admin-scoped)
|
||||
- `fetchAdminCalendars()` → GET /api/admin/calendars
|
||||
- `setSharedCalendar(calendarId)` → PUT /api/admin/calendars/:id/shared
|
||||
- `saveMyCredential(payload)` → POST /api/me/credential (NO userId — member-scoped, T-10-12)
|
||||
|
||||
All fetchers use `credentials:'include'`, `redirect:'manual'`, `handleAuthResponse`. Password never logged or stored beyond in-flight request body (T-10-15).
|
||||
|
||||
### Task 2: CredentialSheet + SetupBanner
|
||||
|
||||
**CredentialSheet** (`apps/pwa/src/components/CredentialSheet.tsx`):
|
||||
- Three modes: `admin-rotate`, `admin-add`, `self-service` — heading copy varies per mode
|
||||
- `role="dialog" aria-modal="true"` bottom sheet, zIndex 301 (backdrop 300), 12px 12px 0 0 borderRadius
|
||||
- `type="password" autoComplete="new-password"` — NEVER pre-filled (T-10-16)
|
||||
- Helper text with Fastmail link `target="_blank" rel="noopener noreferrer"` (UI-SPEC Surface 3)
|
||||
- Loader2 spinner + "Validating against CalDAV…" during mutation
|
||||
- CalDAV failure copy on error state
|
||||
- On success: `invalidateQueries(['admin','members'])` + `invalidateQueries(['me'])` → needsProviderSetup refreshes
|
||||
- Escape closes; focus returns to trigger element (a11y)
|
||||
|
||||
**SetupBanner** (`apps/pwa/src/components/SetupBanner.tsx`):
|
||||
- Renders only when `meQuery.data?.user.needsProviderSetup === true`
|
||||
- `role="status" aria-live="polite"` (screen reader announcement on load)
|
||||
- KeyRound icon + "Set up your calendar" + body copy + "Set up now" CTA
|
||||
- NO dismiss button — the ONLY exit is a successful credential save that flips needsProviderSetup → false
|
||||
- Opens CredentialSheet in self-service mode
|
||||
|
||||
### Task 3: /admin route + AdminPage + nav entries + e2e
|
||||
|
||||
**AdminPage** (`apps/pwa/src/routes/AdminPage.tsx`):
|
||||
- "Admin Settings" h1 (18px/600), maxWidth 640px centered desktop, var(--space-12) padding
|
||||
- MEMBERS section: avatar swatch + member name + "Credential set" / "No credential" badge + "Rotate"/"Add credential" button
|
||||
- SHARED CALENDAR section: radio group with "Currently shared" label, two-tap Save (disabled until selection differs), empty state copy
|
||||
- Opens CredentialSheet for each member on row button click
|
||||
|
||||
**App.tsx** additions:
|
||||
- `/admin` Route gated: `meQuery.isLoading → <div aria-hidden>` (no flash), `isAdmin → <AdminPage />`, else `<Navigate to="/calendar" replace />`
|
||||
- `<SetupBanner />` mounted above Routes in the content area
|
||||
- `isAdmin` prop forwarded to AppNav and BottomTabBar
|
||||
|
||||
**AppNav.tsx** + **BottomTabBar.tsx**:
|
||||
- ShieldCheck (size 18/22) Admin entry with `aria-label="Admin settings"` rendered ONLY when `isAdmin === true`
|
||||
- Conditional import of ShieldCheck from lucide-react
|
||||
|
||||
**admin.spec.ts** (`apps/pwa/e2e/admin.spec.ts`):
|
||||
- Admin user (seeded `is_admin=true` by global-setup): nav entry visible, /admin renders heading + Members section
|
||||
- Non-admin (route-mocked `isAdmin:false`): no nav entry, /admin redirects to /calendar via `waitForURL`
|
||||
- 15 assertions across iphone/pixel/desktop profiles — all pass
|
||||
|
||||
## Playwright-CLI Supplementary Observations
|
||||
|
||||
Admin user (is_admin=true, seeded via mysql2 for playwright-cli check), navigated to `http://localhost:5173/admin`:
|
||||
|
||||
**Snapshot confirms:**
|
||||
- "Admin settings" link in desktop nav sidebar visible with ShieldCheck icon
|
||||
- SetupBanner renders: role=status, "Set up your calendar" heading, "Set up now" CTA (needsProviderSetup=true for dev-bypass user)
|
||||
- "Admin Settings" h1 visible
|
||||
- MEMBERS section: 3 members (Dev User, luc@bergermail.ca, amelia@bergermail.ca), each showing "No credential" + "Add credential" button
|
||||
- SHARED CALENDAR section: radiogroup with "FamilySync Currently shared" selected, Save button disabled (no change)
|
||||
- Clicking "Add credential" for Dev User opens `dialog "Add Credential"` with email+password fields, helper text with Fastmail link, Cancel + "Save Credential" (disabled until fields filled)
|
||||
|
||||
**Non-admin redirect:** Verified via e2e spec (page.route mock) — 5/5 tests confirmed. playwright-cli route mock intercepted the wrong URL (`localhost:3000/api/me` instead of the Vite-proxied `/api/me`) so the non-admin visual was not observed in the browser session, but the e2e spec is the binding proof per the plan.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. [Rule 1 - Bug] meQuery loading gate rendered null instead of redirect-safe element**
|
||||
- **Found during:** Task 3 e2e run (test: "non-admin navigating to /admin is redirected to /calendar")
|
||||
- **Issue:** `meQuery.isLoading ? null` renders nothing as the Route element, but React Router does not trigger a Navigate when the element is null — the URL stays at /admin and no redirect fires during the loading window
|
||||
- **Fix:** Changed to `meQuery.isLoading ? <div aria-hidden />` so the route is occupied during loading, then the Navigate fires once meQuery resolves with isAdmin:false
|
||||
- **Files modified:** apps/pwa/src/App.tsx
|
||||
- **Commit:** 7808426
|
||||
|
||||
**2. [Rule 1 - Bug] e2e redirect test raced ahead of Navigate render**
|
||||
- **Found during:** Task 3 e2e run (same test as above)
|
||||
- **Issue:** Test checked URL immediately after `page.goto('/admin')`, before the meQuery resolved and Navigate rendered
|
||||
- **Fix:** Added `await page.waitForURL(/\/calendar/, { timeout: 10_000 })` to wait for the actual redirect before asserting pathname
|
||||
- **Files modified:** apps/pwa/e2e/admin.spec.ts
|
||||
- **Commit:** 7808426
|
||||
|
||||
**3. [Rule 2 - Formatting] Pre-existing prettier violations in Plan 02/03 API files**
|
||||
- **Found during:** CI gate (format:check)
|
||||
- **Issue:** apps/api/src/routes/me.ts, tests/auth/user.test.ts, tests/lib/requireAdmin.test.ts, tests/routes/me.test.ts had unformatted lines from Plan 02/03 commits (the workspace format:check was already failing before this plan's changes)
|
||||
- **Fix:** Ran prettier --write on those files; 270 API tests still pass
|
||||
- **Files modified:** 4 API files
|
||||
- **Commit:** 79fe3e0
|
||||
|
||||
**4. [Rule 1 - Bug] Unnecessary type assertions flagged by ESLint**
|
||||
- **Found during:** CI gate (lint)
|
||||
- **Issue:** Two `as React.RefObject<HTMLElement | null>` casts in SetupBanner.tsx and AdminPage.tsx — ESLint @typescript-eslint/no-unnecessary-type-assertion flagged them as redundant
|
||||
- **Fix:** Removed both casts; TS already accepted the RefObject types without casting
|
||||
- **Files modified:** apps/pwa/src/components/SetupBanner.tsx, apps/pwa/src/routes/AdminPage.tsx
|
||||
- **Commit:** 79fe3e0
|
||||
|
||||
## Known Stubs
|
||||
|
||||
None. All admin surfaces are fully wired to the live API endpoints. CredentialSheet performs real CalDAV validation (via the server's validateEncryptAndStoreCredential). The SetupBanner uses the real ['me'] query. AdminPage fetches live member + calendar data.
|
||||
|
||||
## Threat Flags
|
||||
|
||||
No new threat surface beyond the plan's threat model:
|
||||
- T-10-14: /admin client redirect is UX-only; server 403 (requireAdmin) is the real boundary — confirmed
|
||||
- T-10-15: password field never pre-filled, never in state beyond in-flight mutation body — confirmed
|
||||
- T-10-16: autoComplete="new-password" on password input — confirmed
|
||||
- T-10-SC: No new packages installed (lucide-react ShieldCheck/KeyRound already in 1.17.0)
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- `apps/pwa/src/routes/AdminPage.tsx` exists with > 60 lines: PASS
|
||||
- `apps/pwa/src/components/CredentialSheet.tsx` exists with > 50 lines: PASS
|
||||
- `apps/pwa/src/components/SetupBanner.tsx` exists with > 20 lines: PASS
|
||||
- `grep "isAdmin" apps/pwa/src/api/client.ts` matches ≥ 2 occurrences: PASS
|
||||
- `grep "needsProviderSetup" apps/pwa/src/api/client.ts` matches: PASS
|
||||
- `grep "autoComplete" apps/pwa/src/components/CredentialSheet.tsx` contains "new-password": PASS
|
||||
- `grep "invalidateQueries" apps/pwa/src/components/CredentialSheet.tsx` ≥ 2 occurrences: PASS
|
||||
- `grep "role=\"status\"" apps/pwa/src/components/SetupBanner.tsx` exists: PASS
|
||||
- `grep "ShieldCheck" apps/pwa/src/components/AppNav.tsx` exists: PASS
|
||||
- `grep "ShieldCheck" apps/pwa/src/components/BottomTabBar.tsx` exists: PASS
|
||||
- `grep "Navigate to=\"/calendar\"" apps/pwa/src/App.tsx` exists: PASS
|
||||
- `pnpm --filter @familysync/pwa typecheck` exits 0 (both app + e2e tsconfigs): PASS
|
||||
- `pnpm --filter @familysync/pwa build` exits 0: PASS
|
||||
- `pnpm --filter @familysync/pwa lint` exits 0: PASS
|
||||
- `pnpm --filter @familysync/pwa test` 191/191 pass: PASS
|
||||
- e2e admin.spec.ts 15/15 pass (iphone + pixel + desktop): PASS
|
||||
- Commits bfe1eff, 2c2c71e, 7808426, 79fe3e0 in git log: PASS
|
||||
@@ -0,0 +1,197 @@
|
||||
---
|
||||
phase: 10-admin-role-settings
|
||||
reviewed: 2026-06-13T00:00:00Z
|
||||
depth: standard
|
||||
files_reviewed: 21
|
||||
files_reviewed_list:
|
||||
- apps/api/src/auth/user.ts
|
||||
- apps/api/src/broker/outboxWorker.ts
|
||||
- apps/api/src/db/migrations/0001_famous_mad_thinker.sql
|
||||
- apps/api/src/db/schema.ts
|
||||
- apps/api/src/index.ts
|
||||
- apps/api/src/lib/requireAdmin.ts
|
||||
- apps/api/src/routes/admin.ts
|
||||
- apps/api/src/routes/me.ts
|
||||
- apps/api/tests/auth/user.test.ts
|
||||
- apps/api/tests/lib/requireAdmin.test.ts
|
||||
- apps/api/tests/routes/admin.test.ts
|
||||
- apps/api/tests/routes/me.test.ts
|
||||
- apps/pwa/e2e/admin.spec.ts
|
||||
- apps/pwa/e2e/global-setup.ts
|
||||
- apps/pwa/src/App.tsx
|
||||
- apps/pwa/src/api/client.ts
|
||||
- apps/pwa/src/components/AppNav.tsx
|
||||
- apps/pwa/src/components/BottomTabBar.tsx
|
||||
- apps/pwa/src/components/SetupBanner.tsx
|
||||
- apps/pwa/src/routes/AdminPage.tsx
|
||||
findings:
|
||||
critical: 1
|
||||
warning: 7
|
||||
info: 5
|
||||
total: 13
|
||||
status: issues_found
|
||||
---
|
||||
|
||||
# Phase 10: Code Review Report
|
||||
|
||||
**Reviewed:** 2026-06-13
|
||||
**Depth:** standard
|
||||
**Files Reviewed:** 21
|
||||
**Status:** issues_found
|
||||
|
||||
## Summary
|
||||
|
||||
Phase 10 adds admin-role primitives (`users.is_admin`, first-login-wins bootstrap), a DB-backed `requireAdmin` guard, an admin/self-service credential surface, and an exclusive shared-calendar designator. The core security contracts hold up well: `requireAdmin` reads `is_admin` from the DB (not the context user), all `/api/admin/*` routes are gated by `adminRouter.use('*', requireAdmin)` as the first statement, the no-echo hook is applied to both credential routes, and `/api/me/credential` resolves `currentUserId` from the session and ignores any body `userId`. The migration is additive (no DROP/TRUNCATE).
|
||||
|
||||
The defects found are concentrated in two areas: (1) the first-login-wins admin bootstrap and shared-calendar designation are non-atomic multi-statement operations with no transaction or row-count guard, and (2) several routes/inputs lack existence/identity validation that lets the system silently enter a wrong state. The single BLOCKER is the shared-calendar PUT, which can leave the household with **zero** shared calendars while returning `{ ok: true }`.
|
||||
|
||||
**Scope limitation:** Two in-scope files could not be read — `apps/api/src/broker/credentialSync.ts` (the central validate/encrypt/store helper) and `apps/pwa/src/components/CredentialSheet.tsx` (the credential input form) — both are in directories denied by the sandbox. Their behavior was reviewed indirectly via call sites (`admin.ts`, `me.ts`) and the integration tests (`admin.test.ts`), which confirm encryption-at-rest and no-echo on the wire. The crypto implementation itself (IV reuse, auth-tag handling, key derivation) and the sheet's client-side handling of the password (e.g. whether it is held in state longer than the request, autocomplete attributes) were **not** directly inspected and should be re-reviewed separately.
|
||||
|
||||
## Critical Issues
|
||||
|
||||
### CR-01: Shared-calendar PUT can clear the only shared calendar and report success
|
||||
|
||||
**File:** `apps/api/src/routes/admin.ts:150-163`
|
||||
**Issue:** `PUT /api/admin/calendars/:id/shared` runs two independent UPDATEs:
|
||||
|
||||
```js
|
||||
await db.update(calendars).set({ isShared: false }).where(eq(calendars.isShared, true)); // clear
|
||||
await db.update(calendars).set({ isShared: true }).where(eq(calendars.id, targetId)); // set
|
||||
```
|
||||
|
||||
`targetId` is only checked for `isNaN`, never for existence. If the id does not match any row (deleted calendar, stale client cache, hand-crafted request, off-by-one from a re-sync that re-keyed calendar ids), step 1 still clears the previously-shared calendar and step 2 updates **0 rows**. The handler then returns `{ ok: true }`. Result: the household silently ends up with **no** shared calendar — the shared family lane disappears for every member, and the UI's `Currently shared` indicator shows nothing, with no error surfaced. This is a data-state-loss / correctness defect in the core ADMIN-02 flow. The two statements are also non-transactional, so a crash between them leaves zero shared calendars even for a valid id.
|
||||
|
||||
**Fix:** Validate the target exists and make the swap atomic. Check the affected-row count of the set, and roll back / 404 if it is zero:
|
||||
|
||||
```js
|
||||
adminRouter.put('/calendars/:id/shared', async (c) => {
|
||||
const targetId = parseInt(c.req.param('id'), 10);
|
||||
if (Number.isNaN(targetId)) {
|
||||
return c.json({ error: 'Invalid calendar id' }, 400);
|
||||
}
|
||||
|
||||
// Confirm the target exists BEFORE clearing the current selection.
|
||||
const [target] = await db
|
||||
.select({ id: calendars.id })
|
||||
.from(calendars)
|
||||
.where(eq(calendars.id, targetId))
|
||||
.limit(1);
|
||||
if (!target) {
|
||||
return c.json({ error: 'Calendar not found' }, 404);
|
||||
}
|
||||
|
||||
// Wrap both writes in a transaction so a crash cannot strand zero shared calendars.
|
||||
await db.transaction(async (tx) => {
|
||||
await tx.update(calendars).set({ isShared: false }).where(eq(calendars.isShared, true));
|
||||
await tx.update(calendars).set({ isShared: true }).where(eq(calendars.id, targetId));
|
||||
});
|
||||
|
||||
return c.json({ ok: true }, 200);
|
||||
});
|
||||
```
|
||||
|
||||
## Warnings
|
||||
|
||||
### WR-01: First-login-wins admin bootstrap is a non-atomic check-then-insert (TOCTOU)
|
||||
|
||||
**File:** `apps/api/src/auth/user.ts:118-136`
|
||||
**Issue:** The zero-admin `COUNT(*)` and the subsequent `INSERT ... isAdmin: shouldBeAdmin` are separate statements with no transaction or locking. Two genuinely-concurrent first logins (two different OIDC identities hitting `/api/me` at the same time on a cold DB) can both read `count === 0` and both insert with `isAdmin: true`, producing two admins instead of one. The comment claims first-login-wins, but the implementation does not enforce a single winner. For a two-person household this is low-probability, but it is a privilege-escalation-adjacent correctness gap in exactly the bootstrap the phase is meant to harden, and Phase 12 is documented to build on this hook.
|
||||
|
||||
**Fix:** Perform the count and insert inside a single transaction with a row lock (e.g. `SELECT ... FOR UPDATE` on the users table or an advisory lock), or gate admin assignment on a `UNIQUE` partial constraint / `app_config` flag set atomically. Minimum viable fix: wrap steps 2-5 in `db.transaction` and re-read the admin count inside it with `FOR UPDATE`.
|
||||
|
||||
### WR-02: `resolveUserId` / `/api/me` will upsert a user with empty-string iss or sub
|
||||
|
||||
**File:** `apps/api/src/routes/me.ts:81-85` and `:115-123`
|
||||
**Issue:** Both the `resolveUserId` helper and the main `/api/me` handler coalesce missing claims to empty strings: `const sub = auth.sub ?? ''` and `const iss = (auth.iss as string | undefined) ?? ''`. If a malformed/partial token ever reaches here with a missing `sub` (the OIDC middleware is mocked as a passthrough in tests, and real-world token edge cases exist), `upsertUser('', '', ...)` creates a bogus identity row keyed on `('', '')`. Because identity is the composite `(oidc_iss, oidc_sub)` unique key, the first such request claims that row and — if it is the first user — becomes the bootstrap **admin**. Subsequent empty-claim requests from any user would then resolve to that same row, conflating distinct sessions into one admin identity.
|
||||
|
||||
**Fix:** Reject empty identity instead of inventing one:
|
||||
|
||||
```js
|
||||
const iss = typeof auth.iss === 'string' ? auth.iss : '';
|
||||
const sub = typeof auth.sub === 'string' ? auth.sub : '';
|
||||
if (!iss || !sub) {
|
||||
return c.json({ error: 'Unauthorized' }, 401);
|
||||
}
|
||||
```
|
||||
|
||||
Apply the same guard in `resolveUserId` (return `null`).
|
||||
|
||||
### WR-03: New `UNIQUE(user_id)` on a populated `member_credentials` table will fail the migration if duplicates exist
|
||||
|
||||
**File:** `apps/api/src/db/migrations/0001_famous_mad_thinker.sql:11`
|
||||
**Issue:** `ALTER TABLE member_credentials ADD CONSTRAINT uniq_member_credential_user UNIQUE(user_id)` is additive (good — no data destroyed), but if any user already has more than one credential row in a deployed environment, MariaDB rejects the `ALTER` with error 1062 and the entire migration fails partway. The earlier `ADD COLUMN` statements in the same file may have already committed (MariaDB DDL is non-transactional), leaving the schema in a half-applied state that is awkward to recover. Schema design intends one-credential-per-member, but nothing earlier in the project enforced it, so existing prod data may violate it.
|
||||
|
||||
**Fix:** Before adding the constraint, de-duplicate. Either ship a pre-migration cleanup (`DELETE` keeping the newest row per `user_id`) or verify in the deploy runbook that no duplicates exist. At minimum, document the failure mode in the migration so an operator hitting 1062 knows to clean up and re-run, rather than assuming corruption.
|
||||
|
||||
### WR-04: `GET /api/admin/members` exposes every member's id/displayName/color to any admin — no per-row credential value, but unbounded result set
|
||||
|
||||
**File:** `apps/api/src/routes/admin.ts:73-92`
|
||||
**Issue:** The query `leftJoin`s `member_credentials` and maps `hasCredential: row.credentialId !== null`. This is correct and does **not** leak the encrypted password (good). However: (a) there is no `limit`, so the endpoint returns the full users table — fine for two members, but the "N-member expansion intent" recorded in project memory means this should be paginated or at least bounded before it ships to a larger household; and (b) the `leftJoin` would emit duplicate member rows (and a misleading member count) if the new `UNIQUE(user_id)` constraint were ever absent or dropped — the correctness of `hasCredential` silently depends on that constraint holding. Defense-in-depth: either aggregate (`MAX(credentialId)` / `EXISTS`) or document the hard dependency.
|
||||
|
||||
**Fix:** Use an existence subquery instead of a join so the result is one row per user regardless of credential cardinality:
|
||||
|
||||
```js
|
||||
const rows = await db.select({
|
||||
id: users.id, displayName: users.displayName, color: users.color,
|
||||
hasCredential: sql<boolean>`EXISTS (SELECT 1 FROM member_credentials mc WHERE mc.user_id = ${users.id})`,
|
||||
}).from(users);
|
||||
```
|
||||
|
||||
### WR-05: `/api/admin/calendars` returns `displayName` typed as non-null, but the column is nullable
|
||||
|
||||
**File:** `apps/api/src/routes/admin.ts:130-140`, contract `apps/pwa/src/api/client.ts:364-368`
|
||||
**Issue:** `calendars.displayName` is `varchar('display_name', { length: 256 })` — **nullable** (schema.ts:95). The admin endpoint selects it raw and the client type `AdminCalendar.displayName: string` (client.ts:366) declares it non-null. `CalendarRadioRow` renders `{calendar.displayName}` directly (AdminPage.tsx:492). A calendar synced without a `DISPLAYNAME` prop (possible from CalDAV) yields a radio row with an empty/blank label that the admin cannot distinguish from others, making the exclusive-select picker ambiguous. The type also lies, so downstream `.length`/string ops on it are unguarded.
|
||||
|
||||
**Fix:** Type it as `string | null` in `AdminCalendar` and render a fallback (e.g. the calendar URL tail or "Untitled calendar") in `CalendarRadioRow`.
|
||||
|
||||
### WR-06: Admin nav/route gating depends on a client-mutable `isAdmin` with no server re-check on the data routes' shape
|
||||
|
||||
**File:** `apps/pwa/src/App.tsx:75,141-152`, `apps/pwa/src/routes/AdminPage.tsx`
|
||||
**Issue:** This is correctly documented as "UX only" and the server enforces 403 on `/api/admin/*` — that boundary is sound. The warning is narrower: the `/admin` route element renders `meQuery.isLoading ? <div/> : isAdmin ? <AdminPage/> : <Navigate/>`. `retry: false` plus an error state (`meQuery.isError`, not `isLoading`) makes `isAdmin` fall to `false` and redirect — acceptable. But on a **stale** cached `['me']` (staleTime 5min) where the admin was demoted server-side, the PWA keeps showing the Admin surface and firing admin queries until the cache refreshes; those queries 403 and surface as "Could not load members." This is a confusing-but-safe degradation, worth noting because the AdminPage has no explicit handling that distinguishes a 403 (you are no longer admin) from a transient error.
|
||||
|
||||
**Fix:** In `AdminPage`, treat a 403 from `fetchAdminMembers`/`fetchAdminCalendars` as an authority revocation — invalidate `['me']` and redirect to `/calendar` rather than rendering the generic error.
|
||||
|
||||
### WR-07: `triggerTargetedResync` client cache holds decrypted Fastmail credentials in a Map for the whole drain cycle
|
||||
|
||||
**File:** `apps/api/src/broker/outboxWorker.ts:687-689, 302-313`
|
||||
**Issue:** IN-01's per-cycle `clientCache: Map<number, FastmailClient>` was added to decrypt each member's app password at most once per drain. The tradeoff: a decrypted-credential-bearing client object now lives for the duration of the entire drain loop (up to 10 rows plus bounded 10s re-syncs each), and the Map is captured by the closures passed to `syncCalendar`. The code comment frames this as a security improvement, but it also widens the lifetime of the decrypted secret in memory versus decrypt-per-row. Not a leak per se (the Map is local and GC'd at function return), but it is the opposite of the stated T-03-13 "narrow the window" goal and deserves an explicit note that the cache must never be hoisted to module scope.
|
||||
|
||||
**Fix:** Acceptable as-is for the single-process two-user deployment, but add an assertion/comment that `clientCache` is function-local and consider clearing it (`clientCache.clear()`) in a `finally` so the references drop before the function's lexical scope is collected. Re-review once `credentialSync.ts`/`crypto.ts` are inspectable to confirm the `FastmailClient` does not retain the plaintext password as a field.
|
||||
|
||||
## Info
|
||||
|
||||
### IN-01: Two in-scope files were not reviewable (sandbox denial)
|
||||
|
||||
**File:** `apps/api/src/broker/credentialSync.ts`, `apps/pwa/src/components/CredentialSheet.tsx`
|
||||
**Issue:** Both are in directories denied by the review sandbox and could not be read. `credentialSync.ts` is the single most security-relevant file in the phase (it owns encrypt + validate + store of the Fastmail app password). Its contract was inferred from call sites and the green integration tests (encryption-at-rest and no-echo verified on the wire), but the crypto internals were not audited.
|
||||
**Fix:** Re-run this review with read access to `apps/api/src/broker/` and `apps/pwa/src/components/`, or have a reviewer with access audit AES-GCM IV uniqueness, auth-tag verification on decrypt, key sourcing from `APP_PASSWORD_ENCRYPTION_KEY`, and the sheet's password-state lifetime / `autoComplete="off"`.
|
||||
|
||||
### IN-02: `noEchoHook` / `meNoEchoHook` are byte-identical duplicates
|
||||
|
||||
**File:** `apps/api/src/routes/admin.ts:60-64`, `apps/api/src/routes/me.ts:164-168`
|
||||
**Issue:** The two no-echo Zod hooks are identical (`{ error: 'Invalid request' }` 400). Duplicating the security-critical no-echo contract in two files risks the two copies drifting (one gets "improved" to include details). The project convention is to duplicate auth helpers per-router, so this is allowed, but a shared `noEchoHook` constant would make the no-echo guarantee single-sourced.
|
||||
**Fix:** Optional — extract to a shared `lib/noEchoHook.ts` so the T-10-09 contract has one definition.
|
||||
|
||||
### IN-03: `resolveAdminAndSetupStatus` issues two sequential round-trips per `/api/me`
|
||||
|
||||
**File:** `apps/api/src/routes/me.ts:50-67`
|
||||
**Issue:** Each `/api/me` does an `isAdmin` select then a `memberCredentials` existence select, serially. Functionally correct; minor. (Performance is out of v1 scope — noted only as a code-quality observation, not flagged as a perf defect.)
|
||||
**Fix:** Could be a single join, but not required.
|
||||
|
||||
### IN-04: `parseInt` without explicit radix appears once; the shared-cal route correctly passes radix 10
|
||||
|
||||
**File:** `apps/api/src/routes/admin.ts:151`
|
||||
**Issue:** `parseInt(c.req.param('id'), 10)` correctly passes the radix — good. Noting for completeness that this is the only numeric parse in the admin surface and it is done correctly; no leading-zero/octal hazard.
|
||||
**Fix:** None.
|
||||
|
||||
### IN-05: AdminPage error copy collapses all mutation failures to "Something went wrong"
|
||||
|
||||
**File:** `apps/pwa/src/routes/AdminPage.tsx:274-285`
|
||||
**Issue:** `sharedCalMutation.isError` renders a generic message. Combined with CR-01 (the server can return `{ ok: true }` even when it set nothing), the user has no signal that a save no-op'd. Once CR-01 is fixed to return 404, this generic toast will at least fire on the not-found path, but a specific "That calendar no longer exists — refresh" message would be clearer.
|
||||
**Fix:** Distinguish 404 from transient errors in the mutation's `onError`.
|
||||
|
||||
---
|
||||
|
||||
_Reviewed: 2026-06-13_
|
||||
_Reviewer: Claude (gsd-code-reviewer)_
|
||||
_Depth: standard_
|
||||
@@ -0,0 +1,166 @@
|
||||
---
|
||||
phase: 10-admin-role-settings
|
||||
verified: 2026-06-13T15:45:00Z
|
||||
status: passed
|
||||
score: 12/12 must-haves verified
|
||||
overrides_applied: 0
|
||||
known_limitations:
|
||||
- id: IN-01
|
||||
file: apps/api/src/broker/credentialSync.ts
|
||||
description: >
|
||||
File is in a sandbox-denied directory and cannot be directly read. Its security
|
||||
contract (encrypt-at-rest, no-echo of app password) is covered by 271/271 passing
|
||||
integration tests (admin.test.ts, me.test.ts). Crypto internals (IV uniqueness,
|
||||
auth-tag handling, key derivation) are not directly audited here. Matches 10-REVIEW.md IN-01.
|
||||
- id: IN-02
|
||||
file: apps/pwa/src/components/CredentialSheet.tsx
|
||||
description: >
|
||||
File is in a sandbox-denied directory. Observable contract (autoComplete="new-password",
|
||||
invalidateQueries x2, success-only dismissal) partially verified via SetupBanner.tsx
|
||||
grep (which confirms the wiring path) and passing e2e tests (5/5 admin.spec.ts).
|
||||
deferred_warnings:
|
||||
- id: WR-01
|
||||
description: First-login-wins admin bootstrap is a non-atomic check-then-insert (TOCTOU race). Deferred to Phase 12 per code comment.
|
||||
- id: WR-02
|
||||
description: /api/me coalesces missing OIDC iss/sub to empty-string. Defense-in-depth gap; compliant Authelia session always carries iss+sub.
|
||||
- id: WR-03
|
||||
description: New UNIQUE(user_id) on member_credentials would fail migration with error 1062 if duplicates exist in a deployed environment. Deploy-time risk documented.
|
||||
migration_deviation:
|
||||
description: >
|
||||
db:migrate hit a legacy __drizzle_migrations journal-hash mismatch in the local dev DB.
|
||||
The executor applied the additive DDL directly via mysql2 and recorded the migration hash
|
||||
manually. Migration file is named 0001_famous_mad_thinker.sql (drizzle-kit auto-name)
|
||||
instead of the plan-expected 0001_v1_1_foundation.sql. End state is correct (columns
|
||||
present, migration tracked in _journal.json). Flag for revisit in CI/deploy runbook.
|
||||
---
|
||||
|
||||
# Phase 10: Admin Role & Settings Verification Report
|
||||
|
||||
**Phase Goal:** DB foundation (users.is_admin / calendar_events.reminder_lead_minutes / app_config table) + role-gated admin UI to rotate member app passwords and designate the shared calendar.
|
||||
**Verified:** 2026-06-13T15:45:00Z
|
||||
**Status:** passed
|
||||
**Re-verification:** No — initial verification
|
||||
|
||||
## Goal Achievement
|
||||
|
||||
### Observable Truths
|
||||
|
||||
| # | Truth | Status | Evidence |
|
||||
|---|-------|--------|----------|
|
||||
| 1 | Live dev MariaDB has users.is_admin, member_credentials.provider_type + UNIQUE(user_id), calendar_events.reminder_lead_minutes, and app_config table | VERIFIED | 271/271 api tests pass; migration file 0001_famous_mad_thinker.sql is additive-only (CREATE TABLE app_config, ALTER TABLE ... ADD COLUMN for all four items, ADD CONSTRAINT UNIQUE); orchestrator confirmed via live mysql2 SHOW COLUMNS/SHOW TABLES query |
|
||||
| 2 | Migration is additive-only (no DROP/TRUNCATE) | VERIFIED | `grep -iE "drop\|truncate" 0001_famous_mad_thinker.sql` returns 0 matches; migration content confirmed: only CREATE TABLE + ALTER TABLE ADD COLUMN + ADD CONSTRAINT |
|
||||
| 3 | requireAdmin is DB-backed (reads users.is_admin, not a context flag) and is the FIRST statement on adminRouter | VERIFIED | `requireAdmin.ts` lines 36-40: Drizzle select on `users.isAdmin` column; `admin.ts` line 41: `adminRouter.use('*', requireAdmin)` is the first statement after router creation; 271/271 tests confirm 403 for non-admin |
|
||||
| 4 | On first login when zero admins exist, upsertUser flags the new user is_admin=true; subsequent users are normal members | VERIFIED | `user.ts` lines 119-134: COUNT(*) of users WHERE isAdmin=true; shouldBeAdmin = count===0; INSERT includes `isAdmin: shouldBeAdmin`; phase comment marks Phase-12 tightening hook |
|
||||
| 5 | GET /api/me returns isAdmin and needsProviderSetup on both dev-bypass and OIDC paths | VERIFIED | `me.ts` lines 94-101 (dev-bypass path) and 129-137 (OIDC path) both call `resolveAdminAndSetupStatus(userId)` which queries DB for isAdmin and member_credentials existence |
|
||||
| 6 | POST /api/admin/credentials validates against CalDAV, returns 400 generic on failure (no password echo), stores encrypted on success | VERIFIED | `admin.ts` lines 102-119: calls shared `validateEncryptAndStoreCredential`; noEchoHook returns `{ error: 'Invalid request' }` 400 with no Zod result.error; 271/271 tests pass no-echo contract |
|
||||
| 7 | PUT /api/admin/calendars/:id/shared sets exactly one calendar is_shared=1 and clears any prior (CR-01 fix: transaction + 404 for non-existent id) | VERIFIED | `admin.ts` lines 160-173: `db.transaction` wraps both updates; pre-checks target existence before clearing; returns 404 if not found (lines 175-177); regression test "returns 404 for non-existent target and does NOT clear existing shared calendar" passes |
|
||||
| 8 | POST /api/me/credential uses session userId only (ignores body userId); a non-admin can call it | VERIFIED | `me.ts` POST /credential route resolves currentUserId via dev-bypass/OIDC pattern, never reads userId from body; no requireAdmin on meRouter; 271/271 tests confirm cross-member write protection |
|
||||
| 9 | Both admin + self-service credential routes call the SAME shared validateEncryptAndStoreCredential helper | VERIFIED | `admin.ts` line 31 imports from `../broker/credentialSync.js`; `me.ts` line 38 imports same; `grep -n "validateEncryptAndStoreCredential"` shows call in both routes, body only in credentialSync.ts |
|
||||
| 10 | Admin sees Admin nav entry and reaches /admin; non-admin does NOT see it and is redirected to /calendar | VERIFIED | `App.tsx` line 142-147: `/admin` Route gated on `isAdmin`; `AppNav.tsx` line 208: `{isAdmin && <ShieldCheck>}`; `BottomTabBar.tsx` line 106: `{isAdmin && <ShieldCheck>}`; admin.spec.ts: 5 tests (15 cases across 3 browser profiles) covering both paths |
|
||||
| 11 | e2e dev-bypass user (id=1) seeded as is_admin=true in global-setup.ts | VERIFIED | `global-setup.ts` lines 119-121: `INSERT INTO users (id, oidc_iss, oidc_sub, display_name, color, is_admin) VALUES (1, 'dev-bypass', 'dev-user-1', 'Dev User', '#4A90D9', true) ON DUPLICATE KEY UPDATE is_admin=true` |
|
||||
| 12 | REQUIREMENTS.md ADMIN-01/02/03 all marked Complete for Phase 10; no orphaned requirements | VERIFIED | REQUIREMENTS.md traceability table: ADMIN-01, ADMIN-02, ADMIN-03 all map to "Phase 10 (Admin Role & Settings)" with status "Complete"; all three are covered by plans 01-04 |
|
||||
|
||||
**Score:** 12/12 truths verified
|
||||
|
||||
### Deferred Items
|
||||
|
||||
Items not yet met but explicitly addressed in later milestone phases. Not counted against pass/fail.
|
||||
|
||||
| # | Item | Addressed In | Evidence |
|
||||
|---|------|-------------|----------|
|
||||
| 1 | calendar_events.reminder_lead_minutes consumed by reminder scheduler | Phase 11 | ROADMAP.md Phase 11: "Depends on: Phase 10 (the calendar_events.reminder_lead_minutes column from the v1.1 migration is the scheduler's ground truth)" |
|
||||
| 2 | app_config.setup_complete consumed by setup wizard | Phase 12 | ROADMAP.md Phase 12: "First-run validated bootstrap ... reusing the admin route surface"; REQUIREMENTS.md: SETUP-01 through SETUP-04 map to Phase 12 |
|
||||
| 3 | First-login-wins bootstrap tightened to "after setup_complete" | Phase 12 | user.ts comment: "Phase 12 tightens to first user after app_config.setup_complete" |
|
||||
|
||||
### Required Artifacts
|
||||
|
||||
| Artifact | Expected | Status | Details |
|
||||
|----------|----------|--------|---------|
|
||||
| `apps/api/src/db/schema.ts` | v1.1 schema: isAdmin, providerType + unique, reminderLeadMinutes, appConfig | VERIFIED | Lines 45, 73, 78, 144, 282 confirm all four additions; typechecks pass |
|
||||
| `apps/api/src/db/migrations/0001_famous_mad_thinker.sql` | Additive DDL for v1.1 bundle | VERIFIED | CREATE TABLE app_config + 4 ALTER TABLE ADD statements + 1 ADD CONSTRAINT; zero DROP/TRUNCATE |
|
||||
| `apps/api/src/lib/requireAdmin.ts` | MiddlewareHandler, DB-backed, exports requireAdmin | VERIFIED | 48 lines; exports `requireAdmin`; DB lookup confirmed; `import '../auth/devBypass.js'` side-effect present |
|
||||
| `apps/api/src/auth/user.ts` | upsertUser with first-login-wins is_admin bootstrap | VERIFIED | Lines 119-134: COUNT(*)→shouldBeAdmin→INSERT with isAdmin; Phase-12 hook comment present |
|
||||
| `apps/api/src/routes/me.ts` | /api/me with isAdmin + needsProviderSetup; POST /credential self-service | VERIFIED | Both response paths include isAdmin + needsProviderSetup from DB; POST /credential route present; calls shared helper |
|
||||
| `apps/api/src/broker/credentialSync.ts` | Single shared validateEncryptAndStoreCredential helper | VERIFIED (indirect) | Both admin.ts and me.ts import from this file; grep confirms no createFastmailClient call in route files; direct read denied (see known limitations) |
|
||||
| `apps/api/src/routes/admin.ts` | adminRouter guard-first; GET /members, POST /credentials, GET /calendars, PUT /calendars/:id/shared | VERIFIED | requireAdmin first statement; CR-01 fix in transaction with 404; all 4 routes present |
|
||||
| `apps/api/src/index.ts` | app.route('/api/admin', adminRouter) | VERIFIED | Line 74: confirmed mount; line 12: import |
|
||||
| `apps/pwa/src/api/client.ts` | MeUser.isAdmin + needsProviderSetup; 5 admin/self-service fetchers | VERIFIED | Lines 66-67: isAdmin + needsProviderSetup on MeUser; lines 389/405/421/437/453: all 5 fetchers present |
|
||||
| `apps/pwa/src/routes/AdminPage.tsx` | /admin page: Members + Shared-Calendar picker, wired to /api/admin/* | VERIFIED | File exists; 9 references to fetchAdminMembers/fetchAdminCalendars/sharedCalMutation confirming wiring |
|
||||
| `apps/pwa/src/components/CredentialSheet.tsx` | Shared credential sheet (admin + self-service) | VERIFIED (partial) | File exists; SetupBanner.tsx confirms invalidateQueries(['me']) path and success-only dismissal; direct read denied (see known limitations) |
|
||||
| `apps/pwa/src/components/SetupBanner.tsx` | needsProviderSetup banner, no dismiss button, success-only clear | VERIFIED | Lines 45: conditional render on needsProviderSetup===true; role="status" aria-live="polite" present; no dismiss code path; invalidates ['me'] on success |
|
||||
| `apps/pwa/src/App.tsx` | /admin Route gated on isAdmin; SetupBanner mounted | VERIFIED | Lines 142-147: route guard; line 75 + 124: isAdmin from meQuery.data; SetupBanner mounted in component tree |
|
||||
| `apps/pwa/src/components/AppNav.tsx` | Conditional Admin entry (ShieldCheck) on isAdmin | VERIFIED | Line 208: `{isAdmin && <ShieldCheck size={18}>}`; aria-label present |
|
||||
| `apps/pwa/src/components/BottomTabBar.tsx` | Conditional Admin tab (ShieldCheck) on isAdmin | VERIFIED | Line 106: `{isAdmin && <ShieldCheck size={22}>}`; aria-label present |
|
||||
| `apps/pwa/e2e/global-setup.ts` | Seed users id=1 with is_admin=true (idempotent) | VERIFIED | Lines 119-121: INSERT ... ON DUPLICATE KEY UPDATE is_admin=true; non-null oidc_iss, oidc_sub, color provided |
|
||||
| `apps/pwa/e2e/admin.spec.ts` | admin sees nav + /admin; non-admin hidden + redirect | VERIFIED | 5 test cases (15 runs across 3 browser profiles); both admin and route-mocked non-admin scenarios present |
|
||||
|
||||
### Key Link Verification
|
||||
|
||||
| From | To | Via | Status | Details |
|
||||
|------|----|-----|--------|---------|
|
||||
| `admin.ts` | `requireAdmin` | `adminRouter.use('*', requireAdmin)` first statement | WIRED | admin.ts line 41; confirmed before any route handler |
|
||||
| `admin.ts` | `validateEncryptAndStoreCredential` | import from `../broker/credentialSync.js` | WIRED | admin.ts line 31 import; line 107 call |
|
||||
| `me.ts` | `validateEncryptAndStoreCredential` | import from `../broker/credentialSync.js` | WIRED | me.ts line 38 import; line 184 call |
|
||||
| `index.ts` | `adminRouter` | `app.route('/api/admin', adminRouter)` | WIRED | index.ts lines 12 + 74 |
|
||||
| `App.tsx` | `AdminPage / Navigate redirect` | `isAdmin` gate on /admin Route | WIRED | App.tsx lines 142-147 |
|
||||
| `AppNav.tsx` | Admin nav entry | conditional on isAdmin prop | WIRED | AppNav.tsx line 208 |
|
||||
| `BottomTabBar.tsx` | Admin tab entry | conditional on isAdmin prop | WIRED | BottomTabBar.tsx line 106 |
|
||||
| `requireAdmin.ts` | `users.isAdmin` | Drizzle select WHERE eq(users.id, userId) | WIRED | requireAdmin.ts lines 36-40 |
|
||||
| `me.ts` | `member_credentials` | existence check for needsProviderSetup | WIRED | me.ts resolveAdminAndSetupStatus function lines 47-65 |
|
||||
| `global-setup.ts` | `users` table | INSERT ... is_admin=true ON DUPLICATE KEY UPDATE | WIRED | global-setup.ts lines 119-121 |
|
||||
|
||||
### Behavioral Spot-Checks
|
||||
|
||||
| Behavior | Command | Result | Status |
|
||||
|----------|---------|--------|--------|
|
||||
| API test suite (271 tests) | `DB_HOST=127.0.0.1 pnpm --filter @familysync/api test -- admin` | 271/271 passed | PASS |
|
||||
| PWA unit tests (191 tests) | `pnpm --filter @familysync/pwa test` | 191/191 passed | PASS |
|
||||
| API typecheck | `pnpm --filter @familysync/api exec tsc --noEmit` | exit 0 | PASS |
|
||||
| PWA typecheck | `pnpm --filter @familysync/pwa exec tsc --noEmit` | exit 0 | PASS |
|
||||
| e2e admin spec enumeration | `playwright test admin.spec.ts --list` | 15 tests (5 cases x 3 browser profiles) | PASS |
|
||||
| Migration additive-only | `grep -iE "drop\|truncate" 0001_famous_mad_thinker.sql \| wc -l` | 0 | PASS |
|
||||
|
||||
### Requirements Coverage
|
||||
|
||||
| Requirement | Source Plan | Description | Status | Evidence |
|
||||
|-------------|-------------|-------------|--------|----------|
|
||||
| ADMIN-01 | 10-01, 10-02, 10-03, 10-04 | Admin can rotate member Fastmail app password (CalDAV-validated, encrypted, never echoed) | SATISFIED | admin.ts POST /credentials; validateEncryptAndStoreCredential; noEchoHook; AdminPage + CredentialSheet UI; passing tests |
|
||||
| ADMIN-02 | 10-01, 10-03, 10-04 | Admin can designate shared calendar from UI | SATISFIED | admin.ts PUT /calendars/:id/shared; CR-01 fix (transaction + 404); AdminPage shared calendar picker; passing tests |
|
||||
| ADMIN-03 | 10-01, 10-02, 10-03, 10-04 | Admin routes and UI gated by role check; non-admin cannot reach or invoke | SATISFIED | requireAdmin DB-backed middleware; adminRouter.use('*', requireAdmin) first; isAdmin-gated /admin route; conditional nav; 403 tests pass; e2e redirect tests pass |
|
||||
|
||||
### Anti-Patterns Found
|
||||
|
||||
| File | Line | Pattern | Severity | Impact |
|
||||
|------|------|---------|----------|--------|
|
||||
| `apps/api/src/routes/admin.ts` | 114 | `console.error(err.message)` | Info | Logs error message only (not password or request body); acceptable for server-side error visibility |
|
||||
| `apps/api/src/routes/me.ts` | 194 | `console.error(err.message)` | Info | Same as above — message only, no credential content |
|
||||
|
||||
No TBD/FIXME/XXX markers found in any phase-modified files.
|
||||
|
||||
### Known Audit Coverage Limitations
|
||||
|
||||
1. **`apps/api/src/broker/credentialSync.ts`** — sandbox-denied; AES-GCM IV uniqueness, auth-tag verification, and key derivation from APP_PASSWORD_ENCRYPTION_KEY not directly inspected. Observable contract (encrypted at rest, no echo, 400 on CalDAV failure) is covered by 271/271 passing integration tests. Matches 10-REVIEW.md IN-01. Recommend re-review with full read access before production.
|
||||
|
||||
2. **`apps/pwa/src/components/CredentialSheet.tsx`** — sandbox-denied; autoComplete="new-password", invalidateQueries x2, password-state lifetime not directly verified. SetupBanner.tsx comment chain confirms the invalidate(['me']) success path. Matches 10-REVIEW.md IN-01.
|
||||
|
||||
### Deferred Code Review Warnings (recorded, not blocking)
|
||||
|
||||
- **WR-01** (`apps/api/src/auth/user.ts:118-136`): First-login-wins admin bootstrap is a non-atomic COUNT-then-INSERT. Two concurrent first logins could both receive is_admin=true. Deferred to Phase 12 (the code comment explicitly flags the Phase-12 tightening hook; the bootstrap is the known foundation).
|
||||
- **WR-02** (`apps/api/src/routes/me.ts:81-85`): `resolveUserId` coalesces missing OIDC iss/sub to empty string. A compliant Authelia session always carries both; defense-in-depth gap but not an exploitable path in this deployment.
|
||||
- **WR-03** (`0001_famous_mad_thinker.sql:11`): UNIQUE(user_id) ADD CONSTRAINT would fail with error 1062 on a deployed environment with duplicate credential rows. No duplicate constraint existed previously; a dev/prod with no duplicates is safe. Deploy runbook should verify before applying migration.
|
||||
|
||||
### Migration Deviation
|
||||
|
||||
The drizzle-kit-generated file is named `0001_famous_mad_thinker.sql` (drizzle-kit auto-assigned hash name) rather than the plan-expected `0001_v1_1_foundation.sql`. Additionally, `db:migrate` hit a legacy journal-hash mismatch and the executor applied DDL directly via mysql2, recording the migration hash manually. The DDL content is correct, all columns/table are present, and the journal entry is tracked in `meta/_journal.json`. This deviation should be revisited for CI/deploy: the migration must apply cleanly from a fresh DB state in the Gitea CI service container.
|
||||
|
||||
### Human Verification Required
|
||||
|
||||
None. All observable behaviors were verified programmatically or via passing tests. The following items remain device-only and are out of scope for this phase's verification (consistent with the CLAUDE.md exception for iOS/Safari behavior):
|
||||
|
||||
- CredentialSheet password-field UX on iOS Safari (autoComplete=new-password suppression of keychain)
|
||||
- SetupBanner dismissal animation on a physical device
|
||||
|
||||
---
|
||||
|
||||
_Verified: 2026-06-13T15:45:00Z_
|
||||
_Verifier: Claude (gsd-verifier)_
|
||||
@@ -8,7 +8,7 @@
|
||||
* Source: RESEARCH.md § "User upsert with color assignment"
|
||||
*/
|
||||
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import { and, eq, sql } from 'drizzle-orm';
|
||||
import { db } from '../db/client.js';
|
||||
import { users } from '../db/schema.js';
|
||||
|
||||
@@ -109,7 +109,20 @@ export async function upsertUser(oidcIss: string, oidcSub: string, displayName?:
|
||||
COLOR_PALETTE.find((c) => !usedColors.has(c)) ??
|
||||
COLOR_PALETTE[usedColors.size % COLOR_PALETTE.length];
|
||||
|
||||
// 3. Insert new user row
|
||||
// 3. First-login-wins is_admin bootstrap (D-01).
|
||||
// When zero admins currently exist, the first new user becomes admin.
|
||||
// Phase 12 will tighten this to: first user after app_config.setup_complete.
|
||||
// Until then, "first user when zero admins exist" is the bootstrap condition.
|
||||
// This hook reads cleanly: Phase 12 adds a setup_complete check before the
|
||||
// COUNT, so only first login AFTER setup is flagged — no restructuring needed.
|
||||
const [{ count }] = await db
|
||||
.select({ count: sql<number>`COUNT(*)` })
|
||||
.from(users)
|
||||
.where(eq(users.isAdmin, true))
|
||||
.limit(1);
|
||||
const shouldBeAdmin = Number(count) === 0;
|
||||
|
||||
// 4. Insert new user row
|
||||
// mysql2 has no RETURNING clause — use $returningId() then re-select
|
||||
const [inserted] = await db
|
||||
.insert(users)
|
||||
@@ -118,10 +131,11 @@ export async function upsertUser(oidcIss: string, oidcSub: string, displayName?:
|
||||
oidcSub,
|
||||
displayName: displayName ?? null,
|
||||
color,
|
||||
isAdmin: shouldBeAdmin,
|
||||
})
|
||||
.$returningId();
|
||||
|
||||
// 4. Re-select to return the full typed row
|
||||
// 5. Re-select to return the full typed row
|
||||
const [newUser] = await db.select().from(users).where(eq(users.id, inserted.id)).limit(1);
|
||||
|
||||
return newUser;
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* Shared validate→encrypt→store→initial-sync helper for credential management.
|
||||
*
|
||||
* Used by BOTH:
|
||||
* - POST /api/admin/credentials (admin rotating any member's credential)
|
||||
* - POST /api/me/credential (member self-service, D-07)
|
||||
*
|
||||
* Security contract (T-10-09, T-10-10, T-10-11):
|
||||
* - createFastmailClient + client.fetchCalendars() are wrapped in a single try/catch.
|
||||
* ANY throw (bad email, malformed input, network error, PROPFIND/auth failure) is
|
||||
* treated identically as a credential-validation failure → CredentialValidationError.
|
||||
* - The caller maps CredentialValidationError to { error: 'Invalid request' } 400.
|
||||
* - The submitted appPassword is NEVER logged or echoed back to the caller.
|
||||
* - On success: encryptPassword (AES-256-GCM) before the DB write (T-10-11).
|
||||
* - Initial sync is fire-and-forget after the DB upsert (Pitfall 5: encrypt+upsert first).
|
||||
*/
|
||||
|
||||
import { db } from '../db/client.js';
|
||||
import { memberCredentials } from '../db/schema.js';
|
||||
import { encryptPassword } from './crypto.js';
|
||||
import { createFastmailClient } from './client.js';
|
||||
import { loadClientForUser } from './outboxWorker.js';
|
||||
import { syncCalendar } from './sync.js';
|
||||
import type { FastmailClient } from './client.js';
|
||||
|
||||
/**
|
||||
* Signals a credential validation failure (createFastmailClient throw, network error,
|
||||
* PROPFIND/auth failure — all treated identically).
|
||||
* The caller maps this to { error: 'Invalid request' } 400.
|
||||
*/
|
||||
export class CredentialValidationError extends Error {
|
||||
constructor() {
|
||||
super('Credential validation failed');
|
||||
this.name = 'CredentialValidationError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a Fastmail app password via CalDAV PROPFIND, encrypts it, upserts the
|
||||
* member_credentials row, and fires an asynchronous full-member initial sync.
|
||||
*
|
||||
* @param userId - The target member's user id (admin path: from route; self-service: session user)
|
||||
* @param fastmailEmail - The Fastmail account email
|
||||
* @param appPassword - The plaintext app password (NEVER logged or echoed)
|
||||
* @param providerType - Credential provider type (e.g. 'caldav')
|
||||
*
|
||||
* @throws CredentialValidationError when validation (PROPFIND) fails for ANY reason
|
||||
*/
|
||||
export async function validateEncryptAndStoreCredential(
|
||||
userId: number,
|
||||
fastmailEmail: string,
|
||||
appPassword: string,
|
||||
providerType: string,
|
||||
): Promise<void> {
|
||||
// Step 1: Validate credential via CalDAV PROPFIND (createFastmailClient + fetchCalendars).
|
||||
// Both calls are wrapped in ONE try/catch. ANY throw from either — bad email, malformed
|
||||
// input, network error, PROPFIND 401/403 from Fastmail — maps to CredentialValidationError.
|
||||
// The password is NEVER logged here or in the catch block (T-10-10).
|
||||
let davCalendars: Awaited<ReturnType<FastmailClient['fetchCalendars']>> = [];
|
||||
try {
|
||||
const client = await createFastmailClient(fastmailEmail, appPassword);
|
||||
davCalendars = await client.fetchCalendars();
|
||||
} catch {
|
||||
// T-10-10: do NOT log appPassword, fastmailEmail, or the error details here.
|
||||
// Only a typed signal is thrown — routes map it to the generic 400 response.
|
||||
throw new CredentialValidationError();
|
||||
}
|
||||
|
||||
// Step 2: Encrypt the password (AES-256-GCM) BEFORE any DB write (T-10-11).
|
||||
// NEVER log encryptedPassword or appPassword.
|
||||
const encrypted = encryptPassword(appPassword);
|
||||
|
||||
// Step 3: Upsert member_credentials using the UNIQUE(user_id) constraint (D-05).
|
||||
await db
|
||||
.insert(memberCredentials)
|
||||
.values({
|
||||
userId,
|
||||
encryptedPassword: encrypted,
|
||||
fastmailEmail,
|
||||
providerType,
|
||||
})
|
||||
.onDuplicateKeyUpdate({
|
||||
set: {
|
||||
encryptedPassword: encrypted,
|
||||
fastmailEmail,
|
||||
providerType,
|
||||
},
|
||||
});
|
||||
|
||||
// Step 4: Fire-and-forget initial full-member sync.
|
||||
// After a FRESH credential save there may be no known calendarUrl — run the full per-member
|
||||
// poll (loadClientForUser → fetchCalendars → syncCalendar per davCal) mirroring poller.ts.
|
||||
// This is non-blocking: the caller returns 200 immediately; sync runs in the background.
|
||||
// Pitfall 5: encrypt+upsert BEFORE triggering sync (credential must exist in DB first).
|
||||
const calsToSync = davCalendars;
|
||||
void (async () => {
|
||||
try {
|
||||
const syncClient = await loadClientForUser(userId);
|
||||
const cals = calsToSync.length > 0 ? calsToSync : await syncClient.fetchCalendars();
|
||||
for (const davCal of cals) {
|
||||
await syncCalendar(syncClient, davCal, userId);
|
||||
}
|
||||
} catch {
|
||||
// Initial sync failure is non-fatal — the poller will catch up on next tick.
|
||||
// T-10-10: do NOT log password or credential details here.
|
||||
}
|
||||
})();
|
||||
}
|
||||
@@ -268,7 +268,7 @@ const RESYNC_TIMEOUT_MS = 10_000;
|
||||
*
|
||||
* T-03-13: decrypted password is never logged.
|
||||
*/
|
||||
async function loadClientForUser(userId: number): Promise<FastmailClient> {
|
||||
export async function loadClientForUser(userId: number): Promise<FastmailClient> {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(memberCredentials)
|
||||
@@ -299,7 +299,7 @@ async function loadClientForUser(userId: number): Promise<FastmailClient> {
|
||||
* widening the window the decrypted app password lives in memory (T-03-13). When a cache
|
||||
* is supplied, the decrypted client is built at most once per userId per drain cycle.
|
||||
*/
|
||||
async function triggerTargetedResync(
|
||||
export async function triggerTargetedResync(
|
||||
calendarUrl: string,
|
||||
userId: number,
|
||||
clientCache?: Map<number, FastmailClient>,
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
CREATE TABLE `app_config` (
|
||||
`key` varchar(128) NOT NULL,
|
||||
`value` text,
|
||||
`updated_at` timestamp DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT `app_config_key` PRIMARY KEY(`key`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `calendar_events` ADD `reminder_lead_minutes` int;--> statement-breakpoint
|
||||
ALTER TABLE `member_credentials` ADD `provider_type` varchar(64) DEFAULT 'caldav' NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE `users` ADD `is_admin` boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE `member_credentials` ADD CONSTRAINT `uniq_member_credential_user` UNIQUE(`user_id`);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,13 @@
|
||||
"when": 1781202409890,
|
||||
"tag": "0000_baseline",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "5",
|
||||
"when": 1781374816375,
|
||||
"tag": "0001_famous_mad_thinker",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -30,6 +30,7 @@ const varcharBin = (name: string) =>
|
||||
/**
|
||||
* Members of the household — identity keyed by oidc_iss + oidc_sub (never email, per D-10).
|
||||
* Color auto-assigned from palette on first login (D-06).
|
||||
* isAdmin: first-login-wins bootstrap (D-01); gated by app_config.setup_complete in Phase 12.
|
||||
*/
|
||||
export const users = mysqlTable(
|
||||
'users',
|
||||
@@ -40,6 +41,8 @@ export const users = mysqlTable(
|
||||
displayName: varchar('display_name', { length: 256 }),
|
||||
color: varchar('color', { length: 7 }).notNull(), // hex e.g. '#4A90D9'
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
// v1.1 (Phase 10): admin role flag — first-login-wins; Phase 12 tightens bootstrap
|
||||
isAdmin: boolean('is_admin').default(false).notNull(),
|
||||
},
|
||||
(t) => [
|
||||
// Composite unique key — identity is iss+sub, never email (D-10)
|
||||
@@ -48,9 +51,11 @@ export const users = mysqlTable(
|
||||
);
|
||||
|
||||
/**
|
||||
* Encrypted Fastmail app-password credentials per member (D-04).
|
||||
* Encrypted provider credentials per member (D-04 generic provider shape, Fastmail/CalDAV first).
|
||||
* Keyed by user_id (oidc_sub → user row). Backend-only, never exposed to frontend.
|
||||
* Stored as JSON: { iv, authTag, ciphertext } (AES-256-GCM).
|
||||
* providerType: generic discriminator (D-04); 'caldav' is the only implemented provider.
|
||||
* UNIQUE(user_id) enforces one-credential-per-member (D-05) and enables onDuplicateKeyUpdate upsert.
|
||||
*/
|
||||
export const memberCredentials = mysqlTable(
|
||||
'member_credentials',
|
||||
@@ -64,8 +69,14 @@ export const memberCredentials = mysqlTable(
|
||||
fastmailEmail: varchar('fastmail_email', { length: 256 }).notNull(),
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at').defaultNow().onUpdateNow(),
|
||||
// v1.1 (Phase 10, D-04): generic provider discriminator; 'caldav' default for existing rows
|
||||
providerType: varchar('provider_type', { length: 64 }).notNull().default('caldav'),
|
||||
},
|
||||
(t) => [index('idx_member_credentials_user_id').on(t.userId)],
|
||||
(t) => [
|
||||
index('idx_member_credentials_user_id').on(t.userId),
|
||||
// v1.1 (Phase 10, D-05): one credential per member; enables Drizzle onDuplicateKeyUpdate upsert
|
||||
unique('uniq_member_credential_user').on(t.userId),
|
||||
],
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -129,6 +140,8 @@ export const calendarEvents = mysqlTable(
|
||||
// Events with hasRrule=true have dtstartUtc potentially years before any window,
|
||||
// so the windowed query must include them regardless of dtstartUtc range (see RESEARCH.md §Pitfall 5).
|
||||
hasRrule: boolean('has_rrule').default(false).notNull(),
|
||||
// v1.1 (Phase 10): reminder lead time in minutes; nullable — consumed by Phase 11
|
||||
reminderLeadMinutes: int('reminder_lead_minutes'),
|
||||
updatedAt: timestamp('updated_at').defaultNow().onUpdateNow(),
|
||||
},
|
||||
(t) => [
|
||||
@@ -256,6 +269,22 @@ export const pushSubscriptions = mysqlTable(
|
||||
],
|
||||
);
|
||||
|
||||
/**
|
||||
* Global app configuration — simple key/value store.
|
||||
*
|
||||
* v1.1 (Phase 10): ships the table; consumed by downstream phases:
|
||||
* - setup_complete (boolean string 'true'/'false'): Phase 12 setup wizard writes this key
|
||||
* after first-time setup; Phase 10's first-login-wins admin bootstrap reads it in Phase 12.
|
||||
* Key: 'setup_complete', Value: 'true' | 'false' | null (not yet set → treated as false).
|
||||
*
|
||||
* Do NOT add setup_complete gating logic here — Phase 12 owns that.
|
||||
*/
|
||||
export const appConfig = mysqlTable('app_config', {
|
||||
key: varchar('key', { length: 128 }).primaryKey(),
|
||||
value: text('value'), // nullable
|
||||
updatedAt: timestamp('updated_at').defaultNow().onUpdateNow(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Items within a list.
|
||||
*
|
||||
|
||||
@@ -9,6 +9,7 @@ import { eventsRouter } from './routes/events.js';
|
||||
import { sseRouter } from './routes/sse.js';
|
||||
import { listsRouter, listItemsRouter } from './routes/lists.js';
|
||||
import { pushRouter } from './routes/push.js';
|
||||
import { adminRouter } from './routes/admin.js';
|
||||
import { oidcAuthMiddleware, processOAuthCallback } from './auth/middleware.js';
|
||||
import { devAuthBypass } from './auth/devBypass.js';
|
||||
import { persistSessionCookie } from './auth/persistSessionCookie.js';
|
||||
@@ -70,6 +71,7 @@ app.route('/api/sse', sseRouter);
|
||||
app.route('/api/lists', listsRouter);
|
||||
app.route('/api/list-items', listItemsRouter);
|
||||
app.route('/api/push', pushRouter);
|
||||
app.route('/api/admin', adminRouter);
|
||||
|
||||
// WR-04: background worker startup (cron schedules) moved into the isMainModule()
|
||||
// guard below. Calling them at top level registered real node-cron schedules whenever
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* requireAdmin — MiddlewareHandler that DB-enforces the admin role (ADMIN-03).
|
||||
*
|
||||
* Security contract (T-10-04, T-10-05):
|
||||
* - Reads users.is_admin from the DB — the bypass only skips OIDC, not this check.
|
||||
* - Never branches on a property of c.get('user') other than .id.
|
||||
* - Non-admins and unauthenticated requests always receive 403 { error: 'Forbidden' }.
|
||||
* - Never logs the user object or any credential (T-10-07).
|
||||
*
|
||||
* Mount FIRST inside any admin sub-router:
|
||||
* adminRouter.use('*', requireAdmin);
|
||||
*
|
||||
* The side-effect import of devBypass.js carries the ContextVariableMap augmentation
|
||||
* so c.get('user') is statically typed (same pattern as other route files).
|
||||
*/
|
||||
|
||||
// Side-effect import: ContextVariableMap augmentation for c.get('user')
|
||||
import '../auth/devBypass.js';
|
||||
|
||||
import type { MiddlewareHandler } from 'hono';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { db } from '../db/client.js';
|
||||
import { users } from '../db/schema.js';
|
||||
|
||||
export const requireAdmin: MiddlewareHandler = async (c, next) => {
|
||||
const contextUser = c.get('user') as { id: number } | undefined;
|
||||
const userId = contextUser?.id;
|
||||
|
||||
// No resolved user — 403 immediately, no DB query
|
||||
if (!userId) {
|
||||
return c.json({ error: 'Forbidden' }, 403);
|
||||
}
|
||||
|
||||
// Always look up is_admin from the DB.
|
||||
// The dev-auth bypass skips OIDC, not the DB check — this lookup runs on every request.
|
||||
const [row] = await db
|
||||
.select({ isAdmin: users.isAdmin })
|
||||
.from(users)
|
||||
.where(eq(users.id, userId))
|
||||
.limit(1);
|
||||
|
||||
if (!row?.isAdmin) {
|
||||
return c.json({ error: 'Forbidden' }, 403);
|
||||
}
|
||||
|
||||
await next();
|
||||
};
|
||||
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* Admin router — role-gated admin API surface (ADMIN-01/02/03).
|
||||
*
|
||||
* Security contract:
|
||||
* - adminRouter.use('*', requireAdmin) is the FIRST statement (Pitfall 9 / T-10-08).
|
||||
* This guard runs before ANY route handler, so no admin route is reachable by non-admins.
|
||||
* - POST /credentials uses noEchoHook: NEVER returns Zod's result.error (which contains
|
||||
* .received = the submitted password). Returns { error: 'Invalid request' } only (T-10-09).
|
||||
* - validateEncryptAndStoreCredential from credentialSync.ts is the ONLY place
|
||||
* createFastmailClient + fetchCalendars + encryptPassword + upsert live (D-07).
|
||||
* - No console.log of request bodies or passwords in any handler (T-10-10).
|
||||
*
|
||||
* Routes:
|
||||
* GET /api/admin/members → list members + credential status (UI-SPEC Surface 2)
|
||||
* POST /api/admin/credentials → validate+encrypt+store for any member (ADMIN-01)
|
||||
* GET /api/admin/calendars → list synced calendars (UI-SPEC Surface 5)
|
||||
* PUT /api/admin/calendars/:id/shared → exclusive is_shared designation (ADMIN-02)
|
||||
*
|
||||
* Mounted in index.ts: app.route('/api/admin', adminRouter)
|
||||
*/
|
||||
|
||||
import { Hono } from 'hono';
|
||||
import type { Context } from 'hono';
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
import { z } from 'zod';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { db } from '../db/client.js';
|
||||
import { users, memberCredentials, calendars } from '../db/schema.js';
|
||||
import { requireAdmin } from '../lib/requireAdmin.js';
|
||||
import {
|
||||
validateEncryptAndStoreCredential,
|
||||
CredentialValidationError,
|
||||
} from '../broker/credentialSync.js';
|
||||
// Side-effect import: brings in the ContextVariableMap augmentation for c.get('user')
|
||||
import '../auth/devBypass.js';
|
||||
|
||||
export const adminRouter = new Hono();
|
||||
|
||||
// Pitfall 9: requireAdmin MUST be the first statement on the router.
|
||||
// All sub-routes are protected — no path can be reached without passing this guard.
|
||||
adminRouter.use('*', requireAdmin);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Zod schema + no-echo hook for credential routes (T-10-09 / Pitfall 7)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const credentialSchema = z.object({
|
||||
userId: z.number().int().positive(),
|
||||
providerType: z.literal('caldav'),
|
||||
fastmailEmail: z.string().email().max(256),
|
||||
appPassword: z.string().min(1).max(500),
|
||||
});
|
||||
|
||||
/**
|
||||
* noEchoHook: NEVER return result.error from zValidator for credential routes.
|
||||
* Zod's error object contains issues[].received which echoes the submitted value
|
||||
* (the app password) — returning it would violate T-10-09 (Pitfall 7).
|
||||
* Always return { error: 'Invalid request' } 400, no other fields.
|
||||
*/
|
||||
const noEchoHook = (result: { success: boolean }, c: Context) => {
|
||||
if (!result.success) {
|
||||
return c.json({ error: 'Invalid request' }, 400);
|
||||
}
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /api/admin/members
|
||||
//
|
||||
// Returns all household members with their credential status.
|
||||
// Feeds UI-SPEC Surface 2 (member list with rotate-credential affordance).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
adminRouter.get('/members', async (c) => {
|
||||
const rows = await db
|
||||
.select({
|
||||
id: users.id,
|
||||
displayName: users.displayName,
|
||||
color: users.color,
|
||||
credentialId: memberCredentials.id,
|
||||
})
|
||||
.from(users)
|
||||
.leftJoin(memberCredentials, eq(memberCredentials.userId, users.id));
|
||||
|
||||
const members = rows.map((row) => ({
|
||||
id: row.id,
|
||||
displayName: row.displayName,
|
||||
color: row.color,
|
||||
hasCredential: row.credentialId !== null,
|
||||
}));
|
||||
|
||||
return c.json({ members });
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /api/admin/credentials
|
||||
//
|
||||
// Admin rotates (or sets for the first time) a member's Fastmail app password.
|
||||
// Validates against CalDAV (PROPFIND) before storing.
|
||||
// Uses the shared validateEncryptAndStoreCredential helper — no duplicated logic here.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
adminRouter.post('/credentials', zValidator('json', credentialSchema, noEchoHook), async (c) => {
|
||||
const { userId, fastmailEmail, appPassword, providerType } = c.req.valid('json');
|
||||
// T-10-10: NEVER log appPassword or c.req.valid('json') here
|
||||
|
||||
try {
|
||||
await validateEncryptAndStoreCredential(userId, fastmailEmail, appPassword, providerType);
|
||||
} catch (err) {
|
||||
if (err instanceof CredentialValidationError) {
|
||||
// T-10-09: map validation failure to generic 400 — no echo of password or Zod details
|
||||
return c.json({ error: 'Invalid request' }, 400);
|
||||
}
|
||||
// Unexpected errors (DB failure, etc.) — log message only, no credential data
|
||||
console.error(
|
||||
'[admin/POST /credentials] Unexpected error:',
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
return c.json({ error: 'Service unavailable' }, 503);
|
||||
}
|
||||
|
||||
return c.json({ ok: true }, 200);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /api/admin/calendars
|
||||
//
|
||||
// Lists all synced calendars. Feeds UI-SPEC Surface 5 (shared-calendar picker).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
adminRouter.get('/calendars', async (c) => {
|
||||
const rows = await db
|
||||
.select({
|
||||
id: calendars.id,
|
||||
displayName: calendars.displayName,
|
||||
isShared: calendars.isShared,
|
||||
})
|
||||
.from(calendars);
|
||||
|
||||
return c.json({ calendars: rows });
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PUT /api/admin/calendars/:id/shared
|
||||
//
|
||||
// Exclusively marks one calendar as is_shared=true (ADMIN-02).
|
||||
// Clears is_shared on any prior shared calendar first (D-06 single-select).
|
||||
// Pattern 7: two sequential UPDATE statements.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
adminRouter.put('/calendars/:id/shared', async (c) => {
|
||||
const targetId = parseInt(c.req.param('id'), 10);
|
||||
if (isNaN(targetId)) {
|
||||
return c.json({ error: 'Invalid calendar id' }, 400);
|
||||
}
|
||||
|
||||
// CR-01: verify the target exists and flip the shared lane atomically.
|
||||
// Without the existence check + transaction, a bad/stale id would clear the
|
||||
// current shared calendar in step 1 and update 0 rows in step 2 — silently
|
||||
// leaving the household with NO shared calendar while still returning ok.
|
||||
const found = await db.transaction(async (tx) => {
|
||||
const [target] = await tx
|
||||
.select({ id: calendars.id })
|
||||
.from(calendars)
|
||||
.where(eq(calendars.id, targetId))
|
||||
.limit(1);
|
||||
if (!target) return false;
|
||||
|
||||
// Step 1: Clear is_shared on any currently-shared calendar
|
||||
await tx.update(calendars).set({ isShared: false }).where(eq(calendars.isShared, true));
|
||||
// Step 2: Set is_shared on the target calendar (D-06 single-select)
|
||||
await tx.update(calendars).set({ isShared: true }).where(eq(calendars.id, targetId));
|
||||
return true;
|
||||
});
|
||||
|
||||
if (!found) {
|
||||
return c.json({ error: 'Calendar not found' }, 404);
|
||||
}
|
||||
|
||||
return c.json({ ok: true }, 200);
|
||||
});
|
||||
+134
-6
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* GET /api/me — returns the authenticated user's identity and assigned color.
|
||||
* GET /api/me — returns the authenticated user's identity, admin role, and provider setup status.
|
||||
*
|
||||
* Flow (normal — OIDC active):
|
||||
* 1. getAuth(c) reads iss + sub from the OIDC session JWT cookie
|
||||
@@ -7,36 +7,98 @@
|
||||
* 2. Derives displayName from OIDC claims (name → preferred_username → email → sub fallback)
|
||||
* then calls upsertUser(iss, sub, displayName) — writes on first visit, corrects a
|
||||
* previously blank displayName on subsequent visits (idempotent, keyed on iss+sub, D-10)
|
||||
* 3. Returns { user: { id, displayName, color } }
|
||||
* 3. Queries users.isAdmin and member_credentials existence for the resolved user
|
||||
* 4. Returns { user: { id, displayName, color, isAdmin, needsProviderSetup } }
|
||||
*
|
||||
* Flow (dev bypass — DEV_AUTH_BYPASS=true, non-production):
|
||||
* devAuthBypass() injects DEV_USER via c.set('user', DEV_USER). oidcAuthMiddleware
|
||||
* is NOT mounted in index.ts when the bypass is active, so getAuth(c) is never called.
|
||||
* This handler reads c.get('user') first and short-circuits to return the dev identity
|
||||
* directly, skipping the DB upsert.
|
||||
* This handler reads c.get('user') first and short-circuits using the dev user's id,
|
||||
* but STILL queries the DB for isAdmin (T-10-05: bypass skips OIDC, not the DB check)
|
||||
* and member_credentials existence.
|
||||
*
|
||||
* Security (D-03, T-10-06):
|
||||
* isAdmin is exposed for UX-only PWA nav gating — it is NOT the security boundary.
|
||||
* The server enforces the role on every /api/admin/* request via requireAdmin (Plan 03).
|
||||
*
|
||||
* The OIDC session cookie is httpOnly + Secure + SameSite (T-02-03).
|
||||
* No credential or refresh-token data is included in the response (T-02-04).
|
||||
* No credential or refresh-token data is included in the response (T-02-04, T-10-07).
|
||||
*/
|
||||
|
||||
import { Hono } from 'hono';
|
||||
import type { Context } from 'hono';
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
import { z } from 'zod';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { getAuth } from '../auth/middleware.js';
|
||||
import { upsertUser, deriveDisplayName } from '../auth/user.js';
|
||||
import { db } from '../db/client.js';
|
||||
import { users, memberCredentials } from '../db/schema.js';
|
||||
import {
|
||||
validateEncryptAndStoreCredential,
|
||||
CredentialValidationError,
|
||||
} from '../broker/credentialSync.js';
|
||||
// Side-effect import: brings in the ContextVariableMap augmentation for c.get('user')
|
||||
import '../auth/devBypass.js';
|
||||
|
||||
export const meRouter = new Hono();
|
||||
|
||||
/**
|
||||
* Looks up isAdmin and needsProviderSetup for a given userId.
|
||||
* Always reads from the DB — bypass only skips OIDC, not this check (T-10-05).
|
||||
*/
|
||||
async function resolveAdminAndSetupStatus(userId: number) {
|
||||
const [userRow] = await db
|
||||
.select({ isAdmin: users.isAdmin })
|
||||
.from(users)
|
||||
.where(eq(users.id, userId))
|
||||
.limit(1);
|
||||
|
||||
const [cred] = await db
|
||||
.select({ id: memberCredentials.id })
|
||||
.from(memberCredentials)
|
||||
.where(eq(memberCredentials.userId, userId))
|
||||
.limit(1);
|
||||
|
||||
return {
|
||||
isAdmin: userRow?.isAdmin ?? false,
|
||||
needsProviderSetup: !cred,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Auth helper — resolves the current user id from dev-bypass or OIDC session.
|
||||
// Per project convention: duplicated per router (not extracted to shared module).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function resolveUserId(c: Context): Promise<number | null> {
|
||||
const devUser = c.get('user') as { id: number } | undefined;
|
||||
if (devUser) return devUser.id;
|
||||
|
||||
const auth = await getAuth(c);
|
||||
if (!auth) return null;
|
||||
|
||||
const iss = (auth.iss as string | undefined) ?? '';
|
||||
const sub = auth.sub ?? '';
|
||||
const displayName = deriveDisplayName(auth);
|
||||
const user = await upsertUser(iss, sub, displayName);
|
||||
return user?.id ?? null;
|
||||
}
|
||||
|
||||
meRouter.get('/', async (c) => {
|
||||
// Dev-auth bypass path: devAuthBypass() sets c.get('user') to DEV_USER when active.
|
||||
// Return the injected dev identity directly — no DB round-trip, no OIDC session needed.
|
||||
// Use the injected dev identity's id for DB lookups — no OIDC session needed,
|
||||
// but isAdmin and needsProviderSetup are still resolved from the DB (T-10-05).
|
||||
const devUser = c.get('user');
|
||||
if (devUser) {
|
||||
const { isAdmin, needsProviderSetup } = await resolveAdminAndSetupStatus(devUser.id);
|
||||
return c.json({
|
||||
user: {
|
||||
id: devUser.id,
|
||||
displayName: devUser.displayName,
|
||||
color: devUser.color,
|
||||
isAdmin,
|
||||
needsProviderSetup,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -64,11 +126,77 @@ meRouter.get('/', async (c) => {
|
||||
return c.json({ error: 'Could not resolve user' }, 500);
|
||||
}
|
||||
|
||||
const { isAdmin, needsProviderSetup } = await resolveAdminAndSetupStatus(user.id);
|
||||
|
||||
return c.json({
|
||||
user: {
|
||||
id: user.id,
|
||||
displayName: user.displayName,
|
||||
color: user.color,
|
||||
isAdmin,
|
||||
needsProviderSetup,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /api/me/credential — member self-service credential endpoint (D-07)
|
||||
//
|
||||
// Security contract (T-10-12 Pitfall 6):
|
||||
// - ALWAYS writes to currentUserId from the session — NEVER a body userId.
|
||||
// - Any body.userId field is IGNORED — this endpoint cannot cross-write.
|
||||
// - Uses the SAME shared validateEncryptAndStoreCredential helper as admin path.
|
||||
// - Does NOT require requireAdmin — any authenticated member can set their own credential.
|
||||
// - Failure (any Zod or validation failure) returns { error: 'Invalid request' } 400
|
||||
// with NO echoed password (noEchoHook + CredentialValidationError → 400).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const meCredentialSchema = z.object({
|
||||
// D-07: no userId field — body userId is not accepted (Pitfall 6)
|
||||
providerType: z.literal('caldav'),
|
||||
fastmailEmail: z.string().email().max(256),
|
||||
appPassword: z.string().min(1).max(500),
|
||||
});
|
||||
|
||||
/**
|
||||
* noEchoHook for /api/me/credential: NEVER echo Zod error details (T-10-09 / Pitfall 7).
|
||||
*/
|
||||
const meNoEchoHook = (result: { success: boolean }, c: Context) => {
|
||||
if (!result.success) {
|
||||
return c.json({ error: 'Invalid request' }, 400);
|
||||
}
|
||||
};
|
||||
|
||||
meRouter.post('/credential', zValidator('json', meCredentialSchema, meNoEchoHook), async (c) => {
|
||||
// Pitfall 6: ALWAYS resolve currentUserId from the session — never from the body.
|
||||
const currentUserId = await resolveUserId(c);
|
||||
if (!currentUserId) {
|
||||
return c.json({ error: 'Unauthorized' }, 401);
|
||||
}
|
||||
|
||||
const { fastmailEmail, appPassword, providerType } = c.req.valid('json');
|
||||
// T-10-10: NEVER log appPassword or c.req.valid('json') here
|
||||
|
||||
try {
|
||||
// D-07: identical validate→encrypt→store→sync path as admin, but always with
|
||||
// currentUserId (not a body userId). Admin passes the target member's userId;
|
||||
// self-service passes the authenticated session userId. Same helper, same argument order.
|
||||
await validateEncryptAndStoreCredential(
|
||||
currentUserId,
|
||||
fastmailEmail,
|
||||
appPassword,
|
||||
providerType,
|
||||
);
|
||||
} catch (err) {
|
||||
if (err instanceof CredentialValidationError) {
|
||||
return c.json({ error: 'Invalid request' }, 400);
|
||||
}
|
||||
console.error(
|
||||
'[me/POST /credential] Unexpected error:',
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
return c.json({ error: 'Service unavailable' }, 503);
|
||||
}
|
||||
|
||||
return c.json({ ok: true }, 200);
|
||||
});
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
/**
|
||||
* Auth: upsertUser color round-robin + identity stability
|
||||
* Auth: upsertUser color round-robin + identity stability + first-login-wins is_admin
|
||||
*
|
||||
* Tests for apps/api/src/auth/user.ts (Plan 02)
|
||||
* Tests for apps/api/src/auth/user.ts (Plan 02 + Plan 10-02)
|
||||
*
|
||||
* Select call order for a NEW user insert (post Plan 10-02):
|
||||
* 1. Lookup by oidc_iss + oidc_sub (identity check)
|
||||
* 2. Used-colors query (color assignment)
|
||||
* 3. Zero-admin COUNT check (first-login-wins is_admin bootstrap — NEW)
|
||||
* 4. Re-fetch after insert (return full row)
|
||||
*
|
||||
* Existing-user (early-return) path remains at 1 select call (no change).
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
@@ -67,9 +75,11 @@ describe('upsertUser', () => {
|
||||
const iss = 'https://auth.example.com';
|
||||
const sub = 'user-sub-001';
|
||||
|
||||
// First select: no existing user
|
||||
// Second select (used colors): no existing users → no colors in use → palette[0]
|
||||
// Third select (re-fetch after insert): return the inserted row
|
||||
// Select call order (new user, post Plan 10-02):
|
||||
// 1. Lookup by iss+sub — not found
|
||||
// 2. Used-colors query — no existing users → palette[0]
|
||||
// 3. Zero-admin COUNT check — 0 admins → shouldBeAdmin=true
|
||||
// 4. Re-fetch after insert — return the inserted row
|
||||
let selectCallCount = 0;
|
||||
mockDb.select.mockImplementation(() => {
|
||||
selectCallCount++;
|
||||
@@ -83,6 +93,10 @@ describe('upsertUser', () => {
|
||||
from: vi.fn().mockResolvedValue([]),
|
||||
};
|
||||
}
|
||||
if (selectCallCount === 3) {
|
||||
// Zero-admin COUNT check — 0 admins → first user becomes admin
|
||||
return makeSelectChain([{ count: 0 }]);
|
||||
}
|
||||
// Re-fetch after insert
|
||||
return makeSelectChain([
|
||||
{
|
||||
@@ -91,6 +105,7 @@ describe('upsertUser', () => {
|
||||
oidcSub: sub,
|
||||
displayName: null,
|
||||
color: COLOR_PALETTE[0],
|
||||
isAdmin: true,
|
||||
createdAt: new Date(),
|
||||
},
|
||||
]);
|
||||
@@ -122,6 +137,10 @@ describe('upsertUser', () => {
|
||||
from: vi.fn().mockResolvedValue([{ color: COLOR_PALETTE[0] }]),
|
||||
};
|
||||
}
|
||||
if (selectCallCount === 3) {
|
||||
// Zero-admin COUNT check — 1 admin already exists → shouldBeAdmin=false
|
||||
return makeSelectChain([{ count: 1 }]);
|
||||
}
|
||||
return makeSelectChain([
|
||||
{
|
||||
id: 2,
|
||||
@@ -129,6 +148,7 @@ describe('upsertUser', () => {
|
||||
oidcSub: sub2,
|
||||
displayName: null,
|
||||
color: COLOR_PALETTE[1],
|
||||
isAdmin: false,
|
||||
createdAt: new Date(),
|
||||
},
|
||||
]);
|
||||
@@ -161,6 +181,10 @@ describe('upsertUser', () => {
|
||||
.mockResolvedValue([{ color: COLOR_PALETTE[0] }, { color: COLOR_PALETTE[2] }]),
|
||||
};
|
||||
}
|
||||
if (selectCallCount === 3) {
|
||||
// Zero-admin COUNT check — admin exists → shouldBeAdmin=false
|
||||
return makeSelectChain([{ count: 1 }]);
|
||||
}
|
||||
return makeSelectChain([
|
||||
{
|
||||
id: 5,
|
||||
@@ -168,6 +192,7 @@ describe('upsertUser', () => {
|
||||
oidcSub: sub,
|
||||
displayName: null,
|
||||
color: COLOR_PALETTE[1],
|
||||
isAdmin: false,
|
||||
createdAt: new Date(),
|
||||
},
|
||||
]);
|
||||
@@ -213,7 +238,12 @@ describe('upsertUser', () => {
|
||||
selectCallCount++;
|
||||
if (selectCallCount === 1) return makeSelectChain([]);
|
||||
if (selectCallCount === 2) {
|
||||
return { from: vi.fn().mockResolvedValue([{ count: 0 }]) };
|
||||
// Used-colors query — no existing users
|
||||
return { from: vi.fn().mockResolvedValue([]) };
|
||||
}
|
||||
if (selectCallCount === 3) {
|
||||
// Zero-admin COUNT check
|
||||
return makeSelectChain([{ count: 0 }]);
|
||||
}
|
||||
return makeSelectChain([
|
||||
{
|
||||
@@ -222,6 +252,7 @@ describe('upsertUser', () => {
|
||||
oidcSub: sub,
|
||||
displayName: null,
|
||||
color: COLOR_PALETTE[0],
|
||||
isAdmin: true,
|
||||
createdAt: new Date(),
|
||||
},
|
||||
]);
|
||||
@@ -249,6 +280,7 @@ describe('upsertUser', () => {
|
||||
oidcSub: sub,
|
||||
displayName: 'Alice',
|
||||
color: '#9B6DC5',
|
||||
isAdmin: false,
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
@@ -261,4 +293,109 @@ describe('upsertUser', () => {
|
||||
expect(user!.color).toBe('#9B6DC5');
|
||||
expect(user!.displayName).toBe('Alice');
|
||||
});
|
||||
|
||||
// ── Plan 10-02: first-login-wins is_admin bootstrap (D-01) ────────────────
|
||||
|
||||
it('inserts first user with is_admin=true when zero admins exist (first-login-wins, D-01)', async () => {
|
||||
const iss = 'https://auth.example.com';
|
||||
const sub = 'sub-first-admin';
|
||||
|
||||
let selectCallCount = 0;
|
||||
mockDb.select.mockImplementation(() => {
|
||||
selectCallCount++;
|
||||
if (selectCallCount === 1) return makeSelectChain([]); // not found
|
||||
if (selectCallCount === 2) {
|
||||
// Used-colors query — empty table
|
||||
return { from: vi.fn().mockResolvedValue([]) };
|
||||
}
|
||||
if (selectCallCount === 3) {
|
||||
// Zero-admin COUNT check — 0 admins → shouldBeAdmin=true
|
||||
return makeSelectChain([{ count: 0 }]);
|
||||
}
|
||||
// Re-fetch after insert
|
||||
return makeSelectChain([
|
||||
{
|
||||
id: 10,
|
||||
oidcIss: iss,
|
||||
oidcSub: sub,
|
||||
displayName: null,
|
||||
color: COLOR_PALETTE[0],
|
||||
isAdmin: true,
|
||||
createdAt: new Date(),
|
||||
},
|
||||
]);
|
||||
});
|
||||
mockDb.insert.mockReturnValue(makeInsertChain([{ id: 10 }]));
|
||||
|
||||
await upsertUser(iss, sub);
|
||||
|
||||
// The inserted row must include isAdmin: true
|
||||
const insertValues = mockDb.insert.mock.results[0]?.value?.values.mock.calls[0]?.[0];
|
||||
expect(insertValues).toBeDefined();
|
||||
expect(insertValues.isAdmin).toBe(true);
|
||||
});
|
||||
|
||||
it('inserts subsequent user with is_admin=false when an admin already exists', async () => {
|
||||
const iss = 'https://auth.example.com';
|
||||
const sub = 'sub-second-user';
|
||||
|
||||
let selectCallCount = 0;
|
||||
mockDb.select.mockImplementation(() => {
|
||||
selectCallCount++;
|
||||
if (selectCallCount === 1) return makeSelectChain([]); // not found
|
||||
if (selectCallCount === 2) {
|
||||
// Used-colors query — one existing user
|
||||
return { from: vi.fn().mockResolvedValue([{ color: COLOR_PALETTE[0] }]) };
|
||||
}
|
||||
if (selectCallCount === 3) {
|
||||
// Zero-admin COUNT check — 1 admin already exists → shouldBeAdmin=false
|
||||
return makeSelectChain([{ count: 1 }]);
|
||||
}
|
||||
return makeSelectChain([
|
||||
{
|
||||
id: 11,
|
||||
oidcIss: iss,
|
||||
oidcSub: sub,
|
||||
displayName: null,
|
||||
color: COLOR_PALETTE[1],
|
||||
isAdmin: false,
|
||||
createdAt: new Date(),
|
||||
},
|
||||
]);
|
||||
});
|
||||
mockDb.insert.mockReturnValue(makeInsertChain([{ id: 11 }]));
|
||||
|
||||
await upsertUser(iss, sub);
|
||||
|
||||
// The inserted row must include isAdmin: false
|
||||
const insertValues = mockDb.insert.mock.results[0]?.value?.values.mock.calls[0]?.[0];
|
||||
expect(insertValues).toBeDefined();
|
||||
expect(insertValues.isAdmin).toBe(false);
|
||||
});
|
||||
|
||||
it('does NOT change is_admin on re-upsert of an existing user (early-return path unchanged)', async () => {
|
||||
const iss = 'https://auth.example.com';
|
||||
const sub = 'sub-existing-member';
|
||||
const existingRow = {
|
||||
id: 5,
|
||||
oidcIss: iss,
|
||||
oidcSub: sub,
|
||||
displayName: 'Member',
|
||||
color: COLOR_PALETTE[0],
|
||||
isAdmin: false,
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
// Existing user found on first select — early return, no insert
|
||||
mockDb.select.mockImplementation(() => makeSelectChain([existingRow]));
|
||||
|
||||
const user = await upsertUser(iss, sub, 'Member');
|
||||
|
||||
// Must NOT insert
|
||||
expect(mockDb.insert).not.toHaveBeenCalled();
|
||||
// isAdmin must NOT be changed (returned as-is from DB row)
|
||||
expect(user!.isAdmin).toBe(false);
|
||||
// select must only have been called once (identity lookup, then early-return)
|
||||
expect(mockDb.select).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* requireAdmin middleware tests (Plan 10-02, Task 1)
|
||||
*
|
||||
* Behavior-pinned contracts:
|
||||
* 1. non-admin DB row → 403 { error: 'Forbidden' }, next() NOT called
|
||||
* 2. admin DB row → next() called, request proceeds
|
||||
* 3. no resolved user on context (c.get('user') undefined) → 403
|
||||
* 4. role is read from the DB (users.is_admin), NOT from context user object —
|
||||
* a context user claiming isAdmin=true but with is_admin=false in DB is still 403
|
||||
* (bypass only skips OIDC, not the DB check — T-10-04, T-10-05)
|
||||
*
|
||||
* Uses mocked db to test the middleware in isolation (no live DB required).
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { Hono } from 'hono';
|
||||
|
||||
// Mock the db singleton so tests do not need a live MariaDB connection.
|
||||
vi.mock('../../src/db/client.js', () => ({
|
||||
db: {
|
||||
select: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Bring in the ContextVariableMap augmentation (sets up c.get('user') typing)
|
||||
vi.mock('../../src/auth/devBypass.js', () => ({
|
||||
DEV_USER: {
|
||||
id: 1,
|
||||
oidcIss: 'dev',
|
||||
oidcSub: 'dev-user',
|
||||
displayName: 'Dev User',
|
||||
color: '#4A90D9',
|
||||
},
|
||||
devAuthBypass: () => async (_c: unknown, next: () => Promise<void>) => next(),
|
||||
COLOR_PALETTE: ['#4A90D9'],
|
||||
}));
|
||||
|
||||
import { db } from '../../src/db/client.js';
|
||||
import { requireAdmin } from '../../src/lib/requireAdmin.js';
|
||||
|
||||
const mockDb = db as { select: ReturnType<typeof vi.fn> };
|
||||
|
||||
// ── DB query chain builder ────────────────────────────────────────────────────
|
||||
|
||||
function makeSelectChain(resolvedValue: unknown[]) {
|
||||
const chain = {
|
||||
from: vi.fn(),
|
||||
where: vi.fn(),
|
||||
limit: vi.fn().mockResolvedValue(resolvedValue),
|
||||
};
|
||||
chain.from.mockReturnValue(chain);
|
||||
chain.where.mockReturnValue(chain);
|
||||
return chain;
|
||||
}
|
||||
|
||||
// ── Test app factory ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Creates a minimal Hono app that mounts requireAdmin and a downstream handler
|
||||
* that sets a header so we can assert whether next() was called.
|
||||
*/
|
||||
function makeTestApp(userOnContext: { id: number; isAdmin?: boolean } | undefined) {
|
||||
const app = new Hono();
|
||||
|
||||
// Inject user into context (simulates devAuthBypass or OIDC middleware output)
|
||||
app.use('*', async (c, next) => {
|
||||
if (userOnContext !== undefined) {
|
||||
// Cast: the ContextVariableMap expects the full DEV_USER shape; we only need id
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
c.set('user', userOnContext as any);
|
||||
}
|
||||
await next();
|
||||
});
|
||||
|
||||
app.use('*', requireAdmin);
|
||||
|
||||
app.get('/test', (c) => c.json({ ok: true }));
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('requireAdmin middleware', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('returns 403 for an authenticated non-admin user (is_admin=false in DB)', async () => {
|
||||
// DB returns row with isAdmin=false
|
||||
mockDb.select.mockReturnValue(makeSelectChain([{ isAdmin: false }]));
|
||||
|
||||
const app = makeTestApp({ id: 42 });
|
||||
const res = await app.request('/test');
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
const body = (await res.json()) as { error: string };
|
||||
expect(body.error).toBe('Forbidden');
|
||||
});
|
||||
|
||||
it('calls next() for an authenticated admin user (is_admin=true in DB)', async () => {
|
||||
// DB returns row with isAdmin=true
|
||||
mockDb.select.mockReturnValue(makeSelectChain([{ isAdmin: true }]));
|
||||
|
||||
const app = makeTestApp({ id: 1 });
|
||||
const res = await app.request('/test');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { ok: boolean };
|
||||
expect(body.ok).toBe(true);
|
||||
});
|
||||
|
||||
it('returns 403 when no user is resolved on context (c.get("user") is undefined)', async () => {
|
||||
// No DB call expected — userId is missing, short-circuit to 403
|
||||
const app = makeTestApp(undefined);
|
||||
const res = await app.request('/test');
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
const body = (await res.json()) as { error: string };
|
||||
expect(body.error).toBe('Forbidden');
|
||||
// DB must NOT be queried when user is absent
|
||||
expect(mockDb.select).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns 403 when context user claims isAdmin=true but DB row has is_admin=false (T-10-04)', async () => {
|
||||
// Context carries a spoofed isAdmin claim — DB should be the authority
|
||||
mockDb.select.mockReturnValue(makeSelectChain([{ isAdmin: false }]));
|
||||
|
||||
// User on context has isAdmin=true (as if a client tried to inject it)
|
||||
const app = makeTestApp({ id: 99, isAdmin: true });
|
||||
const res = await app.request('/test');
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
const body = (await res.json()) as { error: string };
|
||||
expect(body.error).toBe('Forbidden');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,587 @@
|
||||
/**
|
||||
* Admin + self-service credential surface — integration tests (Plan 10-03, TDD RED→GREEN).
|
||||
*
|
||||
* Covers:
|
||||
* Task 2 (admin routes):
|
||||
* - T-10-08: GET /api/admin/members returns 403 for non-admin, 200+list for admin
|
||||
* - T-10-09 (Pitfall 7): POST /api/admin/credentials with invalid password → 400,
|
||||
* no submitted password string in response body; same shape for createFastmailClient
|
||||
* throw and for network/PROPFIND failures
|
||||
* - T-10-09 (Pitfall 7): malformed/bad-email payload → 400 { error: 'Invalid request' }
|
||||
* - T-10-11: valid credential → 200, stored encrypted (encrypted_password != plaintext)
|
||||
* - T-10-08: POST /api/admin/credentials as non-admin → 403
|
||||
* - ADMIN-02: PUT /api/admin/calendars/:id/shared → exactly one calendar is_shared=1
|
||||
* - GET /api/admin/calendars as admin → 200 list; as non-admin → 403
|
||||
*
|
||||
* Task 3 (self-service):
|
||||
* - T-10-12 (Pitfall 6): POST /api/me/credential with body userId for another user
|
||||
* → credential written to session user (currentUserId), NOT the body userId
|
||||
* - POST /api/me/credential with valid credential → 200, stored encrypted
|
||||
* - POST /api/me/credential with bad credential → 400 generic, no echoed password
|
||||
* - POST /api/me/credential does NOT require admin (normal member can use it)
|
||||
*
|
||||
* Architecture:
|
||||
* - Tests import `app` (NOT adminRouter directly — Pitfall 9)
|
||||
* - Real DB integration: dev MariaDB must be running; set DB_HOST=127.0.0.1
|
||||
* - CalDAV (createFastmailClient) is mocked to avoid live Fastmail calls
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { db } from '../../src/db/client.js';
|
||||
import { users, memberCredentials, calendars } from '../../src/db/schema.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CalDAV mock — intercept createFastmailClient so tests do not hit live Fastmail.
|
||||
// Tests that need PROPFIND-success inject a mock client with fetchCalendars resolving.
|
||||
// Tests that need PROPFIND-failure inject a mock client that throws on fetchCalendars.
|
||||
// Tests that need createFastmailClient itself to throw (e.g. bad email format) throw
|
||||
// before returning a client at all.
|
||||
//
|
||||
// Also mock loadClientForUser and triggerTargetedResync from outboxWorker to avoid
|
||||
// the initial-sync touching the real DB in tests.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type FetchCalendarsResult = { url: string; displayName: string }[];
|
||||
|
||||
let mockFetchCalendars: () => Promise<FetchCalendarsResult> = () =>
|
||||
Promise.resolve([{ url: 'https://caldav.fastmail.com/cal/user/', displayName: 'Test Cal' }]);
|
||||
let mockCreateClientShouldThrow = false;
|
||||
let mockCreateClientError: Error | null = null;
|
||||
|
||||
vi.mock('../../src/broker/client.js', () => ({
|
||||
createFastmailClient: vi.fn().mockImplementation(async () => {
|
||||
if (mockCreateClientShouldThrow) {
|
||||
throw mockCreateClientError ?? new Error('Mock CalDAV client creation error');
|
||||
}
|
||||
return { fetchCalendars: mockFetchCalendars };
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/broker/outboxWorker.js', () => ({
|
||||
loadClientForUser: vi.fn().mockResolvedValue({
|
||||
fetchCalendars: () =>
|
||||
Promise.resolve([{ url: 'https://caldav.fastmail.com/cal/', displayName: 'Test' }]),
|
||||
}),
|
||||
triggerTargetedResync: vi.fn().mockResolvedValue(undefined),
|
||||
startOutboxWorker: vi.fn(),
|
||||
initOutboxTrigger: vi.fn(),
|
||||
scheduleOutboxDrain: vi.fn(),
|
||||
runOutboxDrain: vi.fn(),
|
||||
__resetDrainState: vi.fn(),
|
||||
assembleRruleString: vi.fn(),
|
||||
stopOutboxTrigger: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock sync.js to avoid actual CalDAV sync during tests
|
||||
vi.mock('../../src/broker/sync.js', () => ({
|
||||
syncCalendar: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dev-bypass mock: allows us to simulate different users in tests.
|
||||
// currentDevUserId controls which user is "logged in" via the bypass.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let currentDevUserId = 1;
|
||||
|
||||
vi.mock('../../src/auth/devBypass.js', () => ({
|
||||
devAuthBypass:
|
||||
() => async (c: { set: (k: string, v: unknown) => void }, next: () => Promise<void>) => {
|
||||
c.set('user', { id: currentDevUserId });
|
||||
await next();
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@hono/oidc-auth', () => ({
|
||||
oidcAuthMiddleware: () => async (_c: unknown, next: () => Promise<void>) => next(),
|
||||
processOAuthCallback: () => async (c: { json: (v: unknown) => unknown }) => c.json({ ok: true }),
|
||||
getAuth: () => null,
|
||||
}));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Seed helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function seedUser(label: string, isAdmin = false): Promise<number> {
|
||||
const [result] = await db
|
||||
.insert(users)
|
||||
.values({
|
||||
oidcIss: 'https://auth.test',
|
||||
oidcSub: `sub-${label}-${randomUUID()}`,
|
||||
displayName: `User ${label}`,
|
||||
color: '#4A90D9',
|
||||
isAdmin,
|
||||
})
|
||||
.$returningId();
|
||||
return result.id;
|
||||
}
|
||||
|
||||
async function seedCalendar(userId: number, label: string, isShared = false): Promise<number> {
|
||||
const [result] = await db
|
||||
.insert(calendars)
|
||||
.values({
|
||||
userId,
|
||||
url: `https://caldav.fastmail.com/cal/${label}-${randomUUID()}/`,
|
||||
displayName: `Calendar ${label}`,
|
||||
isShared,
|
||||
})
|
||||
.$returningId();
|
||||
return result.id;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Request helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function jsonRequest(method: string, path: string, body?: unknown): Request {
|
||||
return new Request(`http://localhost${path}`, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Import `app` lazily (after mocks are registered) — Pitfall 9: must import
|
||||
// `app` not `adminRouter` directly so the route mount + guard are exercised.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function getApp() {
|
||||
const { app } = await import('../../src/index.js');
|
||||
return app;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Encryption key required for encryptPassword
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
beforeEach(async () => {
|
||||
process.env.APP_PASSWORD_ENCRYPTION_KEY =
|
||||
'0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef';
|
||||
// Reset CalDAV mock state to default (success path)
|
||||
mockCreateClientShouldThrow = false;
|
||||
mockCreateClientError = null;
|
||||
mockFetchCalendars = () =>
|
||||
Promise.resolve([{ url: 'https://caldav.fastmail.com/cal/user/', displayName: 'Test Cal' }]);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// Clean up seeded users and credentials between tests
|
||||
await db.delete(memberCredentials);
|
||||
await db.delete(calendars);
|
||||
await db.delete(users).where(eq(users.oidcIss, 'https://auth.test'));
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// GET /api/admin/members
|
||||
// ===========================================================================
|
||||
|
||||
describe('GET /api/admin/members', () => {
|
||||
it('returns 403 for a non-admin authenticated user (Pitfall 9 / T-10-08)', async () => {
|
||||
const nonAdminId = await seedUser('non-admin', false);
|
||||
currentDevUserId = nonAdminId;
|
||||
const app = await getApp();
|
||||
|
||||
const res = await app.fetch(jsonRequest('GET', '/api/admin/members'));
|
||||
expect(res.status).toBe(403);
|
||||
const body = await res.json();
|
||||
expect(body).toEqual({ error: 'Forbidden' });
|
||||
});
|
||||
|
||||
it('returns 200 with member list for an admin user', async () => {
|
||||
const adminId = await seedUser('admin', true);
|
||||
const memberId = await seedUser('member', false);
|
||||
currentDevUserId = adminId;
|
||||
const app = await getApp();
|
||||
|
||||
const res = await app.fetch(jsonRequest('GET', '/api/admin/members'));
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { members: unknown[] };
|
||||
expect(Array.isArray(body.members)).toBe(true);
|
||||
// Should include at least the two seeded users
|
||||
expect(body.members.length).toBeGreaterThanOrEqual(2);
|
||||
// Each member should have id, displayName, color, hasCredential
|
||||
const memberRow = (body.members as Array<{ id: number }>).find((m) => m.id === memberId);
|
||||
expect(memberRow).toBeDefined();
|
||||
expect(typeof (memberRow as { hasCredential: boolean }).hasCredential).toBe('boolean');
|
||||
});
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// POST /api/admin/credentials
|
||||
// ===========================================================================
|
||||
|
||||
describe('POST /api/admin/credentials', () => {
|
||||
it('returns 403 for a non-admin user (T-10-08)', async () => {
|
||||
const nonAdminId = await seedUser('non-admin-cred', false);
|
||||
const targetId = await seedUser('target', false);
|
||||
currentDevUserId = nonAdminId;
|
||||
const app = await getApp();
|
||||
|
||||
const res = await app.fetch(
|
||||
jsonRequest('POST', '/api/admin/credentials', {
|
||||
userId: targetId,
|
||||
providerType: 'caldav',
|
||||
fastmailEmail: 'user@fastmail.com',
|
||||
appPassword: 'test-secret-pass',
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it('returns 400 with no echoed password when PROPFIND/auth fails (Pitfall 7 / T-10-09)', async () => {
|
||||
const adminId = await seedUser('admin-cred-fail', true);
|
||||
const targetId = await seedUser('target-fail', false);
|
||||
currentDevUserId = adminId;
|
||||
|
||||
// Make fetchCalendars throw (simulates PROPFIND/auth failure)
|
||||
const submittedPassword = 'super-secret-app-password-12345';
|
||||
mockFetchCalendars = () => Promise.reject(new Error('Authentication failed'));
|
||||
|
||||
const app = await getApp();
|
||||
|
||||
const res = await app.fetch(
|
||||
jsonRequest('POST', '/api/admin/credentials', {
|
||||
userId: targetId,
|
||||
providerType: 'caldav',
|
||||
fastmailEmail: 'user@fastmail.com',
|
||||
appPassword: submittedPassword,
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
const bodyText = await res.text();
|
||||
// Pitfall 7: submitted password MUST NOT appear in any 400 response
|
||||
expect(bodyText).not.toContain(submittedPassword);
|
||||
// No Zod error fields
|
||||
expect(bodyText).not.toContain('received');
|
||||
expect(bodyText).not.toContain('issues');
|
||||
// Should return generic error shape
|
||||
const body = JSON.parse(bodyText) as { error: string };
|
||||
expect(body.error).toBe('Invalid request');
|
||||
});
|
||||
|
||||
it('returns 400 when createFastmailClient itself throws (bad email / malformed input)', async () => {
|
||||
const adminId = await seedUser('admin-cred-throw', true);
|
||||
const targetId = await seedUser('target-throw', false);
|
||||
currentDevUserId = adminId;
|
||||
|
||||
const submittedPassword = 'bad-email-secret-pass-99999';
|
||||
mockCreateClientShouldThrow = true;
|
||||
mockCreateClientError = new Error('Invalid email format');
|
||||
|
||||
const app = await getApp();
|
||||
|
||||
const res = await app.fetch(
|
||||
jsonRequest('POST', '/api/admin/credentials', {
|
||||
userId: targetId,
|
||||
providerType: 'caldav',
|
||||
fastmailEmail: 'user@fastmail.com',
|
||||
appPassword: submittedPassword,
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
const bodyText = await res.text();
|
||||
expect(bodyText).not.toContain(submittedPassword);
|
||||
const body = JSON.parse(bodyText) as { error: string };
|
||||
expect(body.error).toBe('Invalid request');
|
||||
});
|
||||
|
||||
it('returns 400 with no echo for a network error (all failures map to same generic 400)', async () => {
|
||||
const adminId = await seedUser('admin-network-err', true);
|
||||
const targetId = await seedUser('target-network', false);
|
||||
currentDevUserId = adminId;
|
||||
|
||||
const submittedPassword = 'network-error-secret-abc123';
|
||||
mockFetchCalendars = () => Promise.reject(new Error('Network connection refused'));
|
||||
|
||||
const app = await getApp();
|
||||
|
||||
const res = await app.fetch(
|
||||
jsonRequest('POST', '/api/admin/credentials', {
|
||||
userId: targetId,
|
||||
providerType: 'caldav',
|
||||
fastmailEmail: 'user@fastmail.com',
|
||||
appPassword: submittedPassword,
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
const bodyText = await res.text();
|
||||
expect(bodyText).not.toContain(submittedPassword);
|
||||
const body = JSON.parse(bodyText) as { error: string };
|
||||
expect(body.error).toBe('Invalid request');
|
||||
});
|
||||
|
||||
it('returns 400 with no Zod echo when schema validation fails (e.g. missing fields)', async () => {
|
||||
const adminId = await seedUser('admin-schema-fail', true);
|
||||
currentDevUserId = adminId;
|
||||
|
||||
const submittedPassword = 'schema-fail-secret-zxcvbn';
|
||||
const app = await getApp();
|
||||
|
||||
const res = await app.fetch(
|
||||
jsonRequest('POST', '/api/admin/credentials', {
|
||||
// Missing userId — schema validation should fail
|
||||
providerType: 'caldav',
|
||||
fastmailEmail: 'user@fastmail.com',
|
||||
appPassword: submittedPassword,
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
const bodyText = await res.text();
|
||||
// Pitfall 7: even schema validation failure must not echo the password
|
||||
expect(bodyText).not.toContain(submittedPassword);
|
||||
expect(bodyText).not.toContain('received');
|
||||
expect(bodyText).not.toContain('issues');
|
||||
const body = JSON.parse(bodyText) as { error: string };
|
||||
expect(body.error).toBe('Invalid request');
|
||||
});
|
||||
|
||||
it('returns 200 on valid credential and stores encrypted password (T-10-11)', async () => {
|
||||
const adminId = await seedUser('admin-valid', true);
|
||||
const targetId = await seedUser('target-valid', false);
|
||||
currentDevUserId = adminId;
|
||||
|
||||
const plainPassword = 'valid-app-password-abcxyz-9876';
|
||||
// Mock fetchCalendars to succeed (default)
|
||||
const app = await getApp();
|
||||
|
||||
const res = await app.fetch(
|
||||
jsonRequest('POST', '/api/admin/credentials', {
|
||||
userId: targetId,
|
||||
providerType: 'caldav',
|
||||
fastmailEmail: 'target@fastmail.com',
|
||||
appPassword: plainPassword,
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
// Verify stored credential is NOT the plaintext (encrypted)
|
||||
const [stored] = await db
|
||||
.select({ encryptedPassword: memberCredentials.encryptedPassword })
|
||||
.from(memberCredentials)
|
||||
.where(eq(memberCredentials.userId, targetId))
|
||||
.limit(1);
|
||||
expect(stored).toBeDefined();
|
||||
expect(stored.encryptedPassword).not.toBe(plainPassword);
|
||||
// Should be the JSON-encoded AES-256-GCM structure
|
||||
const parsed = JSON.parse(stored.encryptedPassword) as Record<string, unknown>;
|
||||
expect(parsed.iv).toBeDefined();
|
||||
expect(parsed.ciphertext).toBeDefined();
|
||||
|
||||
// Response body must not contain the password
|
||||
const bodyText = JSON.stringify(await res.clone().json());
|
||||
expect(bodyText).not.toContain(plainPassword);
|
||||
});
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// GET /api/admin/calendars
|
||||
// ===========================================================================
|
||||
|
||||
describe('GET /api/admin/calendars', () => {
|
||||
it('returns 403 for non-admin', async () => {
|
||||
const nonAdminId = await seedUser('non-admin-cal', false);
|
||||
currentDevUserId = nonAdminId;
|
||||
const app = await getApp();
|
||||
|
||||
const res = await app.fetch(jsonRequest('GET', '/api/admin/calendars'));
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it('returns 200 with calendar list for admin', async () => {
|
||||
const adminId = await seedUser('admin-cal-list', true);
|
||||
await seedCalendar(adminId, 'personal', false);
|
||||
await seedCalendar(adminId, 'family', true);
|
||||
currentDevUserId = adminId;
|
||||
const app = await getApp();
|
||||
|
||||
const res = await app.fetch(jsonRequest('GET', '/api/admin/calendars'));
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { calendars: unknown[] };
|
||||
expect(Array.isArray(body.calendars)).toBe(true);
|
||||
// At least the two seeded calendars
|
||||
expect(body.calendars.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// PUT /api/admin/calendars/:id/shared (ADMIN-02, Pitfall 7-adjacent)
|
||||
// ===========================================================================
|
||||
|
||||
describe('PUT /api/admin/calendars/:id/shared', () => {
|
||||
it('returns 403 for non-admin', async () => {
|
||||
const nonAdminId = await seedUser('non-admin-shared', false);
|
||||
currentDevUserId = nonAdminId;
|
||||
const app = await getApp();
|
||||
|
||||
const res = await app.fetch(jsonRequest('PUT', '/api/admin/calendars/1/shared'));
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it('sets exactly one calendar to is_shared=1 and clears prior shared calendar (ADMIN-02)', async () => {
|
||||
const adminId = await seedUser('admin-shared', true);
|
||||
const calA = await seedCalendar(adminId, 'cal-a', true); // initially shared
|
||||
const calB = await seedCalendar(adminId, 'cal-b', false);
|
||||
currentDevUserId = adminId;
|
||||
const app = await getApp();
|
||||
|
||||
const res = await app.fetch(jsonRequest('PUT', `/api/admin/calendars/${calB}/shared`));
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
// calA should now be is_shared=false, calB should be is_shared=true
|
||||
const [rowA] = await db
|
||||
.select({ isShared: calendars.isShared })
|
||||
.from(calendars)
|
||||
.where(eq(calendars.id, calA))
|
||||
.limit(1);
|
||||
const [rowB] = await db
|
||||
.select({ isShared: calendars.isShared })
|
||||
.from(calendars)
|
||||
.where(eq(calendars.id, calB))
|
||||
.limit(1);
|
||||
|
||||
expect(rowA.isShared).toBe(false);
|
||||
expect(rowB.isShared).toBe(true);
|
||||
|
||||
// Verify exactly ONE calendar has is_shared=true after the update
|
||||
const sharedRows = await db
|
||||
.select({ id: calendars.id })
|
||||
.from(calendars)
|
||||
.where(eq(calendars.isShared, true));
|
||||
// Only calB should be shared (of the ones we seeded; other pre-existing rows excluded
|
||||
// by checking only our seeded IDs)
|
||||
const sharedIds = sharedRows.map((r) => r.id);
|
||||
expect(sharedIds).toContain(calB);
|
||||
expect(sharedIds).not.toContain(calA);
|
||||
});
|
||||
|
||||
it('returns 404 for a non-existent target and does NOT clear the existing shared calendar (CR-01)', async () => {
|
||||
const adminId = await seedUser('admin-shared-missing', true);
|
||||
const calA = await seedCalendar(adminId, 'cal-a-keep', true); // currently the shared family lane
|
||||
currentDevUserId = adminId;
|
||||
const app = await getApp();
|
||||
|
||||
// PUT a target id that does not exist. The handler must verify the target
|
||||
// exists BEFORE clearing the current shared lane, so a bad/stale id can
|
||||
// never silently wipe the family's shared calendar (BLOCKER CR-01).
|
||||
const res = await app.fetch(jsonRequest('PUT', '/api/admin/calendars/99999999/shared'));
|
||||
expect(res.status).toBe(404);
|
||||
|
||||
// calA must STILL be shared — the no-op target must not have cleared it.
|
||||
const [rowA] = await db
|
||||
.select({ isShared: calendars.isShared })
|
||||
.from(calendars)
|
||||
.where(eq(calendars.id, calA))
|
||||
.limit(1);
|
||||
expect(rowA.isShared).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// POST /api/me/credential (Task 3 — member self-service, D-07, T-10-12)
|
||||
// ===========================================================================
|
||||
|
||||
describe('POST /api/me/credential', () => {
|
||||
it('ignores body userId and writes only to session user (Pitfall 6 / T-10-12)', async () => {
|
||||
const userA = await seedUser('self-service-a', false);
|
||||
const userB = await seedUser('self-service-b', false);
|
||||
currentDevUserId = userA; // logged in as userA
|
||||
|
||||
const app = await getApp();
|
||||
|
||||
const res = await app.fetch(
|
||||
jsonRequest('POST', '/api/me/credential', {
|
||||
userId: userB, // body says userB — must be IGNORED
|
||||
providerType: 'caldav',
|
||||
fastmailEmail: 'usera@fastmail.com',
|
||||
appPassword: 'self-service-pass-abcxyz',
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
// userA should have a credential
|
||||
const [credA] = await db
|
||||
.select({ id: memberCredentials.id })
|
||||
.from(memberCredentials)
|
||||
.where(eq(memberCredentials.userId, userA))
|
||||
.limit(1);
|
||||
expect(credA).toBeDefined();
|
||||
|
||||
// userB should NOT have a credential
|
||||
const [credB] = await db
|
||||
.select({ id: memberCredentials.id })
|
||||
.from(memberCredentials)
|
||||
.where(eq(memberCredentials.userId, userB))
|
||||
.limit(1);
|
||||
expect(credB).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns 200 and stores encrypted password for valid credential', async () => {
|
||||
const userId = await seedUser('self-service-valid', false);
|
||||
currentDevUserId = userId;
|
||||
|
||||
const plainPassword = 'self-service-valid-pass-qwerty9876';
|
||||
const app = await getApp();
|
||||
|
||||
const res = await app.fetch(
|
||||
jsonRequest('POST', '/api/me/credential', {
|
||||
providerType: 'caldav',
|
||||
fastmailEmail: 'me@fastmail.com',
|
||||
appPassword: plainPassword,
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const [stored] = await db
|
||||
.select({ encryptedPassword: memberCredentials.encryptedPassword })
|
||||
.from(memberCredentials)
|
||||
.where(eq(memberCredentials.userId, userId))
|
||||
.limit(1);
|
||||
expect(stored).toBeDefined();
|
||||
expect(stored.encryptedPassword).not.toBe(plainPassword);
|
||||
const parsed = JSON.parse(stored.encryptedPassword) as Record<string, unknown>;
|
||||
expect(parsed.iv).toBeDefined();
|
||||
});
|
||||
|
||||
it('returns 400 with no echoed password when credential validation fails (Pitfall 7)', async () => {
|
||||
const userId = await seedUser('self-service-fail', false);
|
||||
currentDevUserId = userId;
|
||||
|
||||
const submittedPassword = 'self-service-bad-pass-xyz9999';
|
||||
mockFetchCalendars = () => Promise.reject(new Error('PROPFIND auth failure'));
|
||||
|
||||
const app = await getApp();
|
||||
|
||||
const res = await app.fetch(
|
||||
jsonRequest('POST', '/api/me/credential', {
|
||||
providerType: 'caldav',
|
||||
fastmailEmail: 'me@fastmail.com',
|
||||
appPassword: submittedPassword,
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
const bodyText = await res.text();
|
||||
expect(bodyText).not.toContain(submittedPassword);
|
||||
const body = JSON.parse(bodyText) as { error: string };
|
||||
expect(body.error).toBe('Invalid request');
|
||||
});
|
||||
|
||||
it('does NOT require admin — a normal member can set their own credential', async () => {
|
||||
const userId = await seedUser('non-admin-self-service', false);
|
||||
currentDevUserId = userId;
|
||||
|
||||
const app = await getApp();
|
||||
|
||||
const res = await app.fetch(
|
||||
jsonRequest('POST', '/api/me/credential', {
|
||||
providerType: 'caldav',
|
||||
fastmailEmail: 'member@fastmail.com',
|
||||
appPassword: 'member-pass-abcabc123',
|
||||
}),
|
||||
);
|
||||
// Should succeed (200) — no admin requirement on this endpoint
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* GET /api/me — regression tests for dev-auth bypass path.
|
||||
* GET /api/me — regression tests for dev-auth bypass path + isAdmin/needsProviderSetup
|
||||
*
|
||||
* Covers:
|
||||
* 1. DEV_AUTH_BYPASS=true (non-production): GET /api/me returns 200 with the injected
|
||||
@@ -8,6 +8,10 @@
|
||||
* 2. Without DEV_AUTH_BYPASS: the OIDC middleware is still wired on /api/*.
|
||||
* Verified structurally by asserting oidcAuthMiddleware is called during app init
|
||||
* (the mock intercepts it and acts as a passthrough, confirming the mount path).
|
||||
* 3. Plan 10-02 additions:
|
||||
* - dev-bypass path returns isAdmin (DB-backed, not hardcoded) + needsProviderSetup
|
||||
* - OIDC path returns isAdmin + needsProviderSetup
|
||||
* - needsProviderSetup=true when no member_credentials row exists; false when one exists
|
||||
*
|
||||
* Architecture note:
|
||||
* devAuthBypass() and devBypassActive in index.ts both evaluate env vars at module
|
||||
@@ -20,13 +24,19 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared mock: DB — avoids real DB connections across all tests in this file.
|
||||
// This mock is hoisted by Vitest and applies to every dynamic import below.
|
||||
//
|
||||
// Default: select chain returns empty arrays (no rows).
|
||||
// Per-test overrides: use vi.mocked(db.select).mockImplementation(...) to
|
||||
// supply per-call sequences for isAdmin and memberCredentials lookups.
|
||||
// ---------------------------------------------------------------------------
|
||||
vi.mock('../../src/db/client.js', () => ({
|
||||
db: {
|
||||
execute: vi.fn().mockResolvedValue([[{ '1': 1 }]]),
|
||||
select: vi.fn().mockReturnValue({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockResolvedValue([]),
|
||||
where: vi.fn().mockReturnValue({
|
||||
limit: vi.fn().mockResolvedValue([]),
|
||||
}),
|
||||
innerJoin: vi.fn().mockReturnValue({
|
||||
innerJoin: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockResolvedValue([]),
|
||||
@@ -138,3 +148,108 @@ describe('GET /api/me — OIDC path (no DEV_AUTH_BYPASS)', () => {
|
||||
expect(body.error).toBe('Unauthorized');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Plan 10-02: isAdmin + needsProviderSetup on /api/me (D-03)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('GET /api/me — isAdmin + needsProviderSetup (Plan 10-02, D-03)', () => {
|
||||
beforeEach(() => {
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.DEV_AUTH_BYPASS = 'true';
|
||||
});
|
||||
|
||||
it('dev-bypass: response includes isAdmin (DB-backed from users.is_admin, not hardcoded)', async () => {
|
||||
// Set up db.select to return isAdmin=true for the users lookup,
|
||||
// and [] for the memberCredentials lookup (needsProviderSetup=true).
|
||||
const { db } = await import('../../src/db/client.js');
|
||||
|
||||
let callCount = 0;
|
||||
vi.mocked(db.select).mockImplementation(() => {
|
||||
callCount++;
|
||||
const limitFn =
|
||||
callCount === 1
|
||||
? vi.fn().mockResolvedValue([{ isAdmin: true }]) // users.isAdmin lookup
|
||||
: vi.fn().mockResolvedValue([]); // memberCredentials lookup (none)
|
||||
return {
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({ limit: limitFn }),
|
||||
innerJoin: vi.fn().mockReturnValue({
|
||||
innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }),
|
||||
}),
|
||||
}),
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any;
|
||||
});
|
||||
|
||||
const { app } = await import('../../src/index.js');
|
||||
const res = await app.request('/api/me');
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const body = (await res.json()) as {
|
||||
user: { id: number; isAdmin: boolean; needsProviderSetup: boolean };
|
||||
};
|
||||
expect(body.user).toHaveProperty('isAdmin');
|
||||
expect(body.user.isAdmin).toBe(true); // DB returns true, not hardcoded
|
||||
});
|
||||
|
||||
it('dev-bypass: needsProviderSetup=true when no member_credentials row exists', async () => {
|
||||
const { db } = await import('../../src/db/client.js');
|
||||
|
||||
let callCount = 0;
|
||||
vi.mocked(db.select).mockImplementation(() => {
|
||||
callCount++;
|
||||
const limitFn =
|
||||
callCount === 1
|
||||
? vi.fn().mockResolvedValue([{ isAdmin: false }]) // users.isAdmin lookup
|
||||
: vi.fn().mockResolvedValue([]); // no member_credentials row
|
||||
return {
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({ limit: limitFn }),
|
||||
innerJoin: vi.fn().mockReturnValue({
|
||||
innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }),
|
||||
}),
|
||||
}),
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any;
|
||||
});
|
||||
|
||||
const { app } = await import('../../src/index.js');
|
||||
const res = await app.request('/api/me');
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const body = (await res.json()) as { user: { needsProviderSetup: boolean } };
|
||||
expect(body.user).toHaveProperty('needsProviderSetup');
|
||||
expect(body.user.needsProviderSetup).toBe(true);
|
||||
});
|
||||
|
||||
it('dev-bypass: needsProviderSetup=false when a member_credentials row exists', async () => {
|
||||
const { db } = await import('../../src/db/client.js');
|
||||
|
||||
let callCount = 0;
|
||||
vi.mocked(db.select).mockImplementation(() => {
|
||||
callCount++;
|
||||
const limitFn =
|
||||
callCount === 1
|
||||
? vi.fn().mockResolvedValue([{ isAdmin: false }]) // users.isAdmin lookup
|
||||
: vi.fn().mockResolvedValue([{ id: 7 }]); // has member_credentials row
|
||||
return {
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({ limit: limitFn }),
|
||||
innerJoin: vi.fn().mockReturnValue({
|
||||
innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }),
|
||||
}),
|
||||
}),
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any;
|
||||
});
|
||||
|
||||
const { app } = await import('../../src/index.js');
|
||||
const res = await app.request('/api/me');
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const body = (await res.json()) as { user: { needsProviderSetup: boolean } };
|
||||
expect(body.user).toHaveProperty('needsProviderSetup');
|
||||
expect(body.user.needsProviderSetup).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* admin.spec.ts — Phase 10 Plan 04 admin route gate
|
||||
*
|
||||
* Tests:
|
||||
* - Admin user (id=1, seeded is_admin=true by global-setup) sees the Admin nav entry
|
||||
* and reaches /admin with "Admin Settings" heading + Members section.
|
||||
* - Non-admin (route-mocked isAdmin:false) does NOT see the Admin nav entry and is
|
||||
* redirected from /admin to /calendar.
|
||||
*
|
||||
* Requires the dev stack running with DEV_AUTH_BYPASS=true (see e2e/README.md).
|
||||
* global-setup seeds: users id=1 is_admin=true (Plan 10-01 note).
|
||||
*
|
||||
* Route-mock pattern for non-admin simulation:
|
||||
* page.route('/api/me', ...) → { user: { ..., isAdmin: false, needsProviderSetup: false } }
|
||||
* per [[dev-data-user1-no-calendars]] idiom + lists.spec page.route precedent.
|
||||
*
|
||||
* Runs on all three device profiles automatically (playwright.config.ts matrix):
|
||||
* iphone: iPhone 14 / WebKit / 390×844
|
||||
* pixel: Pixel 7 / Chromium / 412×915
|
||||
* desktop: Desktop Chrome / Chromium / 1280×720
|
||||
*
|
||||
* Run:
|
||||
* pnpm --filter @familysync/pwa test:e2e
|
||||
* pnpm --filter @familysync/pwa exec playwright test admin.spec.ts
|
||||
* pnpm --filter @familysync/pwa test:e2e -- admin
|
||||
*/
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
// ── Admin user (seeded is_admin=true) ─────────────────────────────────────────
|
||||
|
||||
test.describe('Admin user — admin nav entry + /admin route', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/calendar');
|
||||
// Wait for auth and nav to be visible before asserting
|
||||
await expect(page.getByRole('navigation', { name: 'Main navigation' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('admin sees the Admin nav entry (ShieldCheck, aria-label="Admin settings")', async ({
|
||||
page,
|
||||
}) => {
|
||||
// The admin nav link is rendered with aria-label="Admin settings" in both
|
||||
// AppNav (desktop) and BottomTabBar (mobile).
|
||||
const adminEntry = page.getByRole('link', { name: 'Admin settings' });
|
||||
await expect(adminEntry).toBeVisible();
|
||||
});
|
||||
|
||||
test('admin reaches /admin and sees "Admin Settings" heading', async ({ page }) => {
|
||||
// Navigate directly — also verifies the route guard does NOT redirect admins
|
||||
await page.goto('/admin');
|
||||
await expect(page.getByRole('navigation', { name: 'Main navigation' })).toBeVisible();
|
||||
// The page heading is "Admin Settings"
|
||||
await expect(page.getByRole('heading', { name: 'Admin Settings' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('admin /admin page renders the MEMBERS section', async ({ page }) => {
|
||||
await page.goto('/admin');
|
||||
await expect(page.getByRole('navigation', { name: 'Main navigation' })).toBeVisible();
|
||||
// The section is labeled "Members" (aria-label on <section>)
|
||||
await expect(page.getByRole('region', { name: 'Members' })).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Non-admin user (route-mocked isAdmin:false) ───────────────────────────────
|
||||
|
||||
test.describe('Non-admin user — admin nav entry hidden + /admin redirect', () => {
|
||||
// Route-mock /api/me to return isAdmin:false BEFORE navigation so the PWA
|
||||
// never sees isAdmin:true in this test context.
|
||||
const mockNonAdminMe = async (page: import('@playwright/test').Page) => {
|
||||
await page.route('/api/me', (route) => {
|
||||
void route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
user: {
|
||||
id: 1,
|
||||
displayName: 'Dev User',
|
||||
color: '#4A90D9',
|
||||
isAdmin: false,
|
||||
needsProviderSetup: false,
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
test('non-admin does NOT see the Admin nav entry', async ({ page }) => {
|
||||
await mockNonAdminMe(page);
|
||||
await page.goto('/calendar');
|
||||
await expect(page.getByRole('navigation', { name: 'Main navigation' })).toBeVisible();
|
||||
// Admin entry must be absent
|
||||
const adminEntry = page.getByRole('link', { name: 'Admin settings' });
|
||||
await expect(adminEntry).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('non-admin navigating to /admin is redirected to /calendar', async ({ page }) => {
|
||||
await mockNonAdminMe(page);
|
||||
await page.goto('/admin');
|
||||
// Wait for meQuery to resolve and redirect to fire — the Navigate component
|
||||
// replaces the URL once meQuery.isLoading = false + isAdmin = false.
|
||||
await page.waitForURL(/\/calendar/, { timeout: 10_000 });
|
||||
// Should have landed on /calendar
|
||||
const url = new URL(page.url());
|
||||
expect(url.pathname, `Expected /calendar but got ${url.pathname}`).toMatch(/^\/(calendar)?$/);
|
||||
// "Admin Settings" heading must NOT be present
|
||||
await expect(page.getByRole('heading', { name: 'Admin Settings' })).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
@@ -109,6 +109,18 @@ export default async function globalSetup(): Promise<void> {
|
||||
await conn.execute('TRUNCATE TABLE calendar_events');
|
||||
await conn.execute('SET FOREIGN_KEY_CHECKS=1');
|
||||
|
||||
// Seed the dev-bypass admin user row for id=1 (D-01 dev note, Phase 10).
|
||||
// DEV_USER (id=1) is injected by devBypass.ts WITHOUT a DB upsert, so the users table
|
||||
// has no row for id=1 by default. requireAdmin (Plan 02) does a DB lookup and would 403
|
||||
// the bypass admin UI locally and in e2e. This idempotent seed ensures is_admin=true for
|
||||
// id=1 so admin-UI verification works under DEV_AUTH_BYPASS=true.
|
||||
// oidc_iss/oidc_sub are placeholder non-null values — the bypass path never reads them.
|
||||
await conn.execute(
|
||||
`INSERT INTO users (id, oidc_iss, oidc_sub, display_name, color, is_admin)
|
||||
VALUES (1, 'dev-bypass', 'dev-user-1', 'Dev User', '#4A90D9', true)
|
||||
ON DUPLICATE KEY UPDATE is_admin=true`,
|
||||
);
|
||||
|
||||
// CI guard (Pitfall 4): ensure calendar row id=10 exists before inserting events.
|
||||
// INSERT IGNORE is a no-op if the row already exists (dev DB), creates it if not (CI fresh DB).
|
||||
await conn.execute(
|
||||
|
||||
+28
-1
@@ -42,10 +42,12 @@ import { useQuery } from '@tanstack/react-query';
|
||||
import { CalendarShell } from './components/CalendarShell.js';
|
||||
import { ListsIndex } from './routes/ListsIndex.js';
|
||||
import { ListDetail } from './routes/ListDetail.js';
|
||||
import { AdminPage } from './routes/AdminPage.js';
|
||||
import { BottomTabBar } from './components/BottomTabBar.js';
|
||||
import { AppNav } from './components/AppNav.js';
|
||||
import { PushPermissionPrompt } from './components/PushPermissionPrompt.js';
|
||||
import { PermissionDeniedBanner } from './components/PermissionDeniedBanner.js';
|
||||
import { SetupBanner } from './components/SetupBanner.js';
|
||||
import { SettingsSheet } from './components/SettingsSheet.js';
|
||||
import { fetchMe } from './api/client.js';
|
||||
|
||||
@@ -67,6 +69,11 @@ export default function App() {
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
// isAdmin from /api/me — used for UX gating only (D-03). Server enforces 403.
|
||||
// While meQuery is loading, isAdmin is false/undefined → admin route redirects
|
||||
// (loading gate: no flash of admin content for non-admins).
|
||||
const isAdmin = meQuery.data?.user.isAdmin ?? false;
|
||||
|
||||
// Derive members for AppNav from the shared /api/me response
|
||||
const members = useMemo(() => {
|
||||
if (!meQuery.data?.user) return [];
|
||||
@@ -114,21 +121,41 @@ export default function App() {
|
||||
currentUserColor={meQuery.data?.user.color}
|
||||
currentUserName={meQuery.data?.user.displayName ?? undefined}
|
||||
onOpenSettings={() => setSettingsOpen(true)}
|
||||
isAdmin={isAdmin}
|
||||
/>
|
||||
|
||||
{/* Main content area — all routes render here */}
|
||||
<div style={contentStyle}>
|
||||
{/* SetupBanner: shown above content when needsProviderSetup=true (D-07).
|
||||
Reads from the shared ['me'] query — no additional fetch. */}
|
||||
<SetupBanner />
|
||||
|
||||
<Routes>
|
||||
<Route path="/" element={<Navigate to="/calendar" replace />} />
|
||||
<Route path="/calendar" element={<CalendarShell />} />
|
||||
<Route path="/lists" element={<ListsIndex />} />
|
||||
<Route path="/lists/:listId" element={<ListDetail />} />
|
||||
{/* /admin route: gated by isAdmin (UX, D-03). Server enforces 403 on all /api/admin/* */}
|
||||
{/* Loading gate: show nothing while meQuery is fetching (prevents flash).
|
||||
Once resolved: isAdmin → AdminPage; else → redirect to /calendar. */}
|
||||
<Route
|
||||
path="/admin"
|
||||
element={
|
||||
meQuery.isLoading ? (
|
||||
<div aria-hidden="true" />
|
||||
) : isAdmin ? (
|
||||
<AdminPage />
|
||||
) : (
|
||||
<Navigate to="/calendar" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* BottomTabBar — phone-only (hidden ≥768px via CSS, FIX 4) */}
|
||||
<BottomTabBar />
|
||||
<BottomTabBar isAdmin={isAdmin} />
|
||||
|
||||
{/* Post-install permission prompt (D-08): renders only when isInstalled() is true
|
||||
and Notification.permission === 'default' and not dismissed */}
|
||||
|
||||
@@ -63,6 +63,8 @@ export interface MeUser {
|
||||
id: number;
|
||||
displayName: string | null;
|
||||
color: string;
|
||||
isAdmin: boolean; // from users.is_admin — UX gating only (D-03); server enforces 403 on /api/admin/*
|
||||
needsProviderSetup: boolean; // true when no member_credentials row exists for this user
|
||||
}
|
||||
|
||||
export interface MeResponse {
|
||||
@@ -325,3 +327,137 @@ export async function fetchWritableCalendars(): Promise<WritableCalendar[]> {
|
||||
const body = (await res.json()) as { calendars: WritableCalendar[] };
|
||||
return body.calendars;
|
||||
}
|
||||
|
||||
// ── /api/admin/* (Phase 10, Plan 04) ─────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* A member row as returned by GET /api/admin/members.
|
||||
* Matches the Plan-03 shape: { id, displayName, color, hasCredential }.
|
||||
*/
|
||||
export interface AdminMember {
|
||||
id: number;
|
||||
displayName: string | null;
|
||||
color: string;
|
||||
hasCredential: boolean;
|
||||
}
|
||||
|
||||
export interface AdminMembersResponse {
|
||||
members: AdminMember[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Payload for POST /api/admin/credentials (admin-managed rotation).
|
||||
* NOTE: includes userId (the member being rotated) — admin-scoped.
|
||||
* The app password MUST NOT be logged or stored beyond the in-flight request body (T-10-15).
|
||||
*/
|
||||
export interface SaveCredentialPayload {
|
||||
userId: number;
|
||||
providerType: 'caldav';
|
||||
fastmailEmail: string;
|
||||
appPassword: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A synced calendar row as returned by GET /api/admin/calendars.
|
||||
* Matches the Plan-03 shape: { id, displayName, isShared }.
|
||||
*/
|
||||
export interface AdminCalendar {
|
||||
id: number;
|
||||
displayName: string;
|
||||
isShared: boolean;
|
||||
}
|
||||
|
||||
export interface AdminCalendarsResponse {
|
||||
calendars: AdminCalendar[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Payload for POST /api/me/credential (self-service, member-scoped).
|
||||
* NOTE: no userId field — the server resolves userId from the session (Pitfall 6 / T-10-12).
|
||||
* The app password MUST NOT be logged or stored beyond the in-flight request body (T-10-15).
|
||||
*/
|
||||
export interface SaveMyCredentialPayload {
|
||||
providerType: 'caldav';
|
||||
fastmailEmail: string;
|
||||
appPassword: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the list of all members with their credential status.
|
||||
* Admin-only: the server enforces requireAdmin (403 for non-admins).
|
||||
*/
|
||||
export async function fetchAdminMembers(): Promise<AdminMembersResponse> {
|
||||
const res = await fetch('/api/admin/members', {
|
||||
credentials: 'include',
|
||||
redirect: 'manual',
|
||||
});
|
||||
|
||||
handleAuthResponse(res, 'GET /api/admin/members');
|
||||
|
||||
return res.json() as Promise<AdminMembersResponse>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save (add or rotate) a credential for any member.
|
||||
* Admin-only: requires userId in payload; server enforces requireAdmin.
|
||||
* The app password is sent in the request body and NEVER stored client-side.
|
||||
*/
|
||||
export async function saveCredential(payload: SaveCredentialPayload): Promise<void> {
|
||||
const res = await fetch('/api/admin/credentials', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
redirect: 'manual',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
handleAuthResponse(res, 'POST /api/admin/credentials');
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the list of synced calendars with their shared status.
|
||||
* Admin-only: the server enforces requireAdmin (403 for non-admins).
|
||||
*/
|
||||
export async function fetchAdminCalendars(): Promise<AdminCalendarsResponse> {
|
||||
const res = await fetch('/api/admin/calendars', {
|
||||
credentials: 'include',
|
||||
redirect: 'manual',
|
||||
});
|
||||
|
||||
handleAuthResponse(res, 'GET /api/admin/calendars');
|
||||
|
||||
return res.json() as Promise<AdminCalendarsResponse>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the shared calendar (ADMIN-02: exclusive single-select, D-06).
|
||||
* Admin-only: the server enforces requireAdmin.
|
||||
* Clears is_shared on all other calendars and sets it on the given id.
|
||||
*/
|
||||
export async function setSharedCalendar(calendarId: number): Promise<void> {
|
||||
const res = await fetch(`/api/admin/calendars/${calendarId}/shared`, {
|
||||
method: 'PUT',
|
||||
credentials: 'include',
|
||||
redirect: 'manual',
|
||||
});
|
||||
|
||||
handleAuthResponse(res, `PUT /api/admin/calendars/${calendarId}/shared`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Self-service: save (add) the current member's own credential.
|
||||
* Member-scoped: NO userId in payload — server resolves from session (Pitfall 6 / T-10-12).
|
||||
* The app password is sent in the request body and NEVER stored client-side.
|
||||
* On success, /api/me re-fetched via ['me'] cache invalidation → needsProviderSetup becomes false.
|
||||
*/
|
||||
export async function saveMyCredential(payload: SaveMyCredentialPayload): Promise<void> {
|
||||
const res = await fetch('/api/me/credential', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
redirect: 'manual',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
handleAuthResponse(res, 'POST /api/me/credential');
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
*/
|
||||
|
||||
import { NavLink } from 'react-router';
|
||||
import { CalendarDays, List } from 'lucide-react';
|
||||
import { CalendarDays, List, ShieldCheck } from 'lucide-react';
|
||||
import { ColorLegend, type LegendMember } from './ColorLegend.js';
|
||||
|
||||
interface AppNavProps {
|
||||
@@ -21,6 +21,8 @@ interface AppNavProps {
|
||||
currentUserName?: string;
|
||||
/** Called when the user avatar is tapped — opens the Settings sheet. */
|
||||
onOpenSettings?: () => void;
|
||||
/** When true, renders the Admin nav entry (ShieldCheck). UX gating only (D-03). */
|
||||
isAdmin?: boolean;
|
||||
}
|
||||
|
||||
export function AppNav({
|
||||
@@ -28,6 +30,7 @@ export function AppNav({
|
||||
currentUserColor,
|
||||
currentUserName,
|
||||
onOpenSettings,
|
||||
isAdmin = false,
|
||||
}: AppNavProps) {
|
||||
const isMobile = typeof window !== 'undefined' && window.matchMedia('(max-width: 767px)').matches;
|
||||
|
||||
@@ -47,6 +50,7 @@ export function AppNav({
|
||||
currentUserColor={currentUserColor}
|
||||
currentUserName={currentUserName}
|
||||
onOpenSettings={onOpenSettings}
|
||||
isAdmin={isAdmin}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -129,11 +133,13 @@ function DesktopNav({
|
||||
currentUserColor,
|
||||
currentUserName,
|
||||
onOpenSettings,
|
||||
isAdmin = false,
|
||||
}: {
|
||||
members: LegendMember[];
|
||||
currentUserColor?: string;
|
||||
currentUserName?: string;
|
||||
onOpenSettings?: () => void;
|
||||
isAdmin?: boolean;
|
||||
}) {
|
||||
const navLinkStyle = ({ isActive }: { isActive: boolean }): React.CSSProperties => ({
|
||||
display: 'flex',
|
||||
@@ -198,6 +204,13 @@ function DesktopNav({
|
||||
<List size={18} aria-hidden="true" />
|
||||
Lists
|
||||
</NavLink>
|
||||
{/* Admin entry — only when isAdmin=true (UX gating, D-03) */}
|
||||
{isAdmin && (
|
||||
<NavLink to="/admin" style={navLinkStyle} aria-label="Admin settings">
|
||||
<ShieldCheck size={18} aria-hidden="true" />
|
||||
Admin
|
||||
</NavLink>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Color legend */}
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
*/
|
||||
|
||||
import { NavLink } from 'react-router';
|
||||
import { CalendarDays, List } from 'lucide-react';
|
||||
import { CalendarDays, List, ShieldCheck } from 'lucide-react';
|
||||
|
||||
function isPhone(): boolean {
|
||||
return typeof window !== 'undefined' && window.matchMedia('(max-width: 767px)').matches;
|
||||
@@ -49,7 +49,12 @@ const tabActiveOverride: React.CSSProperties = {
|
||||
borderBottom: '2px solid var(--color-member-0)',
|
||||
};
|
||||
|
||||
export function BottomTabBar() {
|
||||
interface BottomTabBarProps {
|
||||
/** When true, renders the Admin tab (ShieldCheck). UX gating only (D-03). */
|
||||
isAdmin?: boolean;
|
||||
}
|
||||
|
||||
export function BottomTabBar({ isAdmin = false }: BottomTabBarProps) {
|
||||
// Phone-only: return null on desktop (≥768px) so the fixed bar does not overlay
|
||||
// the AppNav sidebar's Settings/avatar button (FIX 4). Consistent with the
|
||||
// isPhone() breakpoint used in AppNav and CalendarShell.
|
||||
@@ -96,6 +101,21 @@ export function BottomTabBar() {
|
||||
<List size={22} aria-hidden="true" />
|
||||
<span>Lists</span>
|
||||
</NavLink>
|
||||
|
||||
{/* Admin tab — only when isAdmin=true (UX gating, D-03) */}
|
||||
{isAdmin && (
|
||||
<NavLink
|
||||
to="/admin"
|
||||
aria-label="Admin settings"
|
||||
style={({ isActive }) => ({
|
||||
...tabBase,
|
||||
...(isActive ? tabActiveOverride : {}),
|
||||
})}
|
||||
>
|
||||
<ShieldCheck size={22} aria-hidden="true" />
|
||||
<span>Admin</span>
|
||||
</NavLink>
|
||||
)}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,396 @@
|
||||
/**
|
||||
* CredentialSheet — credential bottom sheet for admin rotation and member self-service (D-07).
|
||||
*
|
||||
* Shared by two paths:
|
||||
* - admin-rotate: admin sets a credential for a member who already has one ("Rotate Credential")
|
||||
* - admin-add: admin sets a credential for a member who has none ("Add Credential")
|
||||
* - self-service: member sets their own credential ("Add your calendar credential")
|
||||
*
|
||||
* UI-SPEC §Surface 3:
|
||||
* - Bottom sheet: role="dialog", aria-modal, zIndex 301 (backdrop 300)
|
||||
* - borderRadius 12px 12px 0 0 / padding var(--space-6) / maxWidth 480px centered desktop
|
||||
* - Heading variant per mode, member-name subtitle, type="password" / autocomplete="new-password" (T-10-16)
|
||||
* - Helper text + Fastmail app-password link (new tab, rel="noopener noreferrer") (T-10-15)
|
||||
* - "Validating against CalDAV…" Loader2 spinner inline during mutation
|
||||
* - CalDAV 400 failure copy, Save Credential / Cancel actions
|
||||
* - Success: invalidates ['admin','members'] + ['me'] → needsProviderSetup refresh → SetupBanner unmounts
|
||||
* - Escape closes; focus returns to trigger on close
|
||||
* - 44px touch targets throughout
|
||||
*
|
||||
* Security:
|
||||
* T-10-15: password never pre-filled, never logged, never stored beyond in-flight request
|
||||
* T-10-16: autoComplete="new-password" prevents autofill of stored credential
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import {
|
||||
saveCredential,
|
||||
saveMyCredential,
|
||||
type SaveCredentialPayload,
|
||||
type SaveMyCredentialPayload,
|
||||
} from '../api/client.js';
|
||||
|
||||
export type CredentialSheetMode = 'admin-rotate' | 'admin-add' | 'self-service';
|
||||
|
||||
interface CredentialSheetProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
/** Mode determines heading copy and which API endpoint is called */
|
||||
mode: CredentialSheetMode;
|
||||
/** The member being targeted (admin modes). For self-service, the current user's name. */
|
||||
memberName: string | null;
|
||||
/** The member's user id (admin modes only — ignored for self-service) */
|
||||
memberId?: number;
|
||||
/** Ref to the trigger element — focus returns here on close (a11y) */
|
||||
triggerRef?: React.RefObject<HTMLElement | null>;
|
||||
}
|
||||
|
||||
// ── Copywriting contract (UI-SPEC §Copywriting Contract) ───────────────────
|
||||
|
||||
function headingFor(mode: CredentialSheetMode): string {
|
||||
if (mode === 'admin-rotate') return 'Rotate Credential';
|
||||
if (mode === 'admin-add') return 'Add Credential';
|
||||
return 'Add your calendar credential';
|
||||
}
|
||||
|
||||
const HELPER_TEXT = 'Enter the Fastmail app password scoped to Calendars/CalDAV.';
|
||||
const HELPER_LINK_HREF = 'https://app.fastmail.com/settings/security/devicetokens';
|
||||
const HELPER_LINK_TEXT = 'Get an app password';
|
||||
const HELPER_LINK_SUFFIX = " — choose the 'Calendars & Contacts (CalDAV)' scope.";
|
||||
const VALIDATING_TEXT = 'Validating against CalDAV…';
|
||||
const FAILURE_TEXT =
|
||||
"Invalid password — CalDAV validation failed. Check the scope is 'Calendars & Contacts (CalDAV)' and try again.";
|
||||
const SAVE_LABEL = 'Save Credential';
|
||||
const CANCEL_LABEL = 'Cancel';
|
||||
|
||||
// ── Component ──────────────────────────────────────────────────────────────
|
||||
|
||||
export function CredentialSheet({
|
||||
isOpen,
|
||||
onClose,
|
||||
mode,
|
||||
memberName,
|
||||
memberId,
|
||||
triggerRef,
|
||||
}: CredentialSheetProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const [password, setPassword] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [validationError, setValidationError] = useState<string | null>(null);
|
||||
// Focus the heading/first focusable element on open (a11y)
|
||||
const headingRef = useRef<HTMLHeadingElement>(null);
|
||||
|
||||
// Escape key closes the sheet (SettingsSheet pattern)
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
handleClose();
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', onKeyDown);
|
||||
return () => document.removeEventListener('keydown', onKeyDown);
|
||||
}, [isOpen]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Focus heading on open (a11y)
|
||||
useEffect(() => {
|
||||
if (isOpen && headingRef.current) {
|
||||
headingRef.current.focus();
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
function handleClose() {
|
||||
setPassword('');
|
||||
setEmail('');
|
||||
setValidationError(null);
|
||||
onClose();
|
||||
// Return focus to trigger element (a11y)
|
||||
if (triggerRef?.current) {
|
||||
triggerRef.current.focus();
|
||||
}
|
||||
}
|
||||
|
||||
const credentialMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (mode === 'self-service') {
|
||||
const payload: SaveMyCredentialPayload = {
|
||||
providerType: 'caldav',
|
||||
fastmailEmail: email,
|
||||
appPassword: password,
|
||||
};
|
||||
await saveMyCredential(payload);
|
||||
} else {
|
||||
if (!memberId) throw new Error('memberId required for admin modes');
|
||||
const payload: SaveCredentialPayload = {
|
||||
userId: memberId,
|
||||
providerType: 'caldav',
|
||||
fastmailEmail: email,
|
||||
appPassword: password,
|
||||
};
|
||||
await saveCredential(payload);
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
// Invalidate both caches: admin member list + /api/me (needsProviderSetup refresh)
|
||||
void queryClient.invalidateQueries({ queryKey: ['admin', 'members'] });
|
||||
void queryClient.invalidateQueries({ queryKey: ['me'] });
|
||||
handleClose();
|
||||
},
|
||||
onError: () => {
|
||||
setValidationError(FAILURE_TEXT);
|
||||
},
|
||||
});
|
||||
|
||||
const handleSave = () => {
|
||||
setValidationError(null);
|
||||
credentialMutation.mutate();
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const heading = headingFor(mode);
|
||||
const isPending = credentialMutation.isPending;
|
||||
const saveDisabled = isPending || password.trim().length === 0 || email.trim().length === 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
onClick={handleClose}
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
background: 'rgba(0,0,0,0.4)',
|
||||
zIndex: 300,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Sheet */}
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={heading}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
background: 'var(--color-surface)',
|
||||
borderRadius: '12px 12px 0 0',
|
||||
boxShadow: '0 -4px 24px rgba(0,0,0,0.15)',
|
||||
padding: 'var(--space-6, 24px)',
|
||||
zIndex: 301,
|
||||
fontFamily: 'var(--font-family-base)',
|
||||
maxWidth: '480px',
|
||||
margin: '0 auto',
|
||||
}}
|
||||
>
|
||||
{/* Heading */}
|
||||
<h2
|
||||
ref={headingRef}
|
||||
tabIndex={-1}
|
||||
style={{
|
||||
margin: '0 0 var(--space-1, 4px) 0',
|
||||
fontSize: 'var(--text-heading-size, 18px)',
|
||||
fontWeight: 600,
|
||||
lineHeight: 'var(--text-heading-line-height, 1.25)',
|
||||
color: 'var(--color-text-primary)',
|
||||
outline: 'none',
|
||||
}}
|
||||
>
|
||||
{heading}
|
||||
</h2>
|
||||
|
||||
{/* Member subtitle */}
|
||||
{memberName && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: 'var(--text-body-size, 15px)',
|
||||
fontWeight: 400,
|
||||
color: 'var(--color-text-secondary)',
|
||||
marginBottom: 'var(--space-6, 24px)',
|
||||
}}
|
||||
>
|
||||
{memberName}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Email field */}
|
||||
<div style={{ marginBottom: 'var(--space-4, 16px)' }}>
|
||||
<label
|
||||
htmlFor="credential-email"
|
||||
style={{
|
||||
display: 'block',
|
||||
fontSize: 'var(--text-label-size, 13px)',
|
||||
fontWeight: 600,
|
||||
color: 'var(--color-text-primary)',
|
||||
marginBottom: 'var(--space-1, 4px)',
|
||||
}}
|
||||
>
|
||||
Fastmail email
|
||||
</label>
|
||||
<input
|
||||
id="credential-email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="user@fastmail.com"
|
||||
style={{
|
||||
width: '100%',
|
||||
boxSizing: 'border-box',
|
||||
padding: 'var(--space-3, 12px) var(--space-4, 16px)',
|
||||
border: `1px solid ${validationError ? 'var(--color-destructive, #DC2626)' : 'var(--color-border)'}`,
|
||||
borderRadius: 'var(--space-1, 4px)',
|
||||
fontSize: 'var(--text-body-size, 15px)',
|
||||
color: 'var(--color-text-primary)',
|
||||
background: 'var(--color-surface)',
|
||||
fontFamily: 'var(--font-family-base)',
|
||||
outline: 'none',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Password field */}
|
||||
<div style={{ marginBottom: 'var(--space-2, 8px)' }}>
|
||||
<label
|
||||
htmlFor="credential-password"
|
||||
style={{
|
||||
display: 'block',
|
||||
fontSize: 'var(--text-label-size, 13px)',
|
||||
fontWeight: 600,
|
||||
color: 'var(--color-text-primary)',
|
||||
marginBottom: 'var(--space-1, 4px)',
|
||||
}}
|
||||
>
|
||||
App password
|
||||
</label>
|
||||
<input
|
||||
id="credential-password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
// NEVER pre-filled — T-10-16: existing credential is never fetched to client
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
aria-describedby="credential-helper"
|
||||
style={{
|
||||
width: '100%',
|
||||
boxSizing: 'border-box',
|
||||
padding: 'var(--space-3, 12px) var(--space-4, 16px)',
|
||||
border: `1px solid ${validationError ? 'var(--color-destructive, #DC2626)' : 'var(--color-border)'}`,
|
||||
borderRadius: 'var(--space-1, 4px)',
|
||||
fontSize: 'var(--text-body-size, 15px)',
|
||||
color: 'var(--color-text-primary)',
|
||||
background: 'var(--color-surface)',
|
||||
fontFamily: 'var(--font-family-base)',
|
||||
outline: 'none',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Helper text / validation state */}
|
||||
<div
|
||||
id="credential-helper"
|
||||
style={{
|
||||
fontSize: 'var(--text-label-size, 13px)',
|
||||
color: validationError
|
||||
? 'var(--color-destructive, #DC2626)'
|
||||
: 'var(--color-text-secondary)',
|
||||
lineHeight: 1.4,
|
||||
marginBottom: 'var(--space-6, 24px)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 'var(--space-2, 8px)',
|
||||
}}
|
||||
>
|
||||
{isPending ? (
|
||||
<>
|
||||
<Loader2
|
||||
size={16}
|
||||
aria-hidden="true"
|
||||
style={{ animation: 'spin 1s linear infinite', flexShrink: 0 }}
|
||||
/>
|
||||
{VALIDATING_TEXT}
|
||||
</>
|
||||
) : validationError ? (
|
||||
validationError
|
||||
) : (
|
||||
<>
|
||||
{HELPER_TEXT}{' '}
|
||||
<a
|
||||
href={HELPER_LINK_HREF}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{
|
||||
color: 'var(--color-member-0, #4A90D9)',
|
||||
textDecoration: 'underline',
|
||||
}}
|
||||
>
|
||||
{HELPER_LINK_TEXT}
|
||||
</a>
|
||||
{HELPER_LINK_SUFFIX}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions row — right-aligned, Cancel + Save */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
gap: 'var(--space-3, 12px)',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
disabled={isPending}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: isPending ? 'default' : 'pointer',
|
||||
fontSize: 'var(--text-label-size, 13px)',
|
||||
fontWeight: 600,
|
||||
color: 'var(--color-text-secondary)',
|
||||
minHeight: '44px',
|
||||
minWidth: '44px',
|
||||
padding: '0 var(--space-4, 16px)',
|
||||
fontFamily: 'var(--font-family-base)',
|
||||
borderRadius: 'var(--space-1, 4px)',
|
||||
opacity: isPending ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
{CANCEL_LABEL}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={saveDisabled}
|
||||
style={{
|
||||
background: saveDisabled
|
||||
? 'var(--color-border, #E2E4E9)'
|
||||
: 'var(--color-member-0, #4A90D9)',
|
||||
color: '#ffffff',
|
||||
border: 'none',
|
||||
cursor: saveDisabled ? 'default' : 'pointer',
|
||||
fontSize: 'var(--text-label-size, 13px)',
|
||||
fontWeight: 600,
|
||||
minHeight: '44px',
|
||||
minWidth: '44px',
|
||||
padding: '0 var(--space-4, 16px)',
|
||||
borderRadius: 'var(--space-1, 4px)',
|
||||
fontFamily: 'var(--font-family-base)',
|
||||
transition: 'background 0.15s ease',
|
||||
}}
|
||||
>
|
||||
{SAVE_LABEL}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* SetupBanner — self-service credential onboarding banner (D-07).
|
||||
*
|
||||
* Shown ONLY when meQuery.data?.user.needsProviderSetup === true.
|
||||
* NO dismiss/X button — the ONLY way this clears is a successful credential save
|
||||
* which invalidates ['me'] → /api/me refetches → needsProviderSetup becomes false
|
||||
* → this component unmounts on the next render.
|
||||
*
|
||||
* Success-only dismissal contract:
|
||||
* CredentialSheet.onSuccess → invalidateQueries(['me']) → meQuery.data.user.needsProviderSetup=false
|
||||
* → SetupBanner returns null → banner disappears.
|
||||
* There is NO other code path that hides this banner.
|
||||
*
|
||||
* UI-SPEC §Surface 4:
|
||||
* - role="status" + aria-live="polite" (screen readers announce on load)
|
||||
* - KeyRound icon (size 20, var(--color-member-0))
|
||||
* - Heading "Set up your calendar" (15px/600/primary)
|
||||
* - Body copy (13px/400/secondary)
|
||||
* - "Set up now" CTA (accent-filled, minHeight 44px) → opens CredentialSheet in self-service mode
|
||||
* - Card style: var(--color-surface-dim), 1px border, var(--space-2) radius, var(--space-4) padding
|
||||
*
|
||||
* Security: T-05-24 — all copy is plain-text JSX children, no dangerouslySetInnerHTML.
|
||||
*/
|
||||
|
||||
import { useState, useRef } from 'react';
|
||||
import { KeyRound } from 'lucide-react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { fetchMe } from '../api/client.js';
|
||||
import { CredentialSheet } from './CredentialSheet.js';
|
||||
|
||||
export function SetupBanner() {
|
||||
const [sheetOpen, setSheetOpen] = useState(false);
|
||||
// Use HTMLButtonElement for the ref (assignable to the CredentialSheet's HTMLElement trigger)
|
||||
const ctaRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const meQuery = useQuery({
|
||||
queryKey: ['me'],
|
||||
queryFn: fetchMe,
|
||||
retry: false,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
// Only show when needsProviderSetup is explicitly true
|
||||
// (undefined / false = no banner)
|
||||
if (meQuery.data?.user.needsProviderSetup !== true) return null;
|
||||
|
||||
const memberName = meQuery.data.user.displayName ?? 'Member';
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
style={{
|
||||
background: 'var(--color-surface-dim, #F7F7F8)',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 'var(--space-2, 8px)',
|
||||
padding: 'var(--space-4, 16px)',
|
||||
margin: 'var(--space-4, 16px)',
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
gap: 'var(--space-3, 12px)',
|
||||
fontFamily: 'var(--font-family-base)',
|
||||
}}
|
||||
>
|
||||
<KeyRound
|
||||
size={20}
|
||||
aria-hidden="true"
|
||||
style={{ color: 'var(--color-member-0, #4A90D9)', flexShrink: 0, marginTop: '2px' }}
|
||||
/>
|
||||
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 'var(--text-body-size, 15px)',
|
||||
fontWeight: 600,
|
||||
color: 'var(--color-text-primary)',
|
||||
lineHeight: 1.4,
|
||||
marginBottom: 'var(--space-1, 4px)',
|
||||
}}
|
||||
>
|
||||
Set up your calendar
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 'var(--text-label-size, 13px)',
|
||||
fontWeight: 400,
|
||||
color: 'var(--color-text-secondary)',
|
||||
lineHeight: 1.4,
|
||||
marginBottom: 'var(--space-3, 12px)',
|
||||
}}
|
||||
>
|
||||
To sync your Fastmail calendar, you need to add an app password. This takes about a
|
||||
minute.
|
||||
</div>
|
||||
|
||||
{/* CTA — no X/dismiss: the ONLY exit is a successful credential save */}
|
||||
<button
|
||||
ref={ctaRef}
|
||||
type="button"
|
||||
onClick={() => setSheetOpen(true)}
|
||||
style={{
|
||||
background: 'var(--color-member-0, #4A90D9)',
|
||||
color: '#ffffff',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
fontSize: 'var(--text-label-size, 13px)',
|
||||
fontWeight: 600,
|
||||
minHeight: '44px',
|
||||
minWidth: '44px',
|
||||
padding: '0 var(--space-4, 16px)',
|
||||
borderRadius: 'var(--space-1, 4px)',
|
||||
fontFamily: 'var(--font-family-base)',
|
||||
}}
|
||||
>
|
||||
Set up now
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* CredentialSheet in self-service mode.
|
||||
On success: invalidates ['me'] → needsProviderSetup=false → this component unmounts. */}
|
||||
<CredentialSheet
|
||||
isOpen={sheetOpen}
|
||||
onClose={() => setSheetOpen(false)}
|
||||
mode="self-service"
|
||||
memberName={memberName}
|
||||
triggerRef={ctaRef}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,542 @@
|
||||
/**
|
||||
* AdminPage — /admin route (D-02: dedicated gated route, not SettingsSheet extension).
|
||||
*
|
||||
* Non-admin users are redirected to /calendar at the App.tsx route level (UX, D-03).
|
||||
* The server enforces 403 on every /api/admin/* request (Plan 03, requireAdmin).
|
||||
*
|
||||
* UI-SPEC §Surface 1 (/admin route):
|
||||
* - "Admin Settings" heading (18px/600)
|
||||
* - Centered content column, maxWidth 640px on desktop
|
||||
* - var(--space-12) top/bottom padding, var(--space-6) horizontal padding
|
||||
*
|
||||
* UI-SPEC §Surface 2 (MEMBERS section):
|
||||
* - 32px avatar swatch (var(--color-member-N)) + member name + credential status badge
|
||||
* - "Rotate" or "Add credential" action button per hasCredential
|
||||
* - Opens CredentialSheet in admin-rotate or admin-add mode
|
||||
*
|
||||
* UI-SPEC §Surface 5 (SHARED CALENDAR section):
|
||||
* - Radio group, one row per synced calendar
|
||||
* - "Currently shared" label on active selection
|
||||
* - Two-tap Save (disabled until selection differs from saved)
|
||||
* - Empty state when no calendars synced
|
||||
*
|
||||
* Security: client isAdmin gate is UX only. Server 403 is the real boundary (D-03).
|
||||
*/
|
||||
|
||||
import { useState, useRef } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { CheckCircle, AlertCircle } from 'lucide-react';
|
||||
import {
|
||||
fetchAdminMembers,
|
||||
fetchAdminCalendars,
|
||||
setSharedCalendar,
|
||||
type AdminMember,
|
||||
type AdminCalendar,
|
||||
} from '../api/client.js';
|
||||
import { CredentialSheet, type CredentialSheetMode } from '../components/CredentialSheet.js';
|
||||
|
||||
// ── Styles ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const sectionLabelStyle: React.CSSProperties = {
|
||||
fontSize: 'var(--text-label-size, 13px)',
|
||||
fontWeight: 600,
|
||||
color: 'var(--color-text-muted)',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.06em',
|
||||
marginBottom: 'var(--space-2, 8px)',
|
||||
};
|
||||
|
||||
// ── AdminPage ──────────────────────────────────────────────────────────────
|
||||
|
||||
export function AdminPage() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Credential sheet state
|
||||
const [sheetOpen, setSheetOpen] = useState(false);
|
||||
const [sheetMode, setSheetMode] = useState<CredentialSheetMode>('admin-add');
|
||||
const [sheetMember, setSheetMember] = useState<AdminMember | null>(null);
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
// Shared calendar picker state
|
||||
const [selectedCalendarId, setSelectedCalendarId] = useState<number | null>(null);
|
||||
|
||||
// Members query
|
||||
const membersQuery = useQuery({
|
||||
queryKey: ['admin', 'members'],
|
||||
queryFn: fetchAdminMembers,
|
||||
retry: false,
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
|
||||
// Calendars query
|
||||
const calendarsQuery = useQuery({
|
||||
queryKey: ['admin', 'calendars'],
|
||||
queryFn: fetchAdminCalendars,
|
||||
retry: false,
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
|
||||
// Derive current saved shared calendar id from the data
|
||||
const currentSharedId = calendarsQuery.data?.calendars.find((c) => c.isShared)?.id ?? null;
|
||||
|
||||
// Effective selected = user pick OR fallback to current saved
|
||||
const effectiveSelected = selectedCalendarId ?? currentSharedId;
|
||||
|
||||
// Save shared calendar mutation
|
||||
const sharedCalMutation = useMutation({
|
||||
mutationFn: (calId: number) => setSharedCalendar(calId),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['admin', 'calendars'] });
|
||||
// Also invalidate events so the shared lane updates
|
||||
void queryClient.invalidateQueries({ queryKey: ['events'] });
|
||||
setSelectedCalendarId(null); // reset picker
|
||||
},
|
||||
});
|
||||
|
||||
// Open credential sheet for a member
|
||||
function openSheet(member: AdminMember, buttonRef: React.RefObject<HTMLButtonElement | null>) {
|
||||
// Capture the button so focus can return on close
|
||||
(triggerRef as React.MutableRefObject<HTMLElement | null>).current = buttonRef.current;
|
||||
setSheetMember(member);
|
||||
setSheetMode(member.hasCredential ? 'admin-rotate' : 'admin-add');
|
||||
setSheetOpen(true);
|
||||
}
|
||||
|
||||
const saveDisabled =
|
||||
sharedCalMutation.isPending ||
|
||||
effectiveSelected === null ||
|
||||
effectiveSelected === currentSharedId;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
height: '100%',
|
||||
overflowY: 'auto',
|
||||
fontFamily: 'var(--font-family-base)',
|
||||
// Bottom padding to clear the 56px fixed tab bar on phone
|
||||
paddingBottom: 'calc(56px + env(safe-area-inset-bottom, 0px))',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
maxWidth: '640px',
|
||||
margin: '0 auto',
|
||||
padding: 'var(--space-12, 48px) var(--space-6, 24px)',
|
||||
}}
|
||||
>
|
||||
{/* Page heading */}
|
||||
<h1
|
||||
style={{
|
||||
margin: '0 0 var(--space-8, 32px) 0',
|
||||
fontSize: 'var(--text-heading-size, 18px)',
|
||||
fontWeight: 600,
|
||||
lineHeight: 'var(--text-heading-line-height, 1.25)',
|
||||
color: 'var(--color-text-primary)',
|
||||
}}
|
||||
>
|
||||
Admin Settings
|
||||
</h1>
|
||||
|
||||
{/* ── MEMBERS section ─────────────────────────────────────────────── */}
|
||||
<section aria-label="Members" style={{ marginBottom: 'var(--space-8, 32px)' }}>
|
||||
<div style={sectionLabelStyle}>Members</div>
|
||||
|
||||
{membersQuery.isLoading && (
|
||||
<div
|
||||
style={{
|
||||
padding: 'var(--space-4, 16px) 0',
|
||||
color: 'var(--color-text-muted)',
|
||||
fontSize: 'var(--text-body-size, 15px)',
|
||||
}}
|
||||
>
|
||||
Loading members…
|
||||
</div>
|
||||
)}
|
||||
|
||||
{membersQuery.isError && (
|
||||
<div
|
||||
style={{
|
||||
color: 'var(--color-destructive)',
|
||||
fontSize: 'var(--text-body-size, 15px)',
|
||||
padding: 'var(--space-4, 16px) 0',
|
||||
}}
|
||||
>
|
||||
Could not load members.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{membersQuery.data && (
|
||||
<div>
|
||||
{membersQuery.data.members.map((member, idx) => (
|
||||
<MemberRow
|
||||
key={member.id}
|
||||
member={member}
|
||||
colorIndex={idx}
|
||||
onAction={(buttonRef) => openSheet(member, buttonRef)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* ── SHARED CALENDAR section ──────────────────────────────────────── */}
|
||||
<section aria-label="Shared Calendar">
|
||||
<div style={sectionLabelStyle}>Shared Calendar</div>
|
||||
|
||||
<p
|
||||
style={{
|
||||
margin: '0 0 var(--space-4, 16px) 0',
|
||||
fontSize: 'var(--text-body-size, 15px)',
|
||||
fontWeight: 400,
|
||||
color: 'var(--color-text-secondary)',
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
>
|
||||
The shared family calendar is visible to all members in the same color lane.
|
||||
</p>
|
||||
|
||||
{calendarsQuery.isLoading && (
|
||||
<div
|
||||
style={{
|
||||
color: 'var(--color-text-muted)',
|
||||
fontSize: 'var(--text-body-size, 15px)',
|
||||
padding: 'var(--space-2, 8px) 0',
|
||||
}}
|
||||
>
|
||||
Loading calendars…
|
||||
</div>
|
||||
)}
|
||||
|
||||
{calendarsQuery.isError && (
|
||||
<div
|
||||
style={{
|
||||
color: 'var(--color-destructive)',
|
||||
fontSize: 'var(--text-body-size, 15px)',
|
||||
padding: 'var(--space-2, 8px) 0',
|
||||
}}
|
||||
>
|
||||
Could not load calendars.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{calendarsQuery.data && calendarsQuery.data.calendars.length === 0 && (
|
||||
<EmptyCalendarsState />
|
||||
)}
|
||||
|
||||
{calendarsQuery.data && calendarsQuery.data.calendars.length > 0 && (
|
||||
<>
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label="Select shared calendar"
|
||||
style={{ marginBottom: 'var(--space-4, 16px)' }}
|
||||
>
|
||||
{calendarsQuery.data.calendars.map((cal) => (
|
||||
<CalendarRadioRow
|
||||
key={cal.id}
|
||||
calendar={cal}
|
||||
isSelected={effectiveSelected === cal.id}
|
||||
onSelect={() => setSelectedCalendarId(cal.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Two-tap Save button */}
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<button
|
||||
type="button"
|
||||
disabled={saveDisabled}
|
||||
onClick={() => {
|
||||
if (effectiveSelected !== null) {
|
||||
sharedCalMutation.mutate(effectiveSelected);
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
background: saveDisabled
|
||||
? 'var(--color-border, #E2E4E9)'
|
||||
: 'var(--color-member-0, #4A90D9)',
|
||||
color: '#ffffff',
|
||||
border: 'none',
|
||||
cursor: saveDisabled ? 'default' : 'pointer',
|
||||
fontSize: 'var(--text-label-size, 13px)',
|
||||
fontWeight: 600,
|
||||
minHeight: '44px',
|
||||
minWidth: '44px',
|
||||
padding: '0 var(--space-6, 24px)',
|
||||
borderRadius: 'var(--space-1, 4px)',
|
||||
fontFamily: 'var(--font-family-base)',
|
||||
transition: 'background 0.15s ease',
|
||||
}}
|
||||
>
|
||||
{sharedCalMutation.isPending ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{sharedCalMutation.isError && (
|
||||
<div
|
||||
style={{
|
||||
color: 'var(--color-destructive)',
|
||||
fontSize: 'var(--text-label-size, 13px)',
|
||||
marginTop: 'var(--space-2, 8px)',
|
||||
textAlign: 'right',
|
||||
}}
|
||||
>
|
||||
Something went wrong. Please try again.
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* Credential sheet — admin-rotate or admin-add */}
|
||||
{sheetMember && (
|
||||
<CredentialSheet
|
||||
isOpen={sheetOpen}
|
||||
onClose={() => setSheetOpen(false)}
|
||||
mode={sheetMode}
|
||||
memberName={sheetMember.displayName}
|
||||
memberId={sheetMember.id}
|
||||
triggerRef={triggerRef}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── MemberRow ──────────────────────────────────────────────────────────────
|
||||
|
||||
interface MemberRowProps {
|
||||
member: AdminMember;
|
||||
colorIndex: number;
|
||||
onAction: (buttonRef: React.RefObject<HTMLButtonElement | null>) => void;
|
||||
}
|
||||
|
||||
function MemberRow({ member, colorIndex, onAction }: MemberRowProps) {
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 'var(--space-3, 12px)',
|
||||
minHeight: '44px',
|
||||
padding: 'var(--space-2, 8px) 0',
|
||||
borderBottom: '1px solid var(--color-border-subtle, var(--color-border))',
|
||||
}}
|
||||
>
|
||||
{/* Avatar swatch */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
borderRadius: '50%',
|
||||
background: `var(--color-member-${colorIndex}, var(--color-member-0))`,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Name + status */}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 'var(--text-body-size, 15px)',
|
||||
fontWeight: 400,
|
||||
color: 'var(--color-text-primary)',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{member.displayName ?? 'Member'}
|
||||
</div>
|
||||
|
||||
{/* Credential status badge */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 'var(--space-1, 4px)',
|
||||
marginTop: '2px',
|
||||
}}
|
||||
>
|
||||
{member.hasCredential ? (
|
||||
<>
|
||||
<CheckCircle
|
||||
size={16}
|
||||
aria-hidden="true"
|
||||
style={{ color: 'var(--color-text-secondary)', flexShrink: 0 }}
|
||||
/>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 'var(--text-label-size, 13px)',
|
||||
color: 'var(--color-text-secondary)',
|
||||
}}
|
||||
>
|
||||
Credential set
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<AlertCircle
|
||||
size={16}
|
||||
aria-hidden="true"
|
||||
style={{ color: 'var(--color-text-muted)', flexShrink: 0 }}
|
||||
/>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 'var(--text-label-size, 13px)',
|
||||
color: 'var(--color-text-muted)',
|
||||
}}
|
||||
>
|
||||
No credential
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action button */}
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
onClick={() => onAction(buttonRef)}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 'var(--space-1, 4px)',
|
||||
cursor: 'pointer',
|
||||
fontSize: 'var(--text-label-size, 13px)',
|
||||
fontWeight: 600,
|
||||
color: 'var(--color-text-primary)',
|
||||
minHeight: '44px',
|
||||
minWidth: '44px',
|
||||
padding: '0 var(--space-3, 12px)',
|
||||
fontFamily: 'var(--font-family-base)',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{member.hasCredential ? 'Rotate' : 'Add credential'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── CalendarRadioRow ────────────────────────────────────────────────────────
|
||||
|
||||
interface CalendarRadioRowProps {
|
||||
calendar: AdminCalendar;
|
||||
isSelected: boolean;
|
||||
onSelect: () => void;
|
||||
}
|
||||
|
||||
function CalendarRadioRow({ calendar, isSelected, onSelect }: CalendarRadioRowProps) {
|
||||
return (
|
||||
<div
|
||||
role="radio"
|
||||
aria-checked={isSelected}
|
||||
tabIndex={0}
|
||||
onClick={onSelect}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onSelect();
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 'var(--space-3, 12px)',
|
||||
minHeight: '44px',
|
||||
padding: 'var(--space-2, 8px) 0',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{/* Radio indicator: 20px circle */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
borderRadius: '50%',
|
||||
flexShrink: 0,
|
||||
border: isSelected ? 'none' : '2px solid var(--color-border)',
|
||||
background: isSelected ? 'var(--color-member-0, #4A90D9)' : 'transparent',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
{isSelected && (
|
||||
<div
|
||||
style={{
|
||||
width: '8px',
|
||||
height: '8px',
|
||||
borderRadius: '50%',
|
||||
background: '#ffffff',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Calendar name */}
|
||||
<span
|
||||
style={{
|
||||
fontSize: 'var(--text-body-size, 15px)',
|
||||
fontWeight: 400,
|
||||
color: 'var(--color-text-primary)',
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
{calendar.displayName}
|
||||
</span>
|
||||
|
||||
{/* Currently shared label */}
|
||||
{calendar.isShared && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: 'var(--text-label-size, 13px)',
|
||||
fontWeight: 400,
|
||||
color: 'var(--color-member-0, #4A90D9)',
|
||||
}}
|
||||
>
|
||||
Currently shared
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── EmptyCalendarsState ─────────────────────────────────────────────────────
|
||||
|
||||
function EmptyCalendarsState() {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
padding: 'var(--space-8, 32px) 0',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 'var(--text-body-size, 15px)',
|
||||
fontWeight: 600,
|
||||
color: 'var(--color-text-primary)',
|
||||
marginBottom: 'var(--space-2, 8px)',
|
||||
}}
|
||||
>
|
||||
No calendars synced yet
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 'var(--text-label-size, 13px)',
|
||||
color: 'var(--color-text-secondary)',
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
>
|
||||
Calendars sync automatically. Check back after the first sync completes.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user