Compare commits
10
Commits
d2f6d5d77b
...
2067b8c91c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2067b8c91c | ||
|
|
94c62e213e | ||
|
|
2f347cbd98 | ||
|
|
ebde3e1d08 | ||
|
|
3693da6d0b | ||
|
|
79fe3e0e04 | ||
|
|
7808426a2f | ||
|
|
2c2c71e7cc | ||
|
|
bfe1eff5a3 | ||
|
|
0f41993a95 |
@@ -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] 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] 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] 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
|
### 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_
|
||||||
|
|||||||
@@ -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 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 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)
|
- [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 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
|
- [ ] **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)
|
- [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)
|
||||||
@@ -181,11 +181,11 @@ Plans:
|
|||||||
|
|
||||||
**Wave 3** *(blocked on Wave 2 completion)*
|
**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)*
|
**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
|
**UI hint**: yes
|
||||||
|
|
||||||
@@ -369,7 +369,7 @@ Plans:
|
|||||||
| 7. Mobile Test Harness | v1.1 | 4/4 | Complete | 2026-06-11 |
|
| 7. Mobile Test Harness | v1.1 | 4/4 | Complete | 2026-06-11 |
|
||||||
| 8. Gitea CI | 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 |
|
| 9. Faster Write-Back | v1.1 | 2/2 | Complete | 2026-06-12 |
|
||||||
| 10. Admin Role & Settings | v1.1 | 2/4 | In Progress| |
|
| 10. Admin Role & Settings | v1.1 | 4/4 | Complete | 2026-06-13 |
|
||||||
| 11. Per-Event Reminders | v1.1 | 0/? | Not started | - |
|
| 11. Per-Event Reminders | v1.1 | 0/? | Not started | - |
|
||||||
| 12. Initial Setup Wizard | 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 |
|
| 13. Real Lint Gate (ESLint) | v1.1 | 3/3 | Complete | 2026-06-12 |
|
||||||
@@ -383,7 +383,7 @@ Plans:
|
|||||||
|
|
||||||
**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.
|
**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
|
**Requirements:** TBD
|
||||||
**Plans:** 2/4 plans executed
|
**Plans:** 4/4 plans complete
|
||||||
|
|
||||||
Plans:
|
Plans:
|
||||||
|
|
||||||
|
|||||||
+18
-12
@@ -2,16 +2,16 @@
|
|||||||
gsd_state_version: 1.0
|
gsd_state_version: 1.0
|
||||||
milestone: v1.1
|
milestone: v1.1
|
||||||
milestone_name: Operability & Polish
|
milestone_name: Operability & Polish
|
||||||
status: executing
|
status: verifying
|
||||||
stopped_at: "Completed Phase 10 Plan 02 (admin role primitives: requireAdmin, upsertUser is_admin, /api/me isAdmin+needsProviderSetup)"
|
stopped_at: "Completed Phase 10 Plan 02 (admin role primitives: requireAdmin, upsertUser is_admin, /api/me isAdmin+needsProviderSetup)"
|
||||||
last_updated: "2026-06-13T18:39:38.129Z"
|
last_updated: "2026-06-13T19:45:55.920Z"
|
||||||
last_activity: 2026-06-13 -- Phase 10 execution started
|
last_activity: 2026-06-13
|
||||||
progress:
|
progress:
|
||||||
total_phases: 20
|
total_phases: 20
|
||||||
completed_phases: 7
|
completed_phases: 8
|
||||||
total_plans: 27
|
total_plans: 27
|
||||||
completed_plans: 25
|
completed_plans: 27
|
||||||
percent: 35
|
percent: 40
|
||||||
---
|
---
|
||||||
|
|
||||||
# Project State
|
# Project State
|
||||||
@@ -25,10 +25,10 @@ See: .planning/PROJECT.md (updated 2026-06-10)
|
|||||||
|
|
||||||
## Current Position
|
## Current Position
|
||||||
|
|
||||||
Phase: 10 (admin-role-settings) — EXECUTING
|
Phase: 13
|
||||||
Plan: 3 of 4
|
Plan: Not started
|
||||||
Status: Ready to execute
|
Status: Phase complete — ready for verification
|
||||||
Last activity: 2026-06-13 -- Phase 10 execution started
|
Last activity: 2026-06-13
|
||||||
|
|
||||||
### ✅ Resolved Checkpoint — Phase 15 Plan 15-03 Task 2 (human-action)
|
### ✅ 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:**
|
**Velocity:**
|
||||||
|
|
||||||
- Total plans completed: 39
|
- Total plans completed: 43
|
||||||
- Average duration: -
|
- Average duration: -
|
||||||
- Total execution time: 0 hours
|
- 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 | - | - |
|
| 15 | 3 | - | - |
|
||||||
| 09 | 2 | - | - |
|
| 09 | 2 | - | - |
|
||||||
| 16 | 6 | - | - |
|
| 16 | 6 | - | - |
|
||||||
|
| 10 | 4 | - | - |
|
||||||
|
|
||||||
**Recent Trend:**
|
**Recent Trend:**
|
||||||
|
|
||||||
@@ -102,6 +103,8 @@ _Updated after each plan completion_
|
|||||||
| Phase 16 P05 | 7 | 2 tasks | 1 files |
|
| Phase 16 P05 | 7 | 2 tasks | 1 files |
|
||||||
| Phase 10-admin-role-settings P01 | 265 | - tasks | - 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 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
|
## Accumulated Context
|
||||||
|
|
||||||
@@ -173,6 +176,9 @@ Recent decisions affecting current work:
|
|||||||
- [Phase ?]: D-14-gate-security: security wired into gate with individual needs.security.result==success check (not success-or-skipped, Gitea #31007)
|
- [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-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 ?]: 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
|
### Roadmap Evolution
|
||||||
|
|
||||||
@@ -239,7 +245,7 @@ Recent decisions affecting current work:
|
|||||||
|
|
||||||
## Session Continuity
|
## Session Continuity
|
||||||
|
|
||||||
Last session: 2026-06-13T18:39:38.119Z
|
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)
|
Stopped at: Completed Phase 10 Plan 02 (admin role primitives: requireAdmin, upsertUser is_admin, /api/me isAdmin+needsProviderSetup)
|
||||||
Resume file: None
|
Resume file: None
|
||||||
|
|
||||||
|
|||||||
@@ -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)_
|
||||||
@@ -153,11 +153,28 @@ adminRouter.put('/calendars/:id/shared', async (c) => {
|
|||||||
return c.json({ error: 'Invalid calendar id' }, 400);
|
return c.json({ error: 'Invalid calendar id' }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 1: Clear is_shared on any currently-shared calendar
|
// CR-01: verify the target exists and flip the shared lane atomically.
|
||||||
await db.update(calendars).set({ isShared: false }).where(eq(calendars.isShared, true));
|
// 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 2: Set is_shared on the target calendar
|
// Step 1: Clear is_shared on any currently-shared calendar
|
||||||
await db.update(calendars).set({ isShared: true }).where(eq(calendars.id, targetId));
|
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);
|
return c.json({ ok: true }, 200);
|
||||||
});
|
});
|
||||||
|
|||||||
+31
-30
@@ -167,35 +167,36 @@ const meNoEchoHook = (result: { success: boolean }, c: Context) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
meRouter.post(
|
meRouter.post('/credential', zValidator('json', meCredentialSchema, meNoEchoHook), async (c) => {
|
||||||
'/credential',
|
// Pitfall 6: ALWAYS resolve currentUserId from the session — never from the body.
|
||||||
zValidator('json', meCredentialSchema, meNoEchoHook),
|
const currentUserId = await resolveUserId(c);
|
||||||
async (c) => {
|
if (!currentUserId) {
|
||||||
// Pitfall 6: ALWAYS resolve currentUserId from the session — never from the body.
|
return c.json({ error: 'Unauthorized' }, 401);
|
||||||
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);
|
||||||
|
}
|
||||||
|
|
||||||
const { fastmailEmail, appPassword, providerType } = c.req.valid('json');
|
return c.json({ ok: true }, 200);
|
||||||
// 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);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|||||||
@@ -314,7 +314,15 @@ describe('upsertUser', () => {
|
|||||||
}
|
}
|
||||||
// Re-fetch after insert
|
// Re-fetch after insert
|
||||||
return makeSelectChain([
|
return makeSelectChain([
|
||||||
{ id: 10, oidcIss: iss, oidcSub: sub, displayName: null, color: COLOR_PALETTE[0], isAdmin: true, createdAt: new Date() },
|
{
|
||||||
|
id: 10,
|
||||||
|
oidcIss: iss,
|
||||||
|
oidcSub: sub,
|
||||||
|
displayName: null,
|
||||||
|
color: COLOR_PALETTE[0],
|
||||||
|
isAdmin: true,
|
||||||
|
createdAt: new Date(),
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
mockDb.insert.mockReturnValue(makeInsertChain([{ id: 10 }]));
|
mockDb.insert.mockReturnValue(makeInsertChain([{ id: 10 }]));
|
||||||
@@ -344,7 +352,15 @@ describe('upsertUser', () => {
|
|||||||
return makeSelectChain([{ count: 1 }]);
|
return makeSelectChain([{ count: 1 }]);
|
||||||
}
|
}
|
||||||
return makeSelectChain([
|
return makeSelectChain([
|
||||||
{ id: 11, oidcIss: iss, oidcSub: sub, displayName: null, color: COLOR_PALETTE[1], isAdmin: false, createdAt: new Date() },
|
{
|
||||||
|
id: 11,
|
||||||
|
oidcIss: iss,
|
||||||
|
oidcSub: sub,
|
||||||
|
displayName: null,
|
||||||
|
color: COLOR_PALETTE[1],
|
||||||
|
isAdmin: false,
|
||||||
|
createdAt: new Date(),
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
mockDb.insert.mockReturnValue(makeInsertChain([{ id: 11 }]));
|
mockDb.insert.mockReturnValue(makeInsertChain([{ id: 11 }]));
|
||||||
|
|||||||
@@ -24,7 +24,13 @@ vi.mock('../../src/db/client.js', () => ({
|
|||||||
|
|
||||||
// Bring in the ContextVariableMap augmentation (sets up c.get('user') typing)
|
// Bring in the ContextVariableMap augmentation (sets up c.get('user') typing)
|
||||||
vi.mock('../../src/auth/devBypass.js', () => ({
|
vi.mock('../../src/auth/devBypass.js', () => ({
|
||||||
DEV_USER: { id: 1, oidcIss: 'dev', oidcSub: 'dev-user', displayName: 'Dev User', color: '#4A90D9' },
|
DEV_USER: {
|
||||||
|
id: 1,
|
||||||
|
oidcIss: 'dev',
|
||||||
|
oidcSub: 'dev-user',
|
||||||
|
displayName: 'Dev User',
|
||||||
|
color: '#4A90D9',
|
||||||
|
},
|
||||||
devAuthBypass: () => async (_c: unknown, next: () => Promise<void>) => next(),
|
devAuthBypass: () => async (_c: unknown, next: () => Promise<void>) => next(),
|
||||||
COLOR_PALETTE: ['#4A90D9'],
|
COLOR_PALETTE: ['#4A90D9'],
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -456,6 +456,27 @@ describe('PUT /api/admin/calendars/:id/shared', () => {
|
|||||||
expect(sharedIds).toContain(calB);
|
expect(sharedIds).toContain(calB);
|
||||||
expect(sharedIds).not.toContain(calA);
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ===========================================================================
|
// ===========================================================================
|
||||||
|
|||||||
@@ -167,13 +167,16 @@ describe('GET /api/me — isAdmin + needsProviderSetup (Plan 10-02, D-03)', () =
|
|||||||
let callCount = 0;
|
let callCount = 0;
|
||||||
vi.mocked(db.select).mockImplementation(() => {
|
vi.mocked(db.select).mockImplementation(() => {
|
||||||
callCount++;
|
callCount++;
|
||||||
const limitFn = callCount === 1
|
const limitFn =
|
||||||
? vi.fn().mockResolvedValue([{ isAdmin: true }]) // users.isAdmin lookup
|
callCount === 1
|
||||||
: vi.fn().mockResolvedValue([]); // memberCredentials lookup (none)
|
? vi.fn().mockResolvedValue([{ isAdmin: true }]) // users.isAdmin lookup
|
||||||
|
: vi.fn().mockResolvedValue([]); // memberCredentials lookup (none)
|
||||||
return {
|
return {
|
||||||
from: vi.fn().mockReturnValue({
|
from: vi.fn().mockReturnValue({
|
||||||
where: vi.fn().mockReturnValue({ limit: limitFn }),
|
where: vi.fn().mockReturnValue({ limit: limitFn }),
|
||||||
innerJoin: vi.fn().mockReturnValue({ innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }) }),
|
innerJoin: vi.fn().mockReturnValue({
|
||||||
|
innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }),
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
} as any;
|
} as any;
|
||||||
@@ -183,7 +186,9 @@ describe('GET /api/me — isAdmin + needsProviderSetup (Plan 10-02, D-03)', () =
|
|||||||
const res = await app.request('/api/me');
|
const res = await app.request('/api/me');
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
|
|
||||||
const body = (await res.json()) as { user: { id: number; isAdmin: boolean; needsProviderSetup: boolean } };
|
const body = (await res.json()) as {
|
||||||
|
user: { id: number; isAdmin: boolean; needsProviderSetup: boolean };
|
||||||
|
};
|
||||||
expect(body.user).toHaveProperty('isAdmin');
|
expect(body.user).toHaveProperty('isAdmin');
|
||||||
expect(body.user.isAdmin).toBe(true); // DB returns true, not hardcoded
|
expect(body.user.isAdmin).toBe(true); // DB returns true, not hardcoded
|
||||||
});
|
});
|
||||||
@@ -194,13 +199,16 @@ describe('GET /api/me — isAdmin + needsProviderSetup (Plan 10-02, D-03)', () =
|
|||||||
let callCount = 0;
|
let callCount = 0;
|
||||||
vi.mocked(db.select).mockImplementation(() => {
|
vi.mocked(db.select).mockImplementation(() => {
|
||||||
callCount++;
|
callCount++;
|
||||||
const limitFn = callCount === 1
|
const limitFn =
|
||||||
? vi.fn().mockResolvedValue([{ isAdmin: false }]) // users.isAdmin lookup
|
callCount === 1
|
||||||
: vi.fn().mockResolvedValue([]); // no member_credentials row
|
? vi.fn().mockResolvedValue([{ isAdmin: false }]) // users.isAdmin lookup
|
||||||
|
: vi.fn().mockResolvedValue([]); // no member_credentials row
|
||||||
return {
|
return {
|
||||||
from: vi.fn().mockReturnValue({
|
from: vi.fn().mockReturnValue({
|
||||||
where: vi.fn().mockReturnValue({ limit: limitFn }),
|
where: vi.fn().mockReturnValue({ limit: limitFn }),
|
||||||
innerJoin: vi.fn().mockReturnValue({ innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }) }),
|
innerJoin: vi.fn().mockReturnValue({
|
||||||
|
innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }),
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
} as any;
|
} as any;
|
||||||
@@ -221,13 +229,16 @@ describe('GET /api/me — isAdmin + needsProviderSetup (Plan 10-02, D-03)', () =
|
|||||||
let callCount = 0;
|
let callCount = 0;
|
||||||
vi.mocked(db.select).mockImplementation(() => {
|
vi.mocked(db.select).mockImplementation(() => {
|
||||||
callCount++;
|
callCount++;
|
||||||
const limitFn = callCount === 1
|
const limitFn =
|
||||||
? vi.fn().mockResolvedValue([{ isAdmin: false }]) // users.isAdmin lookup
|
callCount === 1
|
||||||
: vi.fn().mockResolvedValue([{ id: 7 }]); // has member_credentials row
|
? vi.fn().mockResolvedValue([{ isAdmin: false }]) // users.isAdmin lookup
|
||||||
|
: vi.fn().mockResolvedValue([{ id: 7 }]); // has member_credentials row
|
||||||
return {
|
return {
|
||||||
from: vi.fn().mockReturnValue({
|
from: vi.fn().mockReturnValue({
|
||||||
where: vi.fn().mockReturnValue({ limit: limitFn }),
|
where: vi.fn().mockReturnValue({ limit: limitFn }),
|
||||||
innerJoin: vi.fn().mockReturnValue({ innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }) }),
|
innerJoin: vi.fn().mockReturnValue({
|
||||||
|
innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }),
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
} as any;
|
} as any;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
+28
-1
@@ -42,10 +42,12 @@ import { useQuery } from '@tanstack/react-query';
|
|||||||
import { CalendarShell } from './components/CalendarShell.js';
|
import { CalendarShell } from './components/CalendarShell.js';
|
||||||
import { ListsIndex } from './routes/ListsIndex.js';
|
import { ListsIndex } from './routes/ListsIndex.js';
|
||||||
import { ListDetail } from './routes/ListDetail.js';
|
import { ListDetail } from './routes/ListDetail.js';
|
||||||
|
import { AdminPage } from './routes/AdminPage.js';
|
||||||
import { BottomTabBar } from './components/BottomTabBar.js';
|
import { BottomTabBar } from './components/BottomTabBar.js';
|
||||||
import { AppNav } from './components/AppNav.js';
|
import { AppNav } from './components/AppNav.js';
|
||||||
import { PushPermissionPrompt } from './components/PushPermissionPrompt.js';
|
import { PushPermissionPrompt } from './components/PushPermissionPrompt.js';
|
||||||
import { PermissionDeniedBanner } from './components/PermissionDeniedBanner.js';
|
import { PermissionDeniedBanner } from './components/PermissionDeniedBanner.js';
|
||||||
|
import { SetupBanner } from './components/SetupBanner.js';
|
||||||
import { SettingsSheet } from './components/SettingsSheet.js';
|
import { SettingsSheet } from './components/SettingsSheet.js';
|
||||||
import { fetchMe } from './api/client.js';
|
import { fetchMe } from './api/client.js';
|
||||||
|
|
||||||
@@ -67,6 +69,11 @@ export default function App() {
|
|||||||
staleTime: 5 * 60 * 1000,
|
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
|
// Derive members for AppNav from the shared /api/me response
|
||||||
const members = useMemo(() => {
|
const members = useMemo(() => {
|
||||||
if (!meQuery.data?.user) return [];
|
if (!meQuery.data?.user) return [];
|
||||||
@@ -114,21 +121,41 @@ export default function App() {
|
|||||||
currentUserColor={meQuery.data?.user.color}
|
currentUserColor={meQuery.data?.user.color}
|
||||||
currentUserName={meQuery.data?.user.displayName ?? undefined}
|
currentUserName={meQuery.data?.user.displayName ?? undefined}
|
||||||
onOpenSettings={() => setSettingsOpen(true)}
|
onOpenSettings={() => setSettingsOpen(true)}
|
||||||
|
isAdmin={isAdmin}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Main content area — all routes render here */}
|
{/* Main content area — all routes render here */}
|
||||||
<div style={contentStyle}>
|
<div style={contentStyle}>
|
||||||
|
{/* SetupBanner: shown above content when needsProviderSetup=true (D-07).
|
||||||
|
Reads from the shared ['me'] query — no additional fetch. */}
|
||||||
|
<SetupBanner />
|
||||||
|
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Navigate to="/calendar" replace />} />
|
<Route path="/" element={<Navigate to="/calendar" replace />} />
|
||||||
<Route path="/calendar" element={<CalendarShell />} />
|
<Route path="/calendar" element={<CalendarShell />} />
|
||||||
<Route path="/lists" element={<ListsIndex />} />
|
<Route path="/lists" element={<ListsIndex />} />
|
||||||
<Route path="/lists/:listId" element={<ListDetail />} />
|
<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>
|
</Routes>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* BottomTabBar — phone-only (hidden ≥768px via CSS, FIX 4) */}
|
{/* 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
|
{/* Post-install permission prompt (D-08): renders only when isInstalled() is true
|
||||||
and Notification.permission === 'default' and not dismissed */}
|
and Notification.permission === 'default' and not dismissed */}
|
||||||
|
|||||||
@@ -63,6 +63,8 @@ export interface MeUser {
|
|||||||
id: number;
|
id: number;
|
||||||
displayName: string | null;
|
displayName: string | null;
|
||||||
color: string;
|
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 {
|
export interface MeResponse {
|
||||||
@@ -325,3 +327,137 @@ export async function fetchWritableCalendars(): Promise<WritableCalendar[]> {
|
|||||||
const body = (await res.json()) as { calendars: WritableCalendar[] };
|
const body = (await res.json()) as { calendars: WritableCalendar[] };
|
||||||
return body.calendars;
|
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 { NavLink } from 'react-router';
|
||||||
import { CalendarDays, List } from 'lucide-react';
|
import { CalendarDays, List, ShieldCheck } from 'lucide-react';
|
||||||
import { ColorLegend, type LegendMember } from './ColorLegend.js';
|
import { ColorLegend, type LegendMember } from './ColorLegend.js';
|
||||||
|
|
||||||
interface AppNavProps {
|
interface AppNavProps {
|
||||||
@@ -21,6 +21,8 @@ interface AppNavProps {
|
|||||||
currentUserName?: string;
|
currentUserName?: string;
|
||||||
/** Called when the user avatar is tapped — opens the Settings sheet. */
|
/** Called when the user avatar is tapped — opens the Settings sheet. */
|
||||||
onOpenSettings?: () => void;
|
onOpenSettings?: () => void;
|
||||||
|
/** When true, renders the Admin nav entry (ShieldCheck). UX gating only (D-03). */
|
||||||
|
isAdmin?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AppNav({
|
export function AppNav({
|
||||||
@@ -28,6 +30,7 @@ export function AppNav({
|
|||||||
currentUserColor,
|
currentUserColor,
|
||||||
currentUserName,
|
currentUserName,
|
||||||
onOpenSettings,
|
onOpenSettings,
|
||||||
|
isAdmin = false,
|
||||||
}: AppNavProps) {
|
}: AppNavProps) {
|
||||||
const isMobile = typeof window !== 'undefined' && window.matchMedia('(max-width: 767px)').matches;
|
const isMobile = typeof window !== 'undefined' && window.matchMedia('(max-width: 767px)').matches;
|
||||||
|
|
||||||
@@ -47,6 +50,7 @@ export function AppNav({
|
|||||||
currentUserColor={currentUserColor}
|
currentUserColor={currentUserColor}
|
||||||
currentUserName={currentUserName}
|
currentUserName={currentUserName}
|
||||||
onOpenSettings={onOpenSettings}
|
onOpenSettings={onOpenSettings}
|
||||||
|
isAdmin={isAdmin}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -129,11 +133,13 @@ function DesktopNav({
|
|||||||
currentUserColor,
|
currentUserColor,
|
||||||
currentUserName,
|
currentUserName,
|
||||||
onOpenSettings,
|
onOpenSettings,
|
||||||
|
isAdmin = false,
|
||||||
}: {
|
}: {
|
||||||
members: LegendMember[];
|
members: LegendMember[];
|
||||||
currentUserColor?: string;
|
currentUserColor?: string;
|
||||||
currentUserName?: string;
|
currentUserName?: string;
|
||||||
onOpenSettings?: () => void;
|
onOpenSettings?: () => void;
|
||||||
|
isAdmin?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const navLinkStyle = ({ isActive }: { isActive: boolean }): React.CSSProperties => ({
|
const navLinkStyle = ({ isActive }: { isActive: boolean }): React.CSSProperties => ({
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@@ -198,6 +204,13 @@ function DesktopNav({
|
|||||||
<List size={18} aria-hidden="true" />
|
<List size={18} aria-hidden="true" />
|
||||||
Lists
|
Lists
|
||||||
</NavLink>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Color legend */}
|
{/* Color legend */}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { NavLink } from 'react-router';
|
import { NavLink } from 'react-router';
|
||||||
import { CalendarDays, List } from 'lucide-react';
|
import { CalendarDays, List, ShieldCheck } from 'lucide-react';
|
||||||
|
|
||||||
function isPhone(): boolean {
|
function isPhone(): boolean {
|
||||||
return typeof window !== 'undefined' && window.matchMedia('(max-width: 767px)').matches;
|
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)',
|
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
|
// 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
|
// the AppNav sidebar's Settings/avatar button (FIX 4). Consistent with the
|
||||||
// isPhone() breakpoint used in AppNav and CalendarShell.
|
// isPhone() breakpoint used in AppNav and CalendarShell.
|
||||||
@@ -96,6 +101,21 @@ export function BottomTabBar() {
|
|||||||
<List size={22} aria-hidden="true" />
|
<List size={22} aria-hidden="true" />
|
||||||
<span>Lists</span>
|
<span>Lists</span>
|
||||||
</NavLink>
|
</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>
|
</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