61 Commits
Author SHA1 Message Date
Lucas BergerandClaude Opus 4.8 66e3b806be chore: remove REQUIREMENTS.md for v1.1 milestone
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 22:04:01 -04:00
Lucas BergerandClaude Opus 4.8 7fbb3cca9d chore: archive v1.1 milestone files
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 22:03:56 -04:00
luckberg 6cc3b8ae27 Merge pull request 'chore(ci): persistent pnpm store + Playwright caches (and Dockerfile BuildKit cache)' (#27) from gsd/quick-260618-tg2-ci-dep-cache into main
Publish / publish (push) Successful in 1m23s
Reviewed-on: #27
2026-06-18 21:32:06 -04:00
Lucas Berger c5cdb9c21d docs(quick-260618-tg2): persistent CI dependency caches (pnpm store + Playwright + Dockerfile)
CI / changes (pull_request) Successful in 4s
CI / api (pull_request) Successful in 2m6s
CI / fast-checks (pull_request) Successful in 2m32s
CI / security (pull_request) Successful in 1m2s
CI / harness (pull_request) Successful in 5m36s
CI / gate (pull_request) Successful in 2s
2026-06-18 21:21:26 -04:00
Lucas BergerandClaude Opus 4.8 6e93e24df0 chore(260618-tg2): BuildKit pnpm-store cache mount in Dockerfile build
Add 'RUN --mount=type=cache,target=/pnpm-store' to all 3 pnpm install
stages (builder/pwa-builder/production) with --store-dir /pnpm-store, plus
the '# syntax=docker/dockerfile:1' directive. Set DOCKER_BUILDKIT=1 on the
publish build step so the legacy builder can't break on the mount syntax.
sharing=locked because builder and pwa-builder run in parallel.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 21:19:45 -04:00
Lucas Berger f83d423e1c docs(20): document CI persistent cache host-mount dependency
- Add "CI dependency caches" subsection to CI Pipeline Overview
- Lists /pnpm-store and /ms-playwright container paths
- Notes act_runner config.yaml container.options host-mount requirement
- Clarifies that CI still works without the mounts (ephemeral fallback)
2026-06-18 21:16:52 -04:00
Lucas Berger 80b20383f1 chore(20): persistent CI caches — pnpm store + Playwright browsers
- All four pnpm install steps now use --store-dir /pnpm-store --prefer-offline
- harness job env adds PLAYWRIGHT_BROWSERS_PATH: /ms-playwright
- Updated stale D-PROBE-04 comments to reflect persistent store
- Playwright install step gets a comment noting the future runner-image optimization
2026-06-18 21:16:43 -04:00
luckberg 2276a254e4 chore: remove unused Redis service and references (#26)
Publish / publish (push) Successful in 23s
2026-06-18 21:06:32 -04:00
Lucas BergerandClaude Opus 4.8 0810260d0b docs(20): add orphaned phase-20 UAT (7/7 passed, playwright-verified)
UAT was committed locally after PR #25's final push, so it never reached
main. Recovered and committed directly per maintainer authorization.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 20:48:52 -04:00
luckberg cc76a32d0a Merge pull request 'Phase 20: Admin Member Editor & Form Declutter' (#25) from gsd/phase-20-admin-member-editor-form-declutter into main
Publish / publish (push) Successful in 39s
Reviewed-on: #25
2026-06-18 20:40:33 -04:00
Lucas Berger c43bd314a1 Merge remote-tracking branch 'origin/main' into gsd/phase-20-admin-member-editor-form-declutter
CI / changes (pull_request) Successful in 4s
CI / fast-checks (pull_request) Successful in 2m26s
CI / api (pull_request) Successful in 1m56s
CI / harness (pull_request) Successful in 5m43s
CI / security (pull_request) Successful in 44s
CI / gate (pull_request) Successful in 3s
# Conflicts:
#	.planning/STATE.md
2026-06-18 20:26:49 -04:00
Lucas Berger ba63940071 docs(20): ship phase 20 — PR #25 2026-06-18 20:26:16 -04:00
Lucas Berger f0aa901f57 docs(phase-20): add security threat verification
CI / changes (pull_request) Successful in 4s
CI / api (pull_request) Successful in 2m8s
CI / fast-checks (pull_request) Successful in 2m26s
CI / security (pull_request) Successful in 1m1s
CI / harness (pull_request) Failing after 13m24s
CI / gate (pull_request) Failing after 2s
2026-06-18 20:21:27 -04:00
Lucas BergerandClaude Opus 4.8 8829fd22b5 docs(20): mark IN-04 resolved — review fully clean
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 18:22:43 -04:00
Lucas BergerandClaude Opus 4.8 5161bd39c2 fix(20): IN-04 skip profile-saved toast + refetch on no-op save
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 18:22:29 -04:00
Lucas BergerandClaude Opus 4.8 5240f1e503 docs(20): add code review fix report — 11/11 fixed, re-review clean
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 18:13:39 -04:00
Lucas BergerandClaude Sonnet 4.6 41a4faec94 fix(20): prettier format admin.test.ts
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 18:05:14 -04:00
Lucas BergerandClaude Sonnet 4.6 182ba1d477 fix(20): IN-02 add maxLength to display-name and username inputs
Add maxLength={256} to the edit-mode and create-mode display-name inputs,
and maxLength={128} to the create-mode username input, matching the server-
side Zod schema limits. This surfaces the constraint client-side instead of
letting a long entry reach the server and return a generic 400.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 18:03:20 -04:00
Lucas BergerandClaude Sonnet 4.6 400733fdc7 fix(20): IN-01 helper text when display name is empty
Render an explanatory helper line when displayName is empty and no
other error is showing, so admins editing an OIDC-provisioned member
with a null displayName understand why Save is disabled rather than
seeing a greyed-out button with no explanation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 18:02:49 -04:00
Lucas BergerandClaude Sonnet 4.6 d2e9862849 fix(20): WR-05 add aria-describedby to admin toggle for last-admin error
Link the admin role="switch" toggle button to the profile-error region
via aria-describedby so screen-reader users who activate the toggle
hear the last-admin error announcement — matching the existing link on
the display-name input.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 18:02:27 -04:00
Lucas BergerandClaude Sonnet 4.6 2fd253ea95 fix(20): WR-03 IN-03 phone sheet maxHeight scroll and safe-area padding
Add maxHeight:90dvh + overflowY:auto to the phone bottom-sheet branch
so content overflowing a short viewport (iPhone SE 667px) is scrollable
rather than clipped and the Save buttons remain reachable.

Add paddingBottom with env(safe-area-inset-bottom) to clear the iOS home
indicator / Android gesture navigation bar (IN-03).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 18:01:59 -04:00
Lucas BergerandClaude Sonnet 4.6 527d85530c fix(20): WR-01 WR-02 WR-04 profile mutation and handleClose fixes
WR-01: Send only changed fields in profileMutation so admin-toggle-only
saves don't re-send displayName (blocking members with null displayName
from ever having their admin flag toggled).

WR-02: Revert the toggle to member!.isAdmin explicitly instead of
`member?.isAdmin ?? true` — the prior fallback was correct by coincidence
but semantically wrong for any error path where member is non-null.

WR-04: Remove member-derived field resets (displayName, isAdmin) from
handleClose — those belong to the useEffect sync that already tracks
member?.id/displayName/isAdmin. Only ephemeral fields (passwords, errors)
are reset on close, eliminating the stale-closure Cancel regression.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 18:01:32 -04:00
Lucas BergerandClaude Sonnet 4.6 ee04aee4fb fix(20): CR-02 derive editorMember from live query data
Store only the member id in editorMemberId state rather than the full
AdminMember object. Derive editorMember from membersQuery.data after each
render, so query invalidations following a per-section save always give
MemberEditorSheet fresh server state — eliminating the stale-reference
silent-overwrite race.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 18:00:47 -04:00
Lucas BergerandClaude Sonnet 4.6 72977334fc fix(20): CR-01 WR-06 atomic last-admin guard + empty-body 400
Wrap the last-admin check and UPDATE in a db.transaction with a
SELECT...FOR UPDATE locking read so concurrent PATCH demotions
serialise and cannot both pass the guard, eliminating the TOCTOU
race (CR-01).

Add a .refine() to updateMemberSchema requiring at least one field,
returning 400 via noEchoHook instead of crashing Drizzle with an
empty SET clause (WR-06).

Add Test H asserting empty {} -> 400 { error: 'Invalid request' }.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 17:59:56 -04:00
Lucas Berger 5c74ada48b docs(20): add deep code review report 2026-06-18 17:57:08 -04:00
Lucas BergerandClaude Opus 4.8 f656a0c77b docs(phase-20): evolve PROJECT.md after phase completion
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 17:48:23 -04:00
Lucas BergerandClaude Opus 4.8 dec8220da3 docs(phase-20): complete phase execution — verification passed (9/9), advisory review
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 17:47:51 -04:00
Lucas Berger 80fd683877 docs(20-03): complete member editor + declutter plan 2026-06-18 17:40:52 -04:00
Lucas Berger 9b62887f0f feat(20-03): unify member editor + declutter admin members panel
- playwright-cli verified: Members tab shows tappable rows, no retired buttons
- Row tap opens 'Edit member' sheet; per-section saves keep sheet open
- 'Add member' trigger opens 'Add member' sheet in create mode
- Profile save fires 'Profile saved.' toast; sheet stays open (D-05)
- eslint + prettier + typecheck + vitest (275 tests) all pass
- Fix pre-existing prettier drift in docs/*, CLAUDE.md, README.md, api/admin.ts
2026-06-18 17:39:00 -04:00
Lucas Berger 9e6b004541 feat(20-03): rework AdminPage MemberRow + Add-member trigger; remove old surfaces
- MemberRow: whole-row role=button, aria-label='Edit {name}', trailing ChevronRight
- Admin badge on member.isAdmin (12px/600, --color-member-0 on --color-surface-dim)
- 'Add member' ghost trigger with Plus icon opens MemberEditorSheet in create mode
- Single MemberEditorSheet replaces CredentialSheet + ResetPasswordSheet mounts
- Removed: inline Local Accounts add-form, ResetPasswordSheet definition, create-form state
- Empty state: 'No members yet' / 'Add a member to get started.' (UI-SPEC copy)
2026-06-18 17:35:37 -04:00
Lucas Berger b125a69b58 feat(20-03): add MemberEditorSheet with edit/create modes and per-section saves
- Edit mode: Profile (display name + admin toggle), Set new password, App password sections
- Create mode: single form with display name, username, initial/confirm password
- Per-section saves keep sheet open; create success closes sheet (D-05, D-07)
- Admin toggle role=switch, aria-checked; last-admin 409 shows inline error + reverts (D-03)
- Section 2 gated on member.hasLocalCredential; passwords never prefilled (T-20-07)
- App password save routes through saveCredential -> CalDAV validation (T-20-08)
- No Rotate/Add credential/Reset password copy (D-06)
2026-06-18 17:31:46 -04:00
Lucas Berger 10149a5966 docs(phase-20): update tracking after wave 1 2026-06-18 17:23:49 -04:00
Lucas Berger 258a188bde chore: merge executor worktree (worktree-agent-af493496867aaca6d) 2026-06-18 17:21:59 -04:00
Lucas Berger b377e9cb0a chore: merge executor worktree (worktree-agent-afcc2c886302ea642) 2026-06-18 17:21:59 -04:00
Lucas Berger 4a179a943d docs(20-01): complete member-profile update plan — SUMMARY + all CI gates green 2026-06-18 17:21:30 -04:00
Lucas BergerandClaude Sonnet 4.6 618991cf13 docs(20-02): complete plan 20-02 summary
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 17:21:26 -04:00
Lucas BergerandClaude Sonnet 4.6 5bcd8180c1 feat(20-02): add updateMemberProfile fetcher + AdminMember.isAdmin
- Add isAdmin: boolean to AdminMember interface (after color, before
  hasCredential) — feeds the Phase 20 editor toggle initial state (D-02)
- Add updateMemberProfile(memberId, body) fetcher: PATCH /api/admin/members/:id,
  credentials:include, redirect:manual, JSON body
- Maps 401/opaqueredirect → SessionExpiredError (existing convention)
- Maps 409/422 → Error('last-admin') sentinel (D-03 last-admin guard)
- Maps other non-ok → generic error
- All 50 tests pass; eslint + prettier + tsc --noEmit exit 0

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 17:20:13 -04:00
Lucas Berger bc48632756 feat(20-01): add PATCH /members/:id member-profile update with last-admin guard
- Add isAdmin field to GET /members select and mapped member object (D-02)
- Add updateMemberSchema (displayName optional string, isAdmin optional boolean)
- Register adminRouter.patch('/members/:id') with noEchoHook and requireAdmin (inherited)
- Handler: parsePositiveIntParam id validation (400), existence check (404),
  D-03 last-admin guard via COUNT(*) query (409), partial set() update (200)
- Fix Test D: switch to adminId2 for GET after self-demotion (adminId1 no longer admin)
- All 44 tests green including 7 new PATCH/isAdmin tests
2026-06-18 17:19:43 -04:00
Lucas BergerandClaude Sonnet 4.6 18da7e9476 test(20-02): add failing tests for updateMemberProfile + AdminMember.isAdmin
- 8 RED tests covering: PATCH URL contract, credentials/redirect shape,
  void on 200, SessionExpiredError on 401/opaqueredirect, last-admin
  sentinel on 409 and 422, generic error on 500
- 1 compile-time shape test for AdminMember.isAdmin: boolean
- All new tests fail (updateMemberProfile is not a function); 42 existing pass

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 17:18:21 -04:00
Lucas Berger a0a82ac9b6 test(20-01): add failing tests for member-profile update + last-admin guard + isAdmin read
- Test A: PATCH displayName happy path → 200, GET reflects change
- Test B: PATCH isAdmin promote → 200, GET shows isAdmin true
- Test C: last-admin guard → 409 when only admin demotes self
- Test D: self-demotion → 200 when second admin exists
- Test E: non-admin PATCH → 403 (requireAdmin boundary)
- Test F: wrong-type body → 400 { error: "Invalid request" }; malformed :id → 400
- Test G: non-existent member id → 404
- Test H: GET /members includes boolean isAdmin per member
All 7 new tests fail RED for the right reasons (route 404 / isAdmin missing)
2026-06-18 17:18:14 -04:00
Lucas Berger 9b8b84edbe docs(20): finalize phase plan (state, roadmap waves, patterns) 2026-06-18 17:06:40 -04:00
Lucas Berger 666845a192 docs(20): create phase plan 2026-06-18 17:04:21 -04:00
Lucas Berger a3d89d0da0 docs(20): UI design contract 2026-06-18 16:51:46 -04:00
Lucas Berger 96193831c4 docs(20): UI design contract 2026-06-18 16:50:31 -04:00
Lucas Berger fe0325ec43 docs(state): record phase 20 context session 2026-06-18 16:42:23 -04:00
Lucas Berger 91948919a8 docs(20): capture phase context 2026-06-18 16:42:19 -04:00
luckberg 555b33d1f1 Merge pull request 'Phase 17: UI Optimization & Polish' (#24) from gsd/phase-17-ui-optimization-polish into main
Publish / publish (push) Successful in 1m26s
Reviewed-on: #24
2026-06-18 16:24:52 -04:00
Lucas BergerandClaude Opus 4.8 a923c923c9 test(17): open Settings tab before asserting Timezone region in e2e
CI / changes (pull_request) Successful in 5s
CI / api (pull_request) Successful in 2m12s
CI / fast-checks (pull_request) Successful in 2m26s
CI / security (pull_request) Successful in 1m0s
CI / harness (pull_request) Successful in 5m25s
CI / gate (pull_request) Successful in 2s
Phase 17 D-10 reworked /admin into a two-tab layout; the Timezone section
moved into the (initially hidden) Settings tab panel. timezone-verify.spec.ts
predates that rework and asserted the region visible on bare /admin, so all 7
tests timed out in the harness. Click the Settings tab in beforeEach and after
the persist-test reload. Product behavior is correct; the spec was stale.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 15:41:53 -04:00
Lucas BergerandClaude Opus 4.8 24bc8d2c32 style(17): apply prettier formatting to satisfy CI format:check
CI / changes (pull_request) Successful in 6s
CI / api (pull_request) Successful in 2m9s
CI / fast-checks (pull_request) Successful in 2m30s
CI / security (pull_request) Successful in 59s
CI / harness (pull_request) Failing after 12m0s
CI / gate (pull_request) Failing after 2s
Reformats 4 phase-17 files (SettingsSheet.tsx, tokens.css, vite.config.ts,
pwa-assets.config.ts) plus 11 pre-existing non-conformant docs/READMEs that
the repo-wide format:check also flags.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 15:23:35 -04:00
Lucas Berger 6c2c6f24a9 docs(17): ship phase 17 — PR #24
CI / changes (pull_request) Successful in 4s
CI / fast-checks (pull_request) Failing after 1m38s
CI / security (pull_request) Has been cancelled
CI / gate (pull_request) Has been cancelled
CI / api (pull_request) Has been cancelled
CI / harness (pull_request) Has been cancelled
2026-06-18 15:19:23 -04:00
Lucas Berger 51ec9c3e82 docs(roadmap): add Phase 20 — Admin Member Editor & Form Declutter 2026-06-18 15:14:25 -04:00
Lucas Berger b8d4a69b73 docs(phase-17): add security threat verification (9 closed, 0 open)
CI / changes (pull_request) Successful in 4s
CI / fast-checks (pull_request) Failing after 1m41s
CI / api (pull_request) Successful in 2m2s
CI / security (pull_request) Successful in 54s
CI / gate (pull_request) Has been cancelled
CI / harness (pull_request) Has been cancelled
2026-06-18 14:33:10 -04:00
Lucas Berger 881f2d2d18 test(17): complete UAT - 9 passed, 0 issues (playwright-cli verified) 2026-06-18 14:31:32 -04:00
Lucas Berger 6fa6725fe8 docs(17): add code review fix report (auto-loop, 3 iterations) 2026-06-18 14:06:03 -04:00
Lucas Berger 287ecae2f7 fix(17): CR-01 make focus-trap visibility filter tolerant of jsdom; IN-01 drop inert containment guard 2026-06-18 14:04:33 -04:00
Lucas Berger 89dee4f586 fix(17): IN-03 render visible Redirecting status during OIDC redirect 2026-06-18 13:57:31 -04:00
Lucas Berger f7575ea2c3 fix(17): IN-02 document hand-maintained favicon.ico coupling 2026-06-18 13:56:48 -04:00
Lucas Berger 7578d48d3d fix(17): IN-01 pull focus back into dialog when activeElement escapes 2026-06-18 13:56:32 -04:00
Lucas Berger 5b4625b41d fix(17): WR-01 exclude hidden/zero-size nodes from focus-trap boundaries 2026-06-18 13:56:23 -04:00
Lucas Berger 4bc1e2a820 fix(17): IN-05 normalize AdminPage JSX formatting with Prettier 2026-06-18 13:48:34 -04:00
Lucas Berger 11b6b36cb9 fix(17): IN-07 memoize Intl.DateTimeFormat timezone resolution 2026-06-18 13:47:37 -04:00
70 changed files with 6813 additions and 1710 deletions
+19 -11
View File
@@ -46,12 +46,12 @@ jobs:
- name: Enable pnpm
run: corepack enable pnpm
# actions/cache@v4 is intentionally omitted — probe (D-PROBE-04) showed it
# times out on this runner (socket hang-up between runner container and job
# container cache server). pnpm install without cache takes ~30s; acceptable.
# actions/cache@v4 intentionally omitted (D-PROBE-04). Installs now target the
# host-mounted pnpm store at /pnpm-store (see act_runner config.yaml container.options).
# Without the host mount the flag still works — pnpm creates an ephemeral store there.
- name: Install dependencies
run: pnpm install --frozen-lockfile
run: pnpm install --frozen-lockfile --store-dir /pnpm-store --prefer-offline
- name: Lint
run: pnpm lint
@@ -104,10 +104,11 @@ jobs:
- name: Enable pnpm
run: corepack enable pnpm
# actions/cache@v4 intentionally omitted — same reasoning as fast-checks job (D-PROBE-04).
# actions/cache@v4 intentionally omitted (D-PROBE-04). Installs now target the
# host-mounted pnpm store at /pnpm-store (see act_runner config.yaml container.options).
- name: Install dependencies
run: pnpm install --frozen-lockfile
run: pnpm install --frozen-lockfile --store-dir /pnpm-store --prefer-offline
# Pitfall 11: service container healthy != MariaDB accepting connections.
# No mysql CLI in the runner image (D-PROBE-03); poll via the already-installed
@@ -181,6 +182,9 @@ jobs:
DB_USER: familysync
DB_PASSWORD: testpass
DB_NAME: familysync
# Persist Playwright browser binaries across runs via host-mounted /ms-playwright.
# Without the host mount CI still works — binaries are downloaded to the ephemeral dir.
PLAYWRIGHT_BROWSERS_PATH: /ms-playwright
steps:
- uses: actions/checkout@v4
@@ -191,10 +195,11 @@ jobs:
- name: Enable pnpm
run: corepack enable pnpm
# actions/cache@v4 intentionally omitted — same reasoning as fast-checks job (D-PROBE-04).
# actions/cache@v4 intentionally omitted (D-PROBE-04). Installs now target the
# host-mounted pnpm store at /pnpm-store (see act_runner config.yaml container.options).
- name: Install dependencies
run: pnpm install --frozen-lockfile
run: pnpm install --frozen-lockfile --store-dir /pnpm-store --prefer-offline
# Pitfall 11: service container healthy != MariaDB accepting connections.
# No mysql CLI in the runner image (D-PROBE-03); poll via the mysql2 driver
@@ -270,7 +275,9 @@ jobs:
# Install Playwright browsers with system deps BEFORE starting the API, so the long
# browser download does not run during the API's lifetime.
# Must run from apps/pwa/ where @playwright/test is installed (D-PROBE-05 confirmed exit 0).
# Do NOT cache browser binaries — Playwright explicitly recommends against it in CI.
# PLAYWRIGHT_BROWSERS_PATH=/ms-playwright (job-level env) persists binaries across runs via
# the host-mounted dir. The --with-deps apt step cannot be cached; baking a runner image
# with browsers preinstalled would also drop the --with-deps apt step (future optimization).
- name: Install Playwright browsers
run: npx playwright install --with-deps webkit chromium
working-directory: apps/pwa
@@ -462,7 +469,8 @@ jobs:
--exit-code 1
# ── pnpm audit + outdated (code-change PRs only, D-12) ───────────────────
# actions/cache@v4 intentionally omitted — same reasoning as fast-checks job (D-PROBE-04).
# actions/cache@v4 intentionally omitted (D-PROBE-04). Installs now target the
# host-mounted pnpm store at /pnpm-store (see act_runner config.yaml container.options).
- uses: actions/setup-node@v4
if: needs.changes.outputs.code == 'true'
@@ -475,7 +483,7 @@ jobs:
- name: Install dependencies
if: needs.changes.outputs.code == 'true'
run: pnpm install --frozen-lockfile
run: pnpm install --frozen-lockfile --store-dir /pnpm-store --prefer-offline
- name: Dependency audit (blocking on High+Critical)
if: needs.changes.outputs.code == 'true'
+5
View File
@@ -88,6 +88,11 @@ jobs:
# Build from REPO ROOT (T-08-10): the Dockerfile copies the pnpm workspace manifest +
# lockfile from the root context; building from apps/api/ would fail to find them.
- name: Build production image
# DOCKER_BUILDKIT=1 is required: the Dockerfile uses `RUN --mount=type=cache`
# (BuildKit) to persist the pnpm store across builds. The legacy builder would
# fail on that syntax. BuildKit is default on Docker 23+, set explicitly for safety.
env:
DOCKER_BUILDKIT: '1'
run: |
set -euo pipefail
docker build --target production \
+29
View File
@@ -1,5 +1,34 @@
# Milestones
## v1.1 Operability & Polish (Shipped: 2026-06-18)
**Scope:** 14 phases (720), 57 plans, ~110 tasks. Continues v1.0 numbering; merged to `main` across a series of phase PRs (latest #27).
**Delivered:** Turned the v1.0 MVP into a configurable, administrable, and maintainable app — guided first-run setup, in-app role-gated admin, per-event reminders, near-instant write-back, local-auth (no-OIDC) mode, auto timezone — backed by a full self-hosted Gitea CI/CD pipeline (mobile + desktop e2e, security scanning, image hygiene, Docker publish) and a real lint gate. No more hand-editing env files or the database.
**Key accomplishments:**
- **Phase 7 — Mobile Test Harness:** Playwright harness (`@playwright/test`) with an iPhone/WebKit + Pixel/Chromium device matrix, SW-block, env-driven baseURL, deterministic dev-DB seed, and `DEV_AUTH_BYPASS` auth; layout/calendar/lists specs assert tap-targets, overflow, and populated/empty/error states. TEST-01/02. (Consumed by Phase 8 CI.)
- **Phase 8 — Gitea CI:** Self-hosted Gitea Actions pipeline — parallel `fast-checks` (lint/typecheck/PWA unit) + `api` (MariaDB 11 service container + migrate + DB-backed tests) + `harness` (dev-stack bring-up + Phase 7 specs on both profiles) gating every PR to `main`, plus a publish job pushing the API production image (`:latest` + `:v1.1-<sha>`, `--password-stdin`). CI-01/02.
- **Phase 9 — Faster Write-Back:** Event-driven outbox drain via a zero-dependency in-process EventEmitter (`outboxTrigger.ts`) — committed enqueues fire `signalOutboxDrain()` so edits land in ~12s instead of ~15s, preserving optimistic-202, create-before-delete, per-uid exactly-once, and the 15s fallback sweep. CAL-15.
- **Phase 10 — Admin Role & Settings:** v1.1 DB foundation (`users.is_admin`, `member_credentials.provider_type`, `calendar_events.reminder_lead_minutes`, `app_config`); DB-backed `requireAdmin` gating all `/api/admin/*`; one shared `validateEncryptAndStoreCredential` (CalDAV PROPFIND + AES-256-GCM) for admin rotation + member self-service; gated `/admin` PWA route. ADMIN-01/02/03.
- **Phase 11 — Per-Event Reminders:** Per-event reminder picker (None / 5m … 2d, all-day → 9 AM local) serialized as a VALARM, with a variable-lead scheduler (`uid:dtstartMs` dedup, dropped the hardcoded 15-min/shared-only restriction) that honors each event's lead, fires nothing without an alarm, and preserves VALARMs set in other clients. CAL-13/14, NOTIF-04/05/06.
- **Phase 12 — Initial Setup Wizard:** Pre-auth `/api/setup/*` first-run wizard validating DB / VAPID / OIDC / app-password before completion, generating env secrets (never persisted to DB), promoting the completing user to admin, and locking with a 423 guard on every invocation. SETUP-01/02/03/04.
- **Phase 13 — Real Lint Gate (ESLint):** ESLint flat config (typescript-eslint + React) across both apps + a Prettier `format:check` gate, turning the hollow `--if-present` no-op into a CI lint gate that actually fails; full first-run baseline cleanup to green.
- **Phase 14 — Desktop E2E Coverage:** Added a `desktop` (Desktop Chrome, no-touch) Playwright project and made the mobile-authored specs desktop-safe, so the CI regression gate validates desktop as well as iphone/pixel.
- **Phase 15 — Doc-Only CI Skip + Markdown Lint:** `dorny/paths-filter` classifies each PR so doc-only changes skip the slow `api`/`harness` jobs, with an always-running `gate` aggregate (avoids the required-check deadlock) and markdownlint-cli2 added to `fast-checks`.
- **Phase 16 — CI Dependency Audit, Security & Image Hygiene:** Boot-time refuse-to-boot guard + baked `NODE_ENV=production` confining `DEV_AUTH_BYPASS` to dev; `pnpm audit` gate with GHSA waiver allowlist + tiered outdated report; eslint-plugin-security; gitleaks (clean 613-commit baseline) + `.dockerignore`; publish-time image-hygiene assertions. SEC/DEP/IMG/CI-03.
- **Phase 17 — UI Optimization & Polish:** Fixed the long-standing phone BottomTabBar/FAB overlap (with a CI regression guard), shipped the real FamilySync logo + full favicon/PWA-icon set + brand accent, restructured `tokens.css` into a themeable semantic-token layer (light-only groundwork), and added a logout control + desktop-centered sheets + admin toasts.
- **Phase 18 — Auto Timezone Detection:** Made the household timezone an explicit, stored, browser-auto-detected, admin-changeable setting (`getHouseholdTimezone(db)` + IANA validation), routing the all-day "9 AM local" reminder computation through it instead of the implicit `process.env.TZ`.
- **Phase 19 — Local Auth (No-OIDC Mode):** Full local username/password account model (scrypt + stateless `local-session` JWT cookie, `local_credentials` table, rate-limit/lockout, login/logout, admin create/reset, self-change, OIDC-link, break-glass CLI) coexisting with the Authelia OIDC path — removing the hard dependency on a deployed Authelia.
- **Phase 20 — Admin Member Editor & Form Declutter:** Replaced per-row Rotate/Reset buttons with a single tappable member-editor sheet (display name + local password + app password) over a new `PATCH /api/admin/members/:id` with a last-admin guard, and collapsed the Add-member form — retiring the confusing "Rotate" copy.
**Requirements:** 17/17 v1.1 requirements complete (TEST, CI, CAL, ADMIN, NOTIF, SETUP). Phases 1320 were driven by decision contracts (D-IDs / AUTH-LOCAL-*) rather than REQ-IDs. Deferred to backlog: self-service onboarding (999.5), provider abstraction (999.1), multiple reminders per event (v1.2), dark mode / theming (999.20), modern styling refresh (999.21).
**Known deferred items at close:** none carried — all phase verifications (incl. Phase 11 & 17 human-needed checks) confirmed by the operator at close.
---
## v1.0 MVP (Shipped: 2026-06-10)
**Scope:** 6 phases, 42 plans, 68 tasks. Shipped via Gitea PR #1 (`gsd/v1.0-milestone``main`, 375 commits).
+16 -11
View File
@@ -8,20 +8,17 @@ FamilySync is a self-hosted, Dockerized family organization hub for a two-person
The household can see and co-edit one color-coded family calendar (shared + each member's personal) and shared lists from a single low-friction PWA — cross-ecosystem, no app store, no per-member calendar credential juggling.
## Current Milestone: v1.1 Operability & Polish
## Current State
**Goal:** Make FamilySync configurable, administrable, and maintainable for real multi-member use — guided setup, in-app admin, per-event reminders, faster write-back, CI/CD, and mobile test coverage — without hand-editing env files or the database.
**Shipped: v1.1 Operability & Polish (2026-06-18)** — 14 phases (720), 57 plans. Full detail in [`MILESTONES.md`](MILESTONES.md) and [`milestones/v1.1-ROADMAP.md`](milestones/v1.1-ROADMAP.md).
**Target features:**
v1.1 turned the v1.0 MVP into a configurable, administrable, maintainable app: guided first-run setup wizard, role-gated in-app admin (credential rotation, shared-calendar designation, member editor), per-event reminders with a variable-lead scheduler, near-instant (~12s) event write-back, local-auth (no-OIDC) mode, and auto timezone detection — all backed by a full self-hosted Gitea CI/CD pipeline (mobile + desktop Playwright regression, a real ESLint gate, dependency/secret/security scanning, dev↔prod image hygiene, and Docker publish). No more hand-editing env files or the database.
- **Per-event reminders** — reminder selector on the event form (incl. "none"), serialized as VALARM; scheduler honors each event's lead instead of a hardcoded 15-min, and fires nothing when an event has no alarm (was backlog 999.4)
- **Admin Settings section** — role-gated UI to manage per-member Fastmail app passwords and designate the shared calendar, replacing manual DB writes (was backlog 999.10)
- **Initial setup wizard** — first-run validated bootstrap of env vars, VAPID keypair, DB connection, and first app password (was backlog 999.11)
- **Faster write-back** — event-driven outbox drain so edits land in ~1s instead of up to ~15s, preserving the optimistic-202 durability guarantees (was backlog 999.13)
- **Gitea CI** — full regression (lint/typecheck/unit/API-integration against a MariaDB service container) on PR to main + build/publish Docker image (was backlog 999.14)
- **Mobile-browser testing** ✅ **delivered (Phase 7, 2026-06-11)** — Playwright harness, two-profile mobile matrix (iPhone/WebKit + Pixel/Chromium), DEV_AUTH_BYPASS auth, deterministic dev-DB seed; 58 specs across both profiles assert layout/state. TEST-01/TEST-02 validated. Consumed by Phase 8 CI (was backlog 999.12)
Deferred to backlog: self-service provider onboarding (999.5), provider abstraction (999.1), dark mode / theming (999.20), and a broader modern-styling refresh (999.21 — future milestone).
Deferred to backlog: self-service provider onboarding (999.5) and provider abstraction (999.1). Admin-managed credentials (999.10) partially cover the multi-member credential gap in the interim.
## Next Milestone
Not yet defined. Start with `/gsd-new-milestone` (questioning → research → requirements → roadmap). Candidate seeds in the backlog: dark mode / theming (999.20), modern visual refresh (999.21), self-service onboarding (999.5), provider abstraction (999.1), dev-user full-app exercise without a real calendar (999.19), and acting on the CI dependency report (999.18).
## Requirements
@@ -39,6 +36,11 @@ Deferred to backlog: self-service provider onboarding (999.5) and provider abstr
- [x] Faster write-back so edits reach Fastmail in ~12s 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] Per-event reminders — choose a reminder lead per event (None / 5m / 10m / 15m / 30m / 1h / 2h / 1d / 2d, all-day → day-granularity + 9 AM fire), serialized as a VALARM, with a variable-lead scheduler that honors each event's lead (CAL-13/CAL-14, NOTIF-04/05/06) — **Validated in Phase 11 (per-event-reminders)**: pure VALARM serialization/classification layer (`buildTimedValarm`/`buildAllDayValarm`/`classifyValarms`/`extractValarms`/`computeAlertInstantUtc`); variable-lead scheduler with `uid:dtstartMs` dedup, dropped fixed-15-min/shared-only restriction, all-day 9 AM-local branch; `reminderLeadMinutes` threaded end-to-end with preserve-on-no-change (D-08); allDay-aware reminder picker with edit pre-population. Gap-closure (Plan 11-05) fixed two code-review blockers — custom/other-client VALARMs are now preserved on edit via a surfaced `reminderIsCustom` signal (CAL-14 / Pitfall 1), and the all-day push body no longer reads "Starts in 0 min" — plus post-event-trigger classification, a server-side max bound, and helper-text gating. 5/5 must-haves verified; 347 API + 206 PWA tests green. **Deferred:** live Fastmail VALARM round-trip + on-device push fire (untestable in dev — no provider connected; backlog 999.19).
- [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.
- [x] Initial setup wizard — first-run validated bootstrap of env/secrets/DB/OIDC/VAPID/app-password instead of hand-editing files; locks once complete (SETUP-01/02/03/04) — **Validated in Phase 12 (initial-setup-wizard)**: pre-auth `/api/setup/*` router mounted before the OIDC guard; each input validated (DB connects, VAPID decodes to 32 bytes + pairs with the public key, OIDC discovery resolves, app password reaches CalDAV); generated secrets shown for env copy and never persisted to the DB; completing user promoted to admin and a 423 guard enforced on every invocation. First-login-claims rework in `upsertUser` (no email coupling).
- [x] Self-hosted Gitea CI/CD + automated browser test coverage (TEST-01/02, CI-01/02) — **Validated in Phases 7/8/13/14/15/16**: a mobile (iPhone/WebKit + Pixel/Chromium) **and** desktop Playwright harness reached via `DEV_AUTH_BYPASS`; a PR pipeline gating lint (real ESLint flat config) / typecheck / unit / MariaDB-backed API integration / the headless harness; doc-only PRs skip the slow jobs via an always-running `gate` aggregate; dependency audit + gitleaks + eslint-plugin-security + dev↔prod image-hygiene assertions; and a publish job pushing the API production image on merge to `main`.
- [x] Local-auth (no-OIDC) mode (AUTH-LOCAL-*) — **Validated in Phase 19 (local-auth-no-oidc-mode)**: full local username/password account model — scrypt hashing, stateless `local-session` JWT cookie, `local_credentials` table, rate-limit/lockout login + logout, admin create/reset member, self-change password, OIDC-link to claim a local user, and a break-glass reset-admin CLI — coexisting with the Authelia OIDC path, removing the hard dependency on a deployed Authelia for solo/small self-hosters.
- [x] Household timezone as an explicit, stored, auto-detected, admin-changeable setting (Phase 18 D-01..D-07) — **Validated in Phase 18 (auto-timezone-detection)**: `getHouseholdTimezone(db)` with IANA validation is the source of truth for the all-day "9 AM local" reminder computation (replacing the implicit `process.env.TZ`), seeded from the browser at first run and changeable from `/admin`; browser-local display/timed-write path untouched.
- [x] PWA visual identity + phone-layout polish + admin member editor (Phase 17 D-01..D-10, Phase 20 D-01..D-07) — **Validated in Phases 17 & 20**: fixed the phone BottomTabBar/FAB overlap (with a CI regression guard), shipped the real FamilySync logo + full favicon/PWA-icon set + brand accent, restructured `tokens.css` into a themeable semantic-token layer (light-only groundwork), added a logout control + desktop-centered sheets; and replaced the per-row Rotate/Reset buttons with a single tappable member-editor sheet over `PATCH /api/admin/members/:id` (last-admin guard), retiring the confusing "Rotate" copy.
### Active
@@ -100,6 +102,9 @@ Deferred to backlog: self-service provider onboarding (999.5) and provider abstr
| **D-15:** Validate the real external topology via a **local Newt connector + test subdomain** through existing Pangolin (Mode A), not an Unraid deploy. Unraid (Mode B) reserved for go-live. | Authelia OIDC + SSE pass-through behaviour live in Authelia + Pangolin/Newt, not in where the origin runs — so a local Newt rig faithfully tests both, decoupling "does the topology work" from "is it in production." Newt dials outbound (no open ports). Only shared touch is an additive, reversible Authelia client. | ✓ Validated (Gate 2 executed live in Phase 3 / D-17) |
| **D-16 (2026-06-05, Phase 2):** No dedicated Fastmail "broker" account. The **shared-family calendar is a calendar collection created on the operator's primary Fastmail account** (`me@lucasberger.ca`) and shared out to the wife + others via Fastmail's own calendar sharing. The app's single app password enumerates it like any other collection; the `calendars.is_shared` flag (operator-set) marks which row is the shared one. | Clarified during the Wave 2 checkpoint: "broker account" was only ever the role the primary account's app password plays. id=1 ("Calendar") is the operator's **personal** calendar, not the shared one — so it must NOT be marked `is_shared`. Aggregating each _other_ member's **personal** calendar still follows the D-09 per-member app-password model (open for Phase 3 onboarding: a member may get a personal color lane, or only the shared calendar). | ✓ Resolved (2026-06-10): "FamilySync" shared calendar created on the primary account, synced as `calendars.id=10`, marked `is_shared=1`; shared lane + reminders now active |
| **D-17 (2026-06-07, Phase 3):** Phase 1 Gate 2 (deferred per D-14) was executed live during Phase 3 against real Authelia OIDC over Pangolin/Newt (Mode A), clearing the load-bearing iOS-standalone-login risk. The full event write path (create/all-day/recurring/edit/delete/conflict) is verified end-to-end to Fastmail. | Live bring-up surfaced bugs the dev-bypass build could not (newt MTU blackhole, OIDC state-cookie race, write-path timezone/identity/join/cache bugs, all-day off-by-one, color collisions). All fixed; UX gaps captured as backlog 999.3999.9. | — Validated (Gate 2, `03-GATE2-RESULTS.md`). Carried: Android install (B5), SSE smoke (Phase 4 entry gate, D-14). |
| **D-18 (2026-06-12, Phase 9):** Faster write-back uses a **zero-dependency in-process EventEmitter** drain signal, not Redis — the drain is single-process by design; Redis stays only for list SSE. | The optimistic-202 outbox is single-process; an in-process signal funnelled through the existing `isDraining` guard preserves all durability guarantees without a new external dependency. (Redis was later removed entirely — quick 260618-smr — as it was unused at runtime.) | ✓ Validated (v1.1, Phase 9, CAL-15) |
| **D-19 (2026-06-17, Phase 19):** FamilySync ships **local username/password auth as a first-class mode coexisting with Authelia OIDC**, not OIDC-only. | The operator runs it this way; a hard dependency on a deployed Authelia is too heavy for solo/small self-hosters. A local user can be linked to an OIDC identity later (claim flow, never email-matched per D-10). | ✓ Validated (v1.1, Phase 19) |
| **D-20 (2026-06-11, Phase 8):** CI runs on the self-hosted Gitea runner with `runs-on: ubuntu-latest` (no self-hosted label) in Docker-executor mode; MariaDB readiness uses `healthcheck.sh --connect`, never `mysqladmin ping` (removed in MariaDB 11); the secret is `REGISTRY_PAT` (the `GITEA_` prefix is silently dropped). | Established by the runner-probe-first approach (PITFALLS 11/12); these constraints are load-bearing for every CI workflow in the repo. | ✓ Validated (v1.1, Phases 8/16, CI-01/02) |
## Evolution
@@ -122,4 +127,4 @@ This document evolves at phase transitions and milestone boundaries.
---
_Last updated: 2026-06-14 — Phase 11 (Per-Event Reminders) complete; CAL-13/14 + NOTIF-04/05/06 validated (live round-trip deferred, 999.19)_
_Last updated: 2026-06-18 after v1.1 milestone — Operability & Polish shipped (Phases 720, 57 plans): guided setup, in-app admin + member editor, per-event reminders, faster write-back, local-auth mode, auto timezone, and full Gitea CI/CD. Next milestone undefined — start with `/gsd-new-milestone`._
+54 -4
View File
@@ -46,6 +46,52 @@ _A living document updated after each milestone. Lessons feed forward into futur
---
## Milestone: v1.1 — Operability & Polish
**Shipped:** 2026-06-18
**Phases:** 14 (720) | **Plans:** 57 | **Sessions:** not tracked
### What Was Built
- A self-hosted Gitea CI/CD pipeline: PR-gating lint (real ESLint flat config) / typecheck / MariaDB-backed API integration / a mobile + desktop Playwright regression harness, plus dependency-audit / gitleaks / eslint-plugin-security / dev↔prod image-hygiene gates and a Docker publish on merge.
- In-app operability: role-gated admin (credential rotation, shared-calendar designation, member editor), a validated first-run setup wizard, per-event reminders with a variable-lead scheduler, auto timezone detection, and ~12s event write-back.
- A first-class local-auth (no-OIDC) mode coexisting with Authelia OIDC, removing the hard dependency on a deployed Authelia.
### What Worked
- **Backlog → phase promotion pipeline:** most of v1.1 (999.4/10/11/12/13/14/15/16) was captured as backlog during v1.0, then promoted cleanly into scoped phases — the deferred-idea capture paid off directly.
- **Runner-probe-first for self-hosted CI (PITFALL 12):** probing `node`/`pnpm`/Docker/registry access on the Gitea runner *before* authoring any test/build steps surfaced every fork answer (Docker-executor, `ubuntu-latest`, artifact-fork, `REGISTRY_PAT` naming) up front and avoided blind CI iteration.
- **Zero-dependency in-process solutions:** the EventEmitter outbox-drain signal (CAL-15) hit the latency goal with no new infra; the project later removed Redis entirely as unused.
- **TDD discipline on the admin/auth chain** (Phases 10/11/12/19) kept the role boundary and credential-handling correct, with route-level 403/423/409 guards asserted in tests.
### What Was Inefficient
- **Dev user can't exercise calendar features end-to-end:** `DEV_AUTH_BYPASS` user 1 has no `member_credentials`/calendars, so per-event reminders (Phase 11) could only be verified via tests + a route-mocked smoke, not hands-on by the operator (→ backlog 999.19). Recurring dev-testability friction.
- **Gitea-specific quirks cost cycles:** secrets with the `GITEA_` prefix are silently dropped (→ `REGISTRY_PAT`); `actions/upload-artifact@v4` is broken on Gitea (needs the `ChristopherHX` fork); `actions/cache@v4` timed out; skipped jobs may not emit a commit-status (drove the always-running `gate` aggregate). None are documented as GitHub-incompatible up front.
- **Scope grew mid-milestone:** the milestone planned as 717 but accreted 18/19/20 via `/gsd-phase`, and the ROADMAP header wasn't kept in sync — the phase-detail sections for 1820 ended up appended after the Backlog. Keep the roadmap header + section ordering current when inserting late phases.
### Patterns Established
- **Runner-probe-first** for any new self-hosted-CI capability — never author steps against an unprobed runner.
- **Always-running `gate` aggregate** (`if: always()`, passes on success-or-skipped) is the only safe required-check surface when path-filtering jobs — never mark a path-filtered job itself required (deadlock).
- **In-process EventEmitter over Redis** for single-process work (the outbox drain); reserve external infra for genuinely cross-process needs.
- **Local auth is a first-class mode**, not a fallback — identity stays OIDC-`iss+sub` (never email); a local user is *linked* to an OIDC identity via an explicit claim flow (D-10/D-19).
- **Confine dev-only affordances at build + boot:** bake `NODE_ENV=production` into the prod image and refuse-to-boot if `DEV_AUTH_BYPASS` is set — defense-in-depth beyond the runtime guard.
### Key Lessons
1. Capturing deferred ideas as structured backlog entries during one milestone makes the next milestone's roadmap nearly write-itself — invest in the capture.
2. Self-hosted GitHub-Actions-compatible runners are *not* drop-in GitHub — probe the runtime, the action ecosystem (forks), and the status/secret semantics before designing the pipeline.
3. Dev-environment testability is a feature: if the dev user can't exercise the real flows, every feature regresses to test-only verification and the operator can't UAT — fix the dev seed/provider story early (999.19).
### Cost Observations
- Model mix: not tracked
- Sessions: not tracked
- Notable: 14 phases shipped in ~8 days (2026-06-10 → 2026-06-18) with heavy parallelization across independent tracks (CI chain vs admin chain vs polish) once the harness landed.
---
## Cross-Milestone Trends
### Process Evolution
@@ -53,13 +99,17 @@ _A living document updated after each milestone. Lessons feed forward into futur
| Milestone | Sessions | Phases | Key Change |
| --------- | -------- | ------ | ----------------------------------------------------------------------------------------------------------------------------- |
| v1.0 | n/a | 6 | Established GSD plan→execute→verify→ship→complete loop; dev-auth bypass for gated infra; milestone-branch + Gitea PR shipping |
| v1.1 | n/a | 14 | Self-hosted Gitea CI/CD as the merge gate; per-phase branch + PR shipping; backlog→phase promotion pipeline; parallel independent tracks |
### Cumulative Quality
| Milestone | Tests | Coverage | Zero-Dep Additions |
| --------- | ------------------------------------- | ------------ | ------------------ |
| v1.0 | PWA 191 + API broker/events 114 green | not measured | n/a |
| Milestone | Tests | Coverage | Zero-Dep Additions |
| --------- | ------------------------------------- | ------------ | ------------------------------------------- |
| v1.0 | PWA 191 + API broker/events 114 green | not measured | n/a |
| v1.1 | PWA ~249 + API ~347 green | not measured | `outboxTrigger.ts` EventEmitter (CAL-15); Redis later removed entirely as unused |
### Top Lessons (Verified Across Milestones)
1. (pending second milestone to cross-validate)
1. **Capture deferred ideas as structured backlog during the milestone** — v1.1's roadmap came almost entirely from v1.0-era backlog entries.
2. **iOS-Safari standalone / on-device push stays a human gate** across both milestones — automated harnesses (desktop + mobile-emulated) cover layout/flows, never the device-only behavior.
3. **`setInterval` + in-process signals over external schedulers/brokers** for this single-process app — node-cron silently no-ops (v1.0), Redis went unused (v1.1).
+44 -485
View File
@@ -3,7 +3,9 @@
## Milestones
-**v1.0 MVP** — Phases 16 (shipped 2026-06-10) — see [`milestones/v1.0-ROADMAP.md`](milestones/v1.0-ROADMAP.md)
- 🚧 **v1.1 Operability & Polish** — Phases 717 (planning) — mobile test harness, Gitea CI (runs the harness), faster write-back, in-app admin, per-event reminders, guided setup, real lint gate, desktop e2e, doc-only CI skip + markdown lint, CI dependency audit + security checks + image hygiene, UI optimization & polish
- **v1.1 Operability & Polish** — Phases 720 (shipped 2026-06-18) — see [`milestones/v1.1-ROADMAP.md`](milestones/v1.1-ROADMAP.md)
> Next milestone not yet defined — start with `/gsd-new-milestone`.
## Phases
@@ -21,438 +23,52 @@ Full phase detail archived in [`milestones/v1.0-ROADMAP.md`](milestones/v1.0-ROA
</details>
### 🚧 v1.1 Operability & Polish (Phases 717)
Make FamilySync configurable, administrable, and maintainable for real multi-member use — without hand-editing env files or the database. The new critical path runs **mobile test harness → Gitea CI** (CI consumes the harness specs for UI regression), and the **admin role → reminders / setup wizard** chain (a single `/api/admin` + `/api/setup` route surface carrying the v1.1 DB migration). Faster write-back is a fully independent track.
- [x] **Phase 7: Mobile Test Harness** - Mobile-emulated, authenticated PWA browser harness so the assistant (and CI) can catch mobile-only defects (completed 2026-06-11)
- [x] **Phase 8: Gitea CI** - Full regression on PR to main (lint/typecheck/unit/API-integration vs a MariaDB service container **+ the Phase 7 mobile harness as a UI-regression step against a CI-hosted dev stack**) + Docker image publish on merge (completed 2026-06-11)
- [x] **Phase 9: Faster Write-Back** - Event-driven outbox drain so edits land in ~1-2s instead of ~15s, preserving every outbox durability guarantee (completed 2026-06-12)
- [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)
- [x] **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 (completed 2026-06-14)
- [x] **Phase 12: Initial Setup Wizard** - First-run validated bootstrap of env/VAPID/DB/OIDC + first app password, reusing the admin route surface (completed 2026-06-16)
- [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 14: Desktop E2E Coverage** - Add a Desktop Chrome Playwright profile + make the mobile-authored specs desktop-safe so the Phase 8 regression gate validates desktop, not just mobile (completed 2026-06-12)
- [x] **Phase 15: Doc-Only CI Skip + Markdown Lint** - Aggregate-gate the slow api/harness CI jobs so doc-only PRs to main merge without running them (no branch-protection deadlock), and add markdownlint to `fast-checks` so docs get a fast format+lint gate (promoted from backlog 999.17) (completed 2026-06-12)
- [x] **Phase 16: CI Dependency Audit, Security Checks & Image Hygiene** - Extend Gitea CI with outdated-dependency reporting + vulnerability audit + a baseline of additional security checks, and enforce the dev/prod image boundary so no dev-bypass, secret, or family data ships in published images (absorbs backlog 999.17); independent of the admin chain (completed 2026-06-13)
- [x] **Phase 17: UI Optimization & Polish** - Phone-layout polish + branding + theme groundwork: fix the long-standing phone-layout overlap where the fixed BottomTabBar covers the New Event FAB and the colour legend (+ small-viewport sweep), finish the branding assets (real FamilySync logo into the BrandSlot seam + a complete favicon/PWA-icon set replacing the placeholder stubs), and restructure tokens.css into a themeable token layer (light-only groundwork for future dark mode). Shipped dark theme → backlog 999.20; broader styling refresh → backlog 999.21 (future milestone) (completed 2026-06-18)
## Phase Details
> v1.0 phase detail (Phases 16) is archived in [`milestones/v1.0-ROADMAP.md`](milestones/v1.0-ROADMAP.md).
### Phase 7: Mobile Test Harness
**Goal**: The assistant can drive the PWA in a mobile-emulated, authenticated browser context against the host-side dev stack, so mobile-only layout and flow defects can be caught automatically instead of only by the operator on real devices. This harness is also the artifact Phase 8 (CI) runs for UI regression.
**Mode:** standard
**Depends on**: Nothing (fully independent; goes first. One new dev dependency `@playwright/test` in `apps/pwa`; no backend changes).
**Requirements**: TEST-01, TEST-02
**Success Criteria** (what must be TRUE):
1. An automated run can load the PWA in a mobile-emulated viewport (device profile + mobile UA + touch) and assert on responsive layout / tap targets.
2. The automated run reaches the authenticated PWA via the existing `DEV_AUTH_BYPASS` on the host-side dev stack — no manual login and no Authelia/OIDC mocking.
3. The harness runs repeatably day-over-day without re-capturing any session state (no stale storage-state failures).
4. The harness specs are structured so they can run headlessly in CI (Phase 8) against a dev stack the runner brings up — no dependence on a developer's already-running host stack.
**Pitfalls this phase owns** (from PITFALLS.md):
- **No stale storage-state** (Pitfall 14): use `DEV_AUTH_BYPASS=true` for the automated harness rather than a checked-in storage-state.json with an expiring session cookie; decide the auth strategy before the first test.
- **Service worker block** (Pitfall 15): set `serviceWorkers: 'block'` (or explicitly unregister) in the context so a previous run's SW does not intercept requests / return stale cached responses; verify no SW-sourced responses in the trace.
- Hard constraints: targets the dev build via `DEV_AUTH_BYPASS` (DEV_AUTH_BYPASS user 1 has no CalDAV credential/calendars — verify layout/flows, not live event-create); real prod-service-worker / iOS-Safari-standalone mobile testing stays a human/device gate (out of scope).
**Plans**: 4 plans (3 waves)Plans:
**Wave 1**
- [x] 07-01-PLAN.md — Harness foundation: @playwright/test + WebKit/Chromium browsers, playwright.config.ts (iPhone/WebKit + Pixel/Chromium matrix, serviceWorkers block, env baseURL, vite webServer), vitest exclude, scripts (Wave 1)
**Wave 2** *(blocked on Wave 1 completion)*
- [x] 07-02-PLAN.md — global-setup.ts: /health readiness poll + deterministic mysql2 reset-and-seed (calendar id 10 INSERT IGNORE guard, list + items) + e2e README/guardrails (Wave 2)
**Wave 3** *(blocked on Wave 2 completion)*
- [x] 07-03-PLAN.md — layout.spec.ts: tap targets >=44px, no overflow, in-viewport, accessible names (UI-SPEC Rules 1-4) + harness self-validation injected-defect proofs (Wave 3)
- [x] 07-04-PLAN.md — calendar.spec.ts + lists.spec.ts: populated/empty/error states (Rules 4/5) + DEV_AUTH_BYPASS auth-reached + no-SW-controller precondition (Wave 3)
**UI hint**: yes
### Phase 8: Gitea CI
**Goal**: Every PR to `main` runs a full regression that gates the merge — lint, typecheck, unit, API-integration against a MariaDB service container, **and the Phase 7 mobile Playwright harness as a UI-regression step against a CI-hosted dev stack** — and a merge to `main` builds and publishes the API Docker image, all on the existing self-hosted Gitea Actions runner.
**Mode:** standard
**Depends on**: Phase 7 (the PR regression runs the Phase 7 mobile harness specs as its UI-regression step; without the harness there is nothing to run). No other code dependencies. Start with a runner-probe step.
**Requirements**: CI-01, CI-02
**Success Criteria** (what must be TRUE):
1. Opening or updating a PR targeting `main` triggers a workflow that runs lint, typecheck (both apps), unit tests, and API integration tests against a MariaDB service container — and a failing run blocks the merge.
2. The API integration tests connect to the service-container MariaDB (DB_HOST=127.0.0.1, service creds) and pass reliably on a cold first run, not only on re-run.
3. The same PR workflow brings up the dev stack inside the runner — the API dev server, the PWA dev server, and the MariaDB service container, with `DEV_AUTH_BYPASS=true` — and runs the Phase 7 mobile Playwright harness specs headlessly against that authed PWA; a harness failure blocks the merge.
4. The harness step waits for both the API and PWA dev servers to be ready (readiness probe / poll) before launching Playwright, so it does not flake on startup races.
5. On merge to `main`, the API Docker image is built and pushed to the Gitea container registry under a sensible tag.
6. Registry credentials never appear in plaintext in the CI logs.
**Pitfalls this phase owns** (from PITFALLS.md):
- **Runner-probe first** (Pitfall 12): the first workflow only probes `node --version` / `pnpm --version` / Docker access on the `self-hosted` runner before any test or build steps are designed; pin Node 22 explicitly, do not assume `actions/setup-node` works as on GitHub.
- **MariaDB readiness wait** (Pitfall 11): add an explicit readiness loop (e.g. `healthcheck.sh --connect --innodb_initialized`, NOT `mysqladmin ping` which is removed in MariaDB 11) before any `drizzle-kit migrate` / integration test step; healthy ≠ accepting connections.
- **Dev-stack readiness races (NEW for the harness step):** running the PWA and API dev servers *inside* CI adds startup/readiness races on top of the MariaDB-11 readiness race. The harness step must wait for **both** the API and PWA dev servers to be accepting connections (poll their URLs / health endpoints) before Playwright launches — do not race the browser against a not-yet-listening server. Run with `DEV_AUTH_BYPASS=true` so the harness reaches the authed PWA exactly as in Phase 7.
- **--password-stdin** (Pitfall 13): `docker login` via `--password-stdin` with the token piped from a registered Gitea secret (PAT with `write:package`); never `-p $TOKEN` on the command line.
- Hard constraints: API integration tests need a real MariaDB and live in `apps/api/tests/` (never `src/`); cache the pnpm store; Drizzle generate+migrate to set up the CI DB schema; the harness step reuses the Phase 7 specs unchanged (CI owns only the stack bring-up + readiness wait, not the spec content).
**Plans**: 4 plans (4 waves)Plans:
**Wave 1**
- [x] 08-01-PLAN.md — Runner probe + operator runner/PAT registration (W0; answers the Docker-vs-host fork)
**Wave 2** *(blocked on Wave 1 completion)*
- [x] 08-02-PLAN.md — ci.yml: fast-checks (lint/typecheck/PWA unit) + API job (MariaDB service + migrate + DB-backed tests)
**Wave 3** *(blocked on Wave 2 completion)*
- [x] 08-03-PLAN.md — ci.yml: harness job (dev-stack bring-up + readiness waits + Phase 7 Playwright specs, both profiles)
**Wave 4** *(blocked on Wave 3 completion)*
- [x] 08-04-PLAN.md — ci.yml: publish job (build production image + push :latest + :v1.1-<sha> via --password-stdin)
**UI hint**: yes
### Phase 9: Faster Write-Back
**Goal**: A created, edited, or deleted event reaches Fastmail within ~1-2 seconds (event-driven outbox drain) instead of waiting up to ~15s for the next interval tick — with every existing durability guarantee intact.
**Mode:** standard
**Depends on**: Nothing (fully independent; the only new artifact is a zero-dependency in-process EventEmitter, `lib/outboxTrigger.ts`). Can run in parallel with any other v1.1 track.
**Requirements**: CAL-15
**Success Criteria** (what must be TRUE):
1. After creating/editing/deleting an event, the change lands in Fastmail in ~1-2s in the common case (drain is signalled on enqueue, not waited-for on the interval) — observable as the change appearing in the Fastmail native app well before the old ~15s window.
2. The route handler still returns an optimistic 202 immediately and never makes a CalDAV call inline — the event-driven signal is fire-and-forget.
3. Edit-as-move still writes the new event before deleting the old one (create-before-delete ordering preserved); no event is ever lost when a move drains under rapid enqueues.
4. No duplicate CalDAV PUTs occur for the same outbox row when the signal and the 15s fallback interval overlap (exactly-once per uid preserved).
5. The 15s `setInterval` fallback still runs and recovers any rows missed by the signal path (startup catch-up, transient errors).
**Pitfalls this phase owns** (from PITFALLS.md):
- **No double-drain** (Pitfall 5): the trigger must set a `drainRequested` flag funnelled through the single setInterval-controlled path / the existing `isDraining` guard — never call `runOutboxDrain()` directly from the signal in a way that bypasses the guard or escapes the error-caught wrapper.
- **Create-before-delete under concurrent enqueues** (Pitfall 6): enqueue CREATE before DELETE; do not fire the signal between the two inserts of a move (publish after both inserts / after the transaction commits).
- Hard constraints: `setInterval` only (no node-cron); single-process by design — **no Redis** for the drain (Redis stays for list SSE); all outbox guarantees (fresh-etag-before-PUT, 412 conflict flow, per-uid exactly-once) unchanged.
**Plans**: 2 plans (2 waves)
Plans:
**Wave 1**
- [x] 09-01-PLAN.md — TDD: outboxTrigger.ts (zero-dep EventEmitter signal) + scheduleOutboxDrain wrapper / drainRequested trailing-re-drain loop + initOutboxTrigger in outboxWorker.ts; trigger-wiring tests (SC-1, SC-4/D-07, D-05) (Wave 1)
**Wave 2** *(blocked on Wave 1 completion)*
- [x] 09-02-PLAN.md — Four post-commit signalOutboxDrain() publish sites in events.ts (create / edit-as-move-after-transaction / same-cal update / delete) + initOutboxTrigger() startup wiring under isMainModule() in index.ts (Wave 2)
### Phase 10: Admin Role & Settings
**Goal**: An admin can manage household configuration that previously required manual DB writes — rotating a member's Fastmail app password and designating the shared family calendar — from a role-gated in-app Settings section, on top of the v1.1 DB foundation this phase introduces.
**Mode:** standard
**Depends on**: Nothing required upstream; this phase **carries the v1.1 DB migration** (users.is_admin, calendar_events.reminder_lead_minutes, app_config table) that Phases 11 and 12 build on. It is the head of the admin chain (10 → 11, 10 → 12).
**Requirements**: ADMIN-01, ADMIN-02, ADMIN-03
**Success Criteria** (what must be TRUE):
1. An admin sees an Admin section in Settings and can list household members with their credential status; a non-admin member never sees it and cannot invoke any `/api/admin/*` route (gets 403).
2. An admin can enter or rotate a member's Fastmail app password; it is validated against CalDAV (PROPFIND) before saving and stored encrypted — and the password is never displayed, echoed in a response, or logged.
3. An admin can pick which synced calendar is the shared family calendar from a list, and the `calendars.is_shared` flag updates accordingly (replacing the manual `UPDATE calendars SET is_shared=1` step).
4. The role check is role-agnostic and member-count-agnostic: it gates on `users.is_admin`, so more admins can be added later without reworking the guard.
5. The DB migration (is_admin, reminder_lead_minutes, app_config) is applied via generate+migrate and is in place for downstream phases (reminder_lead_minutes for Phase 11, app_config.setup_complete for Phase 12).
**Pitfalls this phase owns** (from PITFALLS.md):
- **Admin role check inside the sub-router** (Pitfall 9): apply `requireAdmin` with `.use('*', ...)` inside `adminRouter`, not only at the parent mount; integration test must assert 403 for a non-admin authenticated user.
- **App password never logged/echoed** (Pitfall 7): custom zod-validator `hook` returns a generic 400 (no Zod `received`/`value` field); no `console.log` of request bodies in `routes/admin*`.
- Hard constraints: Drizzle **generate+migrate, never push** (false destructive diff on populated MariaDB); reuse `broker/crypto.ts` `encryptPassword` (no changes to crypto); `/api/admin/credentials` and `/api/admin/calendars/:id/shared` are the single shared surface — do NOT duplicate them into `/api/setup/*` in Phase 12.
**Folded-in scope** (from backlog 999.5, self-service member onboarding): the credential surface this phase builds is the same one a member needs on first login. Expose a `needsProviderSetup` signal (member has no `member_credentials` row) and let a member enter/validate (CalDAV PROPFIND) + encrypt their **own** Fastmail app password — the self-service counterpart of the admin-managed flow, sharing the validation/encryption/initial-sync path. Non-technical-friendly instructions (link to Fastmail's app-password page, required Calendars/CalDAV scope) are the hard UX constraint. Member-scoped: a member can only set their own credential; never log/echo the password.
**Plans**: 4 plans (4 waves)Plans:
**Wave 1**
- [x] 10-01-PLAN.md — v1.1 DB foundation migration (is_admin, provider_type+unique, reminder_lead_minutes, app_config) + dev-bypass admin seed
**Wave 2** *(blocked on Wave 1 completion)*
- [x] 10-02-PLAN.md — requireAdmin guard + first-login-wins bootstrap + /api/me isAdmin/needsProviderSetup (TDD)
**Wave 3** *(blocked on Wave 2 completion)*
- [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)*
- [x] 10-04-PLAN.md — PWA /admin route + nav gating + CredentialSheet + SetupBanner (playwright-cli verified)
**UI hint**: yes
### Phase 11: Per-Event Reminders
**Goal**: A user can choose a reminder lead time per event (None / 5m / 10m / 15m / 30m / 1h / 2h / 1d / 2d, default None), serialized as a VALARM on the event, and the push scheduler fires at that exact lead — firing nothing when there is no alarm and never stripping reminders set in other clients.
**Mode:** standard
**Depends on**: Phase 10 (the `calendar_events.reminder_lead_minutes` column from the v1.1 migration is the scheduler's ground truth). Independent of Phases 7/8/9/12.
**Requirements**: CAL-13, CAL-14, NOTIF-04, NOTIF-05, NOTIF-06
**Success Criteria** (what must be TRUE):
1. When creating or editing a timed event, the user can pick a reminder lead from the preset list (None default); the choice round-trips to Fastmail as a VALARM and is visible/honored on re-open.
2. Editing an event that already has a reminder set in another client (Fastmail / Apple Calendar) preserves that VALARM — it is never silently dropped on round-trip.
3. A reminder push fires at the event's chosen lead time (e.g. T-30 for a 30-minute lead), not a hardcoded 15-minute lead.
4. An event with no reminder set produces no reminder push (no default 15-minute fire).
5. An all-day event's reminder fires at a sensible local time (9 AM on the alert day), not midnight; the reminder selector is disabled/hidden for all-day events in the UI; and reminder delivery stays exactly-once across catch-up scans and rescheduled events.
**Pitfalls this phase owns** (from PITFALLS.md):
- **Preserve-on-edit** (Pitfall 1): the update path extracts and preserves existing VALARM sub-components from `rawVevent` (mirroring the WR-01 RRULE-preserve pattern) — never rebuild-from-scratch and silently strip; `outboxPayloadSchema` distinguishes "no change" from explicit "no reminder".
- **No TRIGGER VALUE=TEXT** (Pitfall 2): build the trigger with `ICAL.Duration.fromSeconds(-n*60)`, not a bare string; unit-test that the ICS emits a DURATION trigger with no `VALUE=TEXT`.
- **All-day 9AM semantics** (Pitfall 3): guard `buildVeventString` (`if (!allDay && reminderMinutes > 0)`), disable the selector when allDay, keep the scheduler's all-day handling at 9 AM local.
- **uid:dtstartMs dedup** (Pitfall 4): change the scheduler dedup key from bare `uid` to compound `uid:dtstartMs` and widen the scan to a variable per-event window so long leads fire and rescheduled events re-fire; keep `eventFieldsSchema` and `outboxPayloadSchema` in sync (IN-03).
- Hard constraints: `setInterval` only; scheduler reads `reminder_lead_minutes` from the DB (ground truth), not the outbox payload; drop the `isShared`-only reminder restriction (a user who set an alarm wants it regardless of calendar).
**Plans**: 4 plans (3 waves)
Plans:
**Wave 1**
- [x] 11-01-PLAN.md — VALARM builders + classifier + extractor + computeAlertInstantUtc (vevent.ts, TDD)
**Wave 2** *(blocked on Wave 1 completion)*
- [x] 11-02-PLAN.md — Variable-lead scheduler: uid:dtstartMs dedup, drop isShared, all-day 9 AM, humanized body (TDD)
- [x] 11-03-PLAN.md — Backend plumbing: schema field, outbox preserve-on-edit, sync upsert, occurrence surfacing
**Wave 3** *(blocked on Wave 2 completion)*
- [x] 11-04-PLAN.md — EventForm reminder picker (allDay swap, edit pre-population) + client types + Playwright smoke
**UI hint**: yes
### Phase 12: Initial Setup Wizard
**Goal**: On first run (no admin/credentials configured), the operator is guided through a validated, step-by-step wizard to bootstrap the app — env presence, generated secrets to copy, DB/OIDC/VAPID/app-password validation — instead of hand-editing `.env` / `docker-compose.yml`; once complete, the setup endpoints lock.
**Mode:** standard
**Depends on**: Phase 10 (reuses the admin role + `/api/admin/credentials` and `/api/admin/calendars/:id/shared` routes; the wizard is the second frontend consumer of that surface, and `app_config` from the Phase 10 migration holds `setup_complete`). Goes last. Independent of Phases 7/8/9/11.
**Requirements**: SETUP-01, SETUP-02, SETUP-03, SETUP-04
**Success Criteria** (what must be TRUE):
1. On a fresh install with nothing configured, the operator reaches a setup wizard (via `GET /api/setup/status` mounted before the OIDC guard) and walks through bootstrap steps instead of editing files by hand.
2. Each input is validated before the step can complete: DB connects, VAPID private key decodes to exactly 32 bytes and pairs with the public key, OIDC discovery resolves, and the Fastmail app password reaches CalDAV (PROPFIND).
3. Generated secrets (session secret, encryption key, VAPID keypair) are displayed for the operator to copy into env; they are never written to the DB or returned in a way that persists, and `APP_PASSWORD_ENCRYPTION_KEY`/`VAPID_PRIVATE_KEY` never enter the DB at all.
4. After completion, the wizard-completing user is promoted to admin (`is_admin`), `app_config.setup_complete` is set, and any further call to a setup endpoint returns 423 Locked.
5. The 423 guard is enforced on every invocation (checked against member-credentials + VAPID env present), not only at startup.
**Pitfalls this phase owns** (from PITFALLS.md):
- **Guard on every invocation** (Pitfall 8): the "already set up" guard returns 423 from all setup routes once configured — implement and test the guard before the happy path; a second POST after completion must return 423, not 200.
- **Secrets stay in env, never in DB** (Pitfalls 8 & 10): the wizard validates secrets by performing a test operation (test encrypt/decrypt, structural VAPID check), never by accepting/storing the key value; no DB column for `vapid_private_key` or `app_password_encryption_key`; never log/echo the app password.
- Hard constraints: `GET /api/setup/status` mounts **before** the OIDC guard (like `/health`); do NOT create `/api/setup/credentials` — reuse the Phase 10 admin routes; Drizzle generate+migrate (any `app_config` seeding via migration).
**Plans**: 7 plans in 4 waves (4 original + 3 gap-closure for 12-UAT.md gaps 1-6)
Plans:
**Wave 1**
- [x] 12-01-PLAN.md — Schema migration (nullable OIDC + claimed) + generate-secrets helper (SETUP-03) + Wave-0 scaffolds
**Wave 2** *(blocked on Wave 1 completion)*
- [x] 12-02-PLAN.md — Pre-auth /api/setup/* router + isSetupLocked 423 guard + index mount + OIDC boot fallback (SETUP-01/02/04)
- [x] 12-03-PLAN.md — First-login-claims rework in upsertUser (D-08, SETUP-01)
**Wave 3** *(blocked on Wave 2 completion)*
- [x] 12-04-PLAN.md — PWA SetupPage wizard + App.tsx gate + UI-SPEC revision (SETUP-01/02)
**Wave 4 — Gap closure** *(UAT 12-UAT.md gaps 1-6; 06+07 parallel, 05 blocked on 06)*
- [x] 12-06-PLAN.md — Backend: validate/vapid asserts wizard key == env VAPID_PUBLIC_KEY (gap 2) + status exposes non-secret DB name (gap 3) (SETUP-02)
- [x] 12-07-PLAN.md — App.tsx: reverse-gate /setup post-completion (gap 5) + reconcile ['me'] so calendar banner clears after wizard (gap 6) (SETUP-01/04)
- [x] 12-05-PLAN.md — SetupPage: drop DB-vs-env aside (gap 1) + read-only DB-name field (gap 3) + persist fields across Back (gap 4) (SETUP-01) — depends on 12-06
**UI hint**: yes
### Phase 13: Real Lint Gate (ESLint)
**Goal**: The CI lint gate actually fails on lint violations. A real ESLint flat config (`eslint.config.js`, `typescript-eslint`; React + react-hooks plugins for `apps/pwa`) plus a package-level `lint` script in `apps/api` and `apps/pwa` makes the existing root `pnpm -r --if-present lint` run a real linter, replacing the hollow no-op gate that exits 0 because no linter exists.
**Mode:** standard
**Depends on**: Phase 8 (the CI `fast-checks` job already runs `pnpm lint`; this fills the slot Phase 8 shipped wired to auto-activate once a package `lint` script lands). Independent of all other phases.
**Requirements**: TBD (promoted from backlog 999.16)
**Success Criteria** (what must be TRUE):
1. `pnpm lint` runs ESLint across both `apps/api` and `apps/pwa` and exits non-zero on an introduced violation (verified by a deliberate test violation), where today it exits 0 with no linter present.
2. The CI `fast-checks` lint step blocks a PR to main on lint violations — the gate can now fail.
3. The first real run's existing violations are resolved (fix / warn / disable decided per rule) so the baseline gate ends green.
**Pitfalls this phase owns**:
- Pick a baseline ruleset (recommended vs strict-type-checked) deliberately — strict surfaces a large upfront cleanup; decide blocking vs advisory before flipping the gate to blocking.
- `typecheck`/tsc already gates type errors; ESLint should not duplicate type-checking rules unnecessarily.
**Plans**: 3 plans — all complete (scope expanded during planning to add a Prettier `format:check` gate)
- [x] 13-01-PLAN.md — Install ESLint/Prettier deps + flat config + package scripts + prove the gate fails (SC-1)
- [x] 13-02-PLAN.md — Fix all first-run lint violations across both apps, green `pnpm lint` (D-13-05/06)
- [x] 13-03-PLAN.md — Prettier reformat (isolated commit) + CI format:check step + green baseline (SC-2/SC-3)
**UI hint**: no
### Phase 14: Desktop E2E Coverage
**Goal**: The Phase 8 regression gate exercises the desktop layout and flows, not just mobile. A `desktop` Playwright project (`devices['Desktop Chrome']`, no touch, wide viewport) is added to `apps/pwa/playwright.config.ts`, and the existing mobile-authored specs are reviewed/adjusted (or appropriately skipped) so `pnpm test:e2e` passes on a no-touch desktop viewport as well as the `iphone`/`pixel` profiles.
**Mode:** standard
**Depends on**: Phase 7 (the harness it extends) and Phase 8 (CI runs `pnpm test:e2e` and picks up the new project automatically — no CI plumbing change needed beyond any desktop-profile runtime/wait). Independent of Phases 913.
**Requirements**: TBD (promoted from backlog 999.15)
**Success Criteria** (what must be TRUE):
1. A `desktop` project exists in `playwright.config.ts` (Desktop Chrome, wide viewport, no `hasTouch`).
2. The existing e2e specs pass (or are explicitly, justifiably skipped) on the desktop profile — touch-gesture / mobile-drawer / mobile-only-layout assumptions are handled.
3. `pnpm test:e2e` in CI runs and gates on both mobile and desktop profiles (blocking-vs-advisory for desktop decided when planned).
**Pitfalls this phase owns**:
- The real work is the spec-compat pass, not CI plumbing — Phase 8 reused the Phase 7 harness unchanged, so the config addition is small but specs authored for touch/mobile need per-spec review.
- Desktop WebKit is optional — the Apple member is already covered on mobile Safari via `iphone`; Desktop Chrome is likely sufficient for a shared/wall browser.
**Plans**: 1 plan
Plans:
- [x] 14-01-PLAN.md — Add the `desktop` Playwright project, desktop-skip the two mobile-only layout assertions (+ D-04 parity), update spec/README docs, and prove `pnpm test:e2e` is green on iphone + pixel + desktop with a blocking CI gate.
**UI hint**: no
### Phase 15: Doc-Only CI Skip
**Goal**: Doc-only PRs to `main` merge without running the slow `harness` (Playwright e2e + dev-stack bring-up, ~5 min) and `api` (MariaDB integration) jobs, while `fast-checks` (Prettier `format:check` + markdown linting) still runs — and branch protection never deadlocks on a required check that never reports. Docs get a *fast but real* gate: format + lint, none of the slow code jobs.
**Mode:** standard
**Depends on**: Phase 8 (the `.gitea/workflows/ci.yml` it modifies) and Phase 13 (the `fast-checks` job + `format:check` step this extends). Independent of Phases 912.
**Requirements**: TBD (promoted from backlog 999.17)
**Success Criteria** (what must be TRUE):
1. A doc-only PR to `main` (only `docs/` or `*.md` changed) skips the `api` and `harness` jobs but still runs `fast-checks`.
2. A PR touching code runs `fast-checks`, `api`, and `harness` as today; a failure in any blocks the merge.
3. Branch protection requires `CI / fast-checks` + an always-running `CI / gate` aggregate (passes when each heavy job is `success` OR `skipped`) — the direct `api`/`harness` requirements are dropped so a skipped heavy job never deadlocks the merge.
4. `fast-checks` runs a markdown linter (markdownlint-cli2) over `**/*.md`; an introduced markdown-lint violation fails the gate, and the existing markdown baseline passes (violations fixed or rules configured) so the gate starts green.
**Pitfalls this phase owns**:
- **Required-check deadlock** — never path-filter a required context directly; a required job that never reports blocks the PR forever. The always-running `gate` job (`if: always()`, passes on `success`/`skipped`) is the only safe gating surface.
- **Gitea skipped-status quirk** — Gitea may not emit a commit-status for a `skipped` job; rely on the always-running `gate`, not on marking `api`/`harness` skipped-but-required.
- **Prettier vs markdownlint overlap** — Prettier already owns markdown *formatting*; scope markdownlint to *content* rules (heading increments, no broken/duplicate link refs, list/code-fence conventions) and disable its purely-stylistic rules that fight Prettier (e.g. line-length, list-indent), so the two don't conflict on the same `.md`.
- **`.planning/*` is push-direct, never linted** — planning bookkeeping bypasses CI via the Unprotected file pattern, so markdownlint never sees it; scope the lint glob to `docs/` + repo-root/app `*.md` and exclude `.planning/**` (and any generated markdown) to avoid a baseline cleanup of churny bookkeeping files.
**Plans**: 3 plans (3 waves)
Plans:
**Wave 1**
- [x] 15-01-PLAN.md — markdownlint-cli2 + `.markdownlint-cli2.jsonc` + `md:lint` script + fast-checks step + fix 13 baseline violations (SC-4)
**Wave 2** *(blocked on Wave 1 completion)*
- [x] 15-02-PLAN.md — ci.yml: `changes` (dorny/paths-filter@v4) + conditional api/harness + always-running `gate` aggregate (SC-1/SC-2, SC-3 YAML)
**Wave 3** *(blocked on Wave 2 completion)*
- [x] 15-03-PLAN.md — operator branch-protection checkpoint (require `CI / fast-checks` + `CI / gate`, drop api/harness) + publish.yml comment update (SC-3)
**UI hint**: no
### Phase 16: CI Dependency Audit, Security Checks & Image Hygiene
**Goal**: The CI pipeline surfaces outdated and vulnerable dependencies, runs a baseline of additional security checks, and enforces a clean dev↔prod boundary in the images it publishes — so the two-person household app doesn't silently rot on stale/CVE-bearing packages, and no dev-only affordance, secret, or family-specific data ever ships in a production image. Extends the existing Gitea CI (Phase 8) workflow with dependency/security/image-hygiene gates rather than standing up a separate pipeline. **Absorbs backlog 999.17 (dev/prod image boundary).**
**Mode:** standard
**Depends on**: Phase 8 (Gitea CI — adds steps to the existing workflow + publish job; no admin-chain dependency). Independent of Phases 1012.
**Requirements**: SEC-01 (secret scanning), SEC-02 (static security lint), DEP-01 (vuln audit gate), DEP-02 (outdated advisory), IMG-01 (NODE_ENV+boot-guard), IMG-02 (.dockerignore), IMG-03 (publish image-hygiene assertions), CI-03 (security job + gate wiring)
**Candidate scope (to be sharpened in `/gsd-discuss-phase 16`):**
- **Outdated dependencies:** a CI step that reports dependencies behind their latest (e.g. `pnpm outdated -r`), surfaced on the PR. Decide gating vs advisory, and how to handle the pinned-version table in CLAUDE.md (the stack pins exact versions — "outdated" must not fight intentional pins).
- **Vulnerability audit:** `pnpm audit` (or equivalent) against the lockfile, failing on a chosen severity threshold (e.g. high/critical). Decide the threshold and an allowlist/waiver mechanism for unfixable transitive advisories.
- **Additional security checks (user is open to these — pick a sensible baseline, avoid over-build):** candidates — secret scanning on the diff (gitleaks/trufflehog), a CodeQL/`eslint-plugin-security` static pass, dependency-review on PRs, Dockerfile/image scan (e.g. trivy) of the published image.
- **Dev/prod boundary definition & enforcement (from 999.17):** the `DEV_AUTH_BYPASS` concept (and any dev-only affordance) must be provably confined to local dev — never to production, never baked into published images. Today the guard is runtime-only (`NODE_ENV !== 'production' && DEV_AUTH_BYPASS === 'true'` in `apps/api/src/auth/devBypass.ts`); add (a) explicit documentation of what "dev image" vs "shipped image" means, and (b) build-time / boot-time enforcement (a `production` image refuses to boot — or the build aborts — if dev-bypass is enabled) as defense-in-depth.
- **No data/secrets in published images (from 999.17):** audit the Dockerfile(s) + the Phase 8 publish job (`publish.yml`) to confirm `.env`, dev seed SQL, local DB dumps, encryption keys, OIDC secrets, the `DEV_USER` seed, and family-specific fixtures are `.dockerignore`d and never `COPY`'d. Add a CI assertion that fails the publish if a dev-bypass code path is active, a forbidden env/secret is present, or personal/seed data is staged into the image context. The dev-stack seed path (`DEV_USER` id 1 + sample calendar/list data) must be unreachable from the production image/compose.
- **Noise control:** these gates are notorious for flaky/advisory-churn failures; decide blocking-on-merge vs warn-only per check, and where results surface (PR annotation vs job log), mirroring Phase 15's gate-aggregation approach.
**Boundary:** Extends the existing Gitea CI workflow + publish job; does not remove dev-bypass (still needed for local verification and the Phase 7/8 harness) and does not add a new external service or a runtime dependency to the app. Automated dependency *upgrades* (e.g. Renovate/Dependabot bots) are a separate concern — decide in discuss whether they're in scope or deferred.
**Plans**: 6 plans in 2 waves
Plans:
**Wave 1**
- [x] 16-01-PLAN.md — Image-hygiene runtime: bake NODE_ENV=production + boot-time refuse-to-boot guard (IMG-01)
- [x] 16-02-PLAN.md — pnpm audit gate + waiver allowlist + advisory-only tiered outdated report (DEP-01, DEP-02)
- [x] 16-03-PLAN.md — Fold eslint-plugin-security into the lint gate as blocking errors + triage (SEC-02)
- [x] 16-04-PLAN.md — gitleaks config + full-history baseline + .dockerignore (SEC-01, IMG-02)
**Wave 2** *(blocked on Wave 1 completion)*
- [x] 16-05-PLAN.md — Add the security job to ci.yml (gitleaks always; audit/outdated code-gated) + gate wiring (CI-03)
- [x] 16-06-PLAN.md — publish.yml static image-hygiene assertion + boot-smoke before push (IMG-03)
**UI hint**: no
### Phase 17: UI Optimization & Polish
**Goal**: A visual-identity & polish pass for the PWA spanning three workstreams: **(A) phone-layout polish** so the phone (≤767px) layout has no fixed-chrome overlap and small-viewport spacing reads cleanly — starting with the long-standing BottomTabBar overlap that hides the New Event FAB and the colour legend, plus a small-viewport sweep; **(B) branding assets** — generate a real FamilySync logo into the existing `BrandSlot` seam (`apps/pwa/src/components/BrandSlot.tsx`) and a complete favicon/PWA-icon set replacing the placeholder stubs in `apps/pwa/public/`; **(C) theme-token groundwork** — restructure `apps/pwa/src/styles/tokens.css` into a themeable semantic-token layer (swappable by `data-theme`/`prefers-color-scheme`), light staying the only shipped theme, so a future dark theme is cheap.
**Mode:** standard
**Depends on**: Nothing structural (CSS/layout + assets only). Best sequenced after Phase 10 merges (the BottomTabBar gained an Admin tab and the new SetupBanner adds top pressure on phone), but otherwise independent of the admin chain.
**Requirements**: No REQ-IDs — decisions D-01…D-10 (17-CONTEXT.md) stand in. Coverage: D-01/D-02 (A, phone overlap+guard) → 17-03; D-03/D-04 (B, assets) → 17-02; D-04/D-05 (B, wiring) → 17-04; D-06 (C, token groundwork) → 17-01; D-07/D-09 (D, logout+sheet centering) → 17-05; D-08/D-09/D-10 (D, admin toasts+reset-sheet+two-tab nav) → 17-06.
**Scope boundary (set in `/gsd-discuss-phase 17`, 2026-06-17):** Workstream C ships token groundwork **only** — no dark palette, no theme toggle (→ backlog **999.20**). A broader "modern styling" visual refresh is **out of scope** and routed to backlog **999.21** (future milestone). Keep Phase 17 a focused polish + branding + groundwork pass, not a redesign.
**Seed defect — phone-layout bottom-bar overlap (documented 2026-06-13; long-standing, NOT introduced by Phase 10 — the BottomTabBar dates to Phase 04):**
At ≤767px (`window.matchMedia('(max-width: 767px)')` in `apps/pwa/src/App.tsx`) the layout switches to a 48px top AppNav + a `position: fixed` BottomTabBar (`height: calc(56px + env(safe-area-inset-bottom))`, z-index 200; `apps/pwa/src/components/BottomTabBar.tsx`) + a floating "New Event" FAB (`position: fixed; bottom: var(--space-6); right: var(--space-6)`; `apps/pwa/src/components/CalendarShell.tsx`). Two problems:
1. **FAB sits inside the bar** — the FAB's `bottom` offset (~`--space-6`, ≈24px) is smaller than the bar's 56px height, so the round New Event button overlaps the bottom tab bar (lands on the Admin tab).
2. **Content occluded** — the content area (`contentStyle` in `App.tsx`) reserves no `padding-bottom` for the fixed bar, so the bottom of the calendar and the colour-legend chips (e.g. the "Dev User" / member legend) slide under the bar and are partially hidden.
**Fix sketch (CSS-only, no behaviour change):** on phone, lift the FAB to `bottom: calc(56px + env(safe-area-inset-bottom, 0px) + var(--space-6))` and add a matching `padding-bottom: calc(56px + env(safe-area-inset-bottom, 0px))` to the phone content/scroll area (or reduce the `100dvh` column by the bar height). Verify across the `iphone`/`pixel`/`desktop` Playwright profiles and a real narrow Chromium via playwright-cli.
**Evidence:** reproduced 2026-06-13 with playwright-cli at 390×844 (FAB over the Admin tab; "Dev User" legend clipped) vs 1280×800 (desktop sidebar, no overlap). Full detail in todo `2026-06-13-pwa-phone-bottombar-overlap.md`.
**Candidate scope (to sharpen in `/gsd-discuss-phase 17`):** the seed defect above, plus a sweep for other small-viewport spacing / tap-target / overlap issues (the Phase 7 `layout.spec.ts` tap-target/overflow assertions are a ready checklist) and any phone/desktop visual inconsistencies noticed in use. Keep it a focused polish pass, not a redesign.
**Plans**: 6/6 plans complete
Plans:
**Wave 1**
- [x] 17-01-PLAN.md — C: tokens.css themeable-layer groundwork + --bottom-chrome-h token (D-06) [Wave 1]
- [x] 17-02-PLAN.md — B: generate logo + full icon set, operator approval checkpoint (D-03, D-04) [Wave 1]
**Wave 2** *(blocked on Wave 1 completion)*
- [x] 17-03-PLAN.md — A: phone FAB/BottomTabBar overlap fix + sweep + CI overlap assertion (D-01, D-02) [Wave 2, dep 01]
- [x] 17-04-PLAN.md — B: wire logo into BrandSlot + index.html favicons + manifest maskable + accent (D-04, D-05) [Wave 2, dep 01,02]
- [x] 17-05-PLAN.md — D: logout control + sheet desktop-centering (SettingsSheet/CredentialSheet) (D-07, D-09) [Wave 2, dep 01]
- [x] 17-06-PLAN.md — D: admin success toasts + two-tab ARIA nav + reset-sheet centering + admin.spec.ts (D-08, D-09, D-10) [Wave 2, dep 01]
**UI hint**: yes
<details>
<summary>✅ v1.1 Operability & Polish (Phases 720) — SHIPPED 2026-06-18</summary>
- [x] Phase 7: Mobile Test Harness (4/4 plans) — completed 2026-06-11
- [x] Phase 8: Gitea CI (4/4 plans) — completed 2026-06-11
- [x] Phase 9: Faster Write-Back (2/2 plans) — completed 2026-06-12
- [x] Phase 10: Admin Role & Settings (4/4 plans) — completed 2026-06-13
- [x] Phase 11: Per-Event Reminders (5/5 plans) — completed 2026-06-14
- [x] Phase 12: Initial Setup Wizard (7/7 plans) — completed 2026-06-16
- [x] Phase 13: Real Lint Gate (ESLint) (3/3 plans) — completed 2026-06-12
- [x] Phase 14: Desktop E2E Coverage (1/1 plans) — completed 2026-06-12
- [x] Phase 15: Doc-Only CI Skip + Markdown Lint (3/3 plans) — completed 2026-06-12
- [x] Phase 16: CI Dependency Audit, Security & Image Hygiene (6/6 plans) — completed 2026-06-13
- [x] Phase 17: UI Optimization & Polish (6/6 plans) — completed 2026-06-18
- [x] Phase 18: Auto Timezone Detection (4/4 plans) — completed 2026-06-14
- [x] Phase 19: Local Auth (No-OIDC Mode) (5/5 plans) — completed 2026-06-17
- [x] Phase 20: Admin Member Editor & Form Declutter (3/3 plans) — completed 2026-06-18
Full phase detail archived in [`milestones/v1.1-ROADMAP.md`](milestones/v1.1-ROADMAP.md).
</details>
## Progress
| Phase | Milestone | Plans Complete | Status | Completed |
| ----- | --------- | -------------- | -------- | ---------- |
| 1. Foundation + Broker Spike | v1.0 | 4/4 | Complete | 2026-06-04 |
| 2. Calendar Display | v1.0 | 5/5 | Complete | 2026-06-05 |
| 3. Event Write-Back + PWA Install| v1.0 | 12/12 | Complete | 2026-06-07 |
| 4. Shared Lists + Live Sync | v1.0 | 7/7 | Complete | 2026-06-09 |
| 5. Web Push Notifications | v1.0 | 8/8 | Complete | 2026-06-10 |
| 6. UX Polish | v1.0 | 6/6 | Complete | 2026-06-10 |
| 7. Mobile Test Harness | v1.1 | 4/4 | Complete | 2026-06-11 |
| 8. Gitea CI | v1.1 | 4/4 | Complete | 2026-06-11 |
| 9. Faster Write-Back | v1.1 | 2/2 | Complete | 2026-06-12 |
| 10. Admin Role & Settings | v1.1 | 4/4 | Complete | 2026-06-13 |
| 11. Per-Event Reminders | v1.1 | 5/5 | Complete | 2026-06-14 |
| 12. Initial Setup Wizard | v1.1 | 7/7 | Complete | 2026-06-16 |
| 13. Real Lint Gate (ESLint) | v1.1 | 3/3 | Complete | 2026-06-12 |
| 14. Desktop E2E Coverage | v1.1 | 1/1 | Complete | 2026-06-12 |
| 15. Doc-Only CI Skip + MD Lint | v1.1 | 3/3 | Complete | 2026-06-12 |
| 16. CI Dep Audit, Sec & Img Hyg | v1.1 | 6/6 | Complete | 2026-06-13 |
| 17. UI Optimization & Polish | v1.1 | 6/6 | Complete | 2026-06-18 |
| 18. Auto Timezone Detection | v1.1 | 4/4 | Complete | 2026-06-14 |
| 19. Local Auth (No-OIDC Mode) | v1.1 | 5/5 | Complete | 2026-06-17 |
| 1. Foundation + Broker Spike | v1.0 | 4/4 | Complete | 2026-06-04 |
| 2. Calendar Display | v1.0 | 5/5 | Complete | 2026-06-05 |
| 3. Event Write-Back + PWA Install | v1.0 | 12/12 | Complete | 2026-06-07 |
| 4. Shared Lists + Live Sync | v1.0 | 7/7 | Complete | 2026-06-09 |
| 5. Web Push Notifications | v1.0 | 8/8 | Complete | 2026-06-10 |
| 6. UX Polish | v1.0 | 6/6 | Complete | 2026-06-10 |
| 7. Mobile Test Harness | v1.1 | 4/4 | Complete | 2026-06-11 |
| 8. Gitea CI | v1.1 | 4/4 | Complete | 2026-06-11 |
| 9. Faster Write-Back | v1.1 | 2/2 | Complete | 2026-06-12 |
| 10. Admin Role & Settings | v1.1 | 4/4 | Complete | 2026-06-13 |
| 11. Per-Event Reminders | v1.1 | 5/5 | Complete | 2026-06-14 |
| 12. Initial Setup Wizard | v1.1 | 7/7 | Complete | 2026-06-16 |
| 13. Real Lint Gate (ESLint) | v1.1 | 3/3 | Complete | 2026-06-12 |
| 14. Desktop E2E Coverage | v1.1 | 1/1 | Complete | 2026-06-12 |
| 15. Doc-Only CI Skip + MD Lint | v1.1 | 3/3 | Complete | 2026-06-12 |
| 16. CI Dep Audit, Sec & Img Hyg | v1.1 | 6/6 | Complete | 2026-06-13 |
| 17. UI Optimization & Polish | v1.1 | 6/6 | Complete | 2026-06-18 |
| 18. Auto Timezone Detection | v1.1 | 4/4 | Complete | 2026-06-14 |
| 19. Local Auth (No-OIDC Mode) | v1.1 | 5/5 | Complete | 2026-06-17 |
| 20. Admin Member Editor & Declutter | v1.1 | 3/3 | Complete | 2026-06-18 |
## Backlog
@@ -695,60 +311,3 @@ Plans:
- [ ] TBD (promote with /gsd-review-backlog when ready)
### Phase 18: Auto timezone detection and ability to change timezone
**Goal:** Make the household timezone an explicit, stored, user-changeable setting — auto-detected from the browser at first run, changeable from the role-gated /admin Settings — and route the server-side all-day "9 AM local" reminder computation through it (replacing the implicit `process.env.TZ` fallback), without touching the already-correct browser-local display/timed-write path.
**Requirements**: TBD (decision contract D-01..D-07 from 18-CONTEXT.md)
**Depends on:** Phase 10 (admin role + `/admin` Settings + `app_config`); Phase 11 (all-day reminder computation this rewires). Independent of Phase 17. Phase 12 (setup wizard) not required — seeding is self-contained.
**Plans:** 4/4 plans complete
Plans:
**Wave 1**
- [x] 18-01-PLAN.md — TDD: getHouseholdTimezone(db) accessor + isValidIanaTimezone (D-05/D-06)
**Wave 2** *(blocked on Wave 1 completion)*
- [x] 18-02-PLAN.md — TDD: admin GET/PUT/seed timezone endpoints on adminRouter, requireAdmin + IANA validation + no-overwrite seed (D-01/D-02/D-03/D-04)
- [x] 18-03-PLAN.md — TDD: route all-day reminder TZ at reminderScheduler:247 + outboxWorker:501,607 through the accessor (D-05/D-06/D-07)
**Wave 3** *(blocked on Wave 2 completion)*
- [x] 18-04-PLAN.md — PWA Timezone section in /admin Settings (searchable IANA picker + detected-zone seed) + client fns (D-02/D-04)
**UI hint**: yes
### Phase 19: Local Auth (No-OIDC Mode)
**Goal:** Let an operator run FamilySync entirely on **local DB users with no OIDC** — username/password accounts and a local login flow that coexists with the Authelia OIDC path — and **optionally wire OIDC in later** by claiming/linking an existing local user to an OIDC identity. Removes the hard dependency on a deployed Authelia for small/solo self-hosters.
**Mode:** standard
**Depends on:** Phase 12 (Initial Setup Wizard) — builds directly on the pre-OIDC **local-user foundation** introduced there: nullable `users.oidc_iss`/`oidc_sub` + the claimed/pending marker, and the first-login-claims merge. Phase 19 generalizes that single bootstrap local user into a full local-account model + login.
**Requirements**: AUTH-LOCAL-01..AUTH-LOCAL-20 (derived during planning 2026-06-17) — local_credentials schema (01), scrypt hash/verify (02), login route (03), localAuthMiddleware (04), auth-mode endpoint (05), logout (06), admin create-member (07), admin reset (08), self-change (09), OIDC-link (10), break-glass CLI (11), LoginPage (12), admin UI (13), settings UI (14), routing gate (15), dev-bypass/harness rework (16), hasLocalCredential (17), de-Authelia copy (18), rate-limit/lockout (19), auth unit tests (20). Plus `LOCAL_SESSION_SECRET` env + boot assertion (D-05).
**Plans:** 5/5 plans complete
**Provenance:** Deferred from the Phase 12 discussion (2026-06-15) — see `.planning/phases/12-initial-setup-wizard/12-CONTEXT.md` §Deferred Ideas. The operator runs FamilySync this way themselves and wants no-OIDC operation as a first-class mode.
**Open questions for discuss/spec:**
- Password hashing/storage choice (e.g. argon2id/bcrypt) and how it sits alongside the env-only secret kernel from Phase 12.
- How local login coexists with `oidcAuthMiddleware` ordering in `apps/api/src/index.ts` (route-level auth strategy selection vs. a mode flag in `app_config`).
- The OIDC-link flow: claiming an existing local user into an `oidc_iss+oidc_sub` identity without violating the D-10 "identity is OIDC, never email" rule.
- Whether "local mode vs OIDC mode" is a deploy-time switch or both can be live simultaneously.
Plans:
**Wave 1**
- [x] 19-01-PLAN.md — Foundation (TDD): local_credentials schema + 0003 migration, scrypt hash/verify, local-session JWT helpers, LOCAL_SESSION_SECRET boot guard + generate-secrets, .dockerignore scripts exclusion (AUTH-LOCAL-01/02)
**Wave 2** *(blocked on Wave 1)*
- [x] 19-02-PLAN.md — Backend account mgmt (TDD): admin create/reset member, self-change password, hasLocalCredential, linkOidcToUser helper + /api/me/link-oidc (AUTH-LOCAL-07/08/09/10/17)
**Wave 3** *(blocked on Wave 2)*
- [x] 19-03-PLAN.md — Middleware + routes + wiring (TDD): localAuthMiddleware, /api/auth/mode, login (rate-limit/lockout) + logout, index.ts mount + OIDC-guard skip + /callback link branch, de-Authelia comments (AUTH-LOCAL-03/04/05/06/18/19/20)
**Wave 4** *(blocked on Wave 3; 04 + 05 parallel)*
- [x] 19-04-PLAN.md — PWA: LoginPage + BrandSlot + App.tsx gate + client.ts + AdminPage + SettingsSheet (AUTH-LOCAL-12/13/14/15)
- [x] 19-05-PLAN.md — Dev-bypass Option C + break-glass CLI + harness/CI rework + login.spec.ts (AUTH-LOCAL-11/16)
+27 -23
View File
@@ -2,36 +2,36 @@
gsd_state_version: 1.0
milestone: v1.1
milestone_name: Operability & Polish
current_phase: 18
current_phase_name: Auto timezone detection and ability to change timezone
status: executing
stopped_at: Phase 17 UI-SPEC approved
last_updated: "2026-06-18T17:11:30.158Z"
current_phase: null
status: Awaiting next milestone
stopped_at: v1.1 milestone shipped & archived
last_updated: "2026-06-19T01:58:30.566Z"
last_activity: 2026-06-18
last_activity_desc: Phase 17 complete, transitioned to Phase 18
last_activity_desc: Milestone v1.1 completed and archived
progress:
total_phases: 26
completed_phases: 12
total_plans: 55
completed_plans: 54
percent: 46
total_phases: 20
completed_phases: 20
total_plans: 57
completed_plans: 57
percent: 100
current_phase_name: Awaiting next milestone
---
# Project State
## Project Reference
See: .planning/PROJECT.md (updated 2026-06-16)
See: .planning/PROJECT.md (updated 2026-06-18 after v1.1 milestone)
**Core value:** One color-coded family calendar (shared + personal) and shared lists from a single low-friction PWA — cross-ecosystem, no app store
**Current focus:** Phase 17 — ui-optimization-polish
**Current focus:** Planning next milestone — run `/gsd-new-milestone`
## Current Position
Phase: 18 — Auto timezone detection and ability to change timezone
Plan: Not started
Status: Executing Phase 17
Last activity: 2026-06-18Phase 17 complete, transitioned to Phase 18
Phase: Milestone v1.1 complete
Plan:
Status: Awaiting next milestone
Last activity: 2026-06-19Milestone v1.1 completed and archived
### ✅ Resolved Checkpoint — Phase 15 Plan 15-03 Task 2 (human-action)
@@ -41,7 +41,7 @@ Done 2026-06-12. Gitea branch protection on `main` now requires EXACTLY `CI / fa
**Velocity:**
- Total plans completed: 66
- Total plans completed: 69
- Average duration: -
- Total execution time: 0 hours
@@ -62,6 +62,7 @@ Done 2026-06-12. Gitea branch protection on `main` now requires EXACTLY `CI / fa
| 12 | 7 | - | - |
| 19 | 5 | - | - |
| 17 | 6 | - | - |
| 20 | 3 | - | - |
**Recent Trend:**
@@ -121,6 +122,7 @@ _Updated after each plan completion_
| Phase 12 P02 | 15 | 3 tasks | 6 files |
| Phase 12 P03 | 8 | 1 tasks | 2 files |
| Phase 12 P06 | 8 | 2 tasks tasks | 3 files files |
| Phase 20 P03 | 10 | 3 tasks | 3 files |
## Accumulated Context
@@ -216,6 +218,7 @@ Recent decisions affecting current work:
- **Phase 16 added (2026-06-12, /gsd-phase):** CI Dependency Audit, Security Checks & Image Hygiene — extend the Phase 8 Gitea CI workflow with outdated-dependency reporting (`pnpm outdated`), a vulnerability audit (`pnpm audit` at a chosen severity), and a baseline of additional security checks (secret scan / image scan). User requested a 16 integer phase (not a decimal insert) — they've been running independent/CI phases ahead of the admin chain. **Depends on Phase 8; independent of the admin chain (1012).** Scope still needs definition — run /gsd-discuss-phase 16. Milestone window now Phases 716.
- **Backlog 999.17 folded into Phase 16 + removed (2026-06-12, /gsd-phase):** the dev/prod image-boundary item (confine `DEV_AUTH_BYPASS` to dev via build/boot-time enforcement; ensure no `.env`/secrets/encryption keys/`DEV_USER` seed/family data ship in published images; CI assertion in the publish job) was pulled into Phase 16 — shared CI surface and overlapping secret/image scanning made a separate phase redundant. The 999.17 backlog entry + its phase dir were **deleted** (not retained-for-history) since the scope now lives in an active phase; this also clears the recycled-number collision with Phase 15's historical "promoted from 999.17" provenance (the markdown-lint item that became Phase 15 had reused 999.17 first).
- **Phase 18 added (2026-06-13, /gsd-phase):** Auto timezone detection and ability to change timezone — let the app auto-detect the household timezone and allow changing it. User invoked `/gsd-phase --insert 18` but Phase 18 didn't exist (17 was the last integer phase), so after confirmation it was added as an integer phase at the end of the milestone, not a decimal insert. Motivated by the Phase 11 all-day-reminder dependency on a correct server `TZ` (all-day reminders fire at 9 AM local, computed from `process.env.TZ`). Scope still needs definition — run /gsd-discuss-phase 18. Milestone window now Phases 718.
- **Phase 20 added (2026-06-18, /gsd-phase):** Admin Member Editor & Form Declutter — replace the per-member-row action buttons (Rotate/Add credential + Reset password) with a single edit affordance (click member name or an edit button) opening a member-detail editor for all of a member's details (display name, local-login password, Fastmail/CalDAV app password) with clear non-jargon labels that retire "Rotate"; and collapse the "Add member" form behind a single trigger by default. Seeded by a UX gripe during Phase 17 verification that "Rotate" for the app password is unintuitive. Client-side AdminPage + CredentialSheet rework over existing `/api/admin` endpoints; no new authorization boundary. Scope still needs definition — run /gsd-discuss-phase 20. Milestone window now Phases 720.
### Pending Todos
@@ -254,6 +257,8 @@ Recent decisions affecting current work:
| 260613-dmw | Exclude `.gitea/**` from the CI `changes` `code` paths-filter so workflow-only PRs skip the heavy api/harness jobs (treated like docs) while fast-checks + gate still run. Single `- '!.gitea/**'` negation appended after the yml/yaml globs (index 11 vs 5). Rides along on the Phase 16 branch / PR #15. | 2026-06-13 | 2d329a9 | | [260613-dmw-exclude-gitea-workflow-config-changes-fr](./quick/260613-dmw-exclude-gitea-workflow-config-changes-fr/) |
| 260613-fp9 | `.gitea`/`.planning`-only pushes to main no longer trigger the Docker publish — added `paths-ignore: ['.gitea/**', '.planning/**']` under `on.push` in `.gitea/workflows/publish.yml` (skips only when EVERY changed file matches; mixed code+docs pushes still publish). `.dockerignore` already excludes `.planning` so the image is byte-identical. Done in isolated worktree (phase-10 agent held main tree). | 2026-06-13 | cd5a88c | | [260613-fp9-gitea-and-planning-pushes-should-not-tri](./quick/260613-fp9-gitea-and-planning-pushes-should-not-tri/) |
| 260613-ndv | Isolate local apps/api integration tests to a dedicated `familysync_test` DB so test runs stop polluting the dev `familysync` DB. New CI-gated vitest globalSetup root-provisions (CREATE DATABASE + GRANT) + migrates + truncate-resets `familysync_test` each run; `vitest.config.ts` forces `DB_NAME=familysync_test` for local workers (no-op under CI, so CI's `familysync` service DB + db:migrate are untouched). Verified: dev `familysync` users stays 3 across a run, `familysync_test` resets (186→93, not doubled), 244/244 tests pass (flaky list_shares timeout gone), typecheck 0. Branch off main. | 2026-06-13 | 07d5161 | Verified | [260613-ndv-wire-apps-api-integration-tests-to-a-ded](./quick/260613-ndv-wire-apps-api-integration-tests-to-a-ded/) |
| 260618-smr | Remove unused Redis service and all references — Redis confirmed unused at runtime (no ioredis/redis client import, no `REDIS_*` env, not a dependency in any package.json). Dropped the `redis` service from both compose files and cleaned all references in CLAUDE.md, README.md, and docs/* + e2e config. Kept the in-memory-vs-Redis design-rationale comments (D-12/D-18) in listEmitter/reminderScheduler/linkNonceStore/localAuth. `docker compose config` parses clean (0 redis); `format:check` green. Branch off main. | 2026-06-18 | 0b42666 | Verified | [260618-smr-remove-unused-redis-service-and-referenc](./quick/260618-smr-remove-unused-redis-service-and-referenc/) |
| 260618-tg2 | Persistent CI dependency caches — point all 4 CI `pnpm install` steps at a host-mounted `/pnpm-store` (`--store-dir /pnpm-store --prefer-offline`) and persist Playwright browsers via `PLAYWRIGHT_BROWSERS_PATH=/ms-playwright` on the harness job; added BuildKit `--mount=type=cache` to all 3 Dockerfile install stages + `DOCKER_BUILDKIT=1` on the publish build. Avoids `actions/cache` (D-PROBE-04 timeout). In-repo only — requires act_runner `config.yaml` `container.options` host mounts (manual host change). Verdaccio deferred. Branch off main. | 2026-06-18 | 6e93e24 | Verified | [260618-tg2-persistent-ci-dependency-caches-pnpm-sto](./quick/260618-tg2-persistent-ci-dependency-caches-pnpm-sto/) |
## Deferred Items
@@ -273,11 +278,10 @@ Recent decisions affecting current work:
## Session Continuity
Last session: 2026-06-18T15:00:30.986Z
Stopped at: Phase 17 UI-SPEC approved
Resume file: .planning/phases/17-ui-optimization-polish/17-UI-SPEC.md
Last session: 2026-06-18T21:40:44.704Z
Stopped at: Phase 20 UI-SPEC approved
Resume file: .planning/phases/20-admin-member-editor-form-declutter/20-UI-SPEC.md
## Operator Next Steps
- **Phase 8 is complete.** CI pipeline is fully operational on the self-hosted Gitea runner.
- Next: `/gsd-plan-phase 9` (Faster Write-Back — fully independent, lowest risk) or `/gsd-plan-phase 10` (Admin Role & Settings — carries the v1.1 DB migration that Phases 11 & 12 depend on). These can run in parallel once planned.
- Start the next milestone with /gsd-new-milestone
@@ -1,3 +1,12 @@
# Requirements Archive: v1.1 Operability & Polish
**Archived:** 2026-06-19
**Status:** SHIPPED
For current requirements, see `.planning/REQUIREMENTS.md`.
---
# Requirements: FamilySync — v1.1 "Operability & Polish"
**Defined:** 2026-06-10
+772
View File
@@ -0,0 +1,772 @@
# Roadmap: FamilySync
## Milestones
- ✅ **v1.0 MVP** — Phases 16 (shipped 2026-06-10) — see [`milestones/v1.0-ROADMAP.md`](milestones/v1.0-ROADMAP.md)
- ✅ **v1.1 Operability & Polish** — Phases 720 (shipped 2026-06-18) — mobile test harness, Gitea CI (runs the harness), faster write-back, in-app admin, per-event reminders, guided setup, real lint gate, desktop e2e, doc-only CI skip + markdown lint, CI dependency audit + security checks + image hygiene, UI optimization & polish, auto timezone detection, local auth (no-OIDC mode), admin member editor & declutter
## Phases
<details>
<summary>✅ v1.0 MVP (Phases 16) — SHIPPED 2026-06-10</summary>
- [x] Phase 1: Foundation + Broker Spike (4/4 plans) — completed 2026-06-04
- [x] Phase 2: Calendar Display (5/5 plans) — completed 2026-06-05
- [x] Phase 3: Event Write-Back + PWA Install (12/12 plans) — completed 2026-06-07
- [x] Phase 4: Shared Lists + Live Sync (7/7 plans) — completed 2026-06-09
- [x] Phase 5: Web Push Notifications (8/8 plans) — completed 2026-06-10
- [x] Phase 6: UX Polish (6/6 plans) — completed 2026-06-10
Full phase detail archived in [`milestones/v1.0-ROADMAP.md`](milestones/v1.0-ROADMAP.md).
</details>
### ✅ v1.1 Operability & Polish (Phases 720) — SHIPPED 2026-06-18
Make FamilySync configurable, administrable, and maintainable for real multi-member use — without hand-editing env files or the database. The new critical path runs **mobile test harness → Gitea CI** (CI consumes the harness specs for UI regression), and the **admin role → reminders / setup wizard** chain (a single `/api/admin` + `/api/setup` route surface carrying the v1.1 DB migration). Faster write-back is a fully independent track.
- [x] **Phase 7: Mobile Test Harness** - Mobile-emulated, authenticated PWA browser harness so the assistant (and CI) can catch mobile-only defects (completed 2026-06-11)
- [x] **Phase 8: Gitea CI** - Full regression on PR to main (lint/typecheck/unit/API-integration vs a MariaDB service container **+ the Phase 7 mobile harness as a UI-regression step against a CI-hosted dev stack**) + Docker image publish on merge (completed 2026-06-11)
- [x] **Phase 9: Faster Write-Back** - Event-driven outbox drain so edits land in ~1-2s instead of ~15s, preserving every outbox durability guarantee (completed 2026-06-12)
- [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)
- [x] **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 (completed 2026-06-14)
- [x] **Phase 12: Initial Setup Wizard** - First-run validated bootstrap of env/VAPID/DB/OIDC + first app password, reusing the admin route surface (completed 2026-06-16)
- [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 14: Desktop E2E Coverage** - Add a Desktop Chrome Playwright profile + make the mobile-authored specs desktop-safe so the Phase 8 regression gate validates desktop, not just mobile (completed 2026-06-12)
- [x] **Phase 15: Doc-Only CI Skip + Markdown Lint** - Aggregate-gate the slow api/harness CI jobs so doc-only PRs to main merge without running them (no branch-protection deadlock), and add markdownlint to `fast-checks` so docs get a fast format+lint gate (promoted from backlog 999.17) (completed 2026-06-12)
- [x] **Phase 16: CI Dependency Audit, Security Checks & Image Hygiene** - Extend Gitea CI with outdated-dependency reporting + vulnerability audit + a baseline of additional security checks, and enforce the dev/prod image boundary so no dev-bypass, secret, or family data ships in published images (absorbs backlog 999.17); independent of the admin chain (completed 2026-06-13)
- [x] **Phase 17: UI Optimization & Polish** - Phone-layout polish + branding + theme groundwork: fix the long-standing phone-layout overlap where the fixed BottomTabBar covers the New Event FAB and the colour legend (+ small-viewport sweep), finish the branding assets (real FamilySync logo into the BrandSlot seam + a complete favicon/PWA-icon set replacing the placeholder stubs), and restructure tokens.css into a themeable token layer (light-only groundwork for future dark mode). Shipped dark theme → backlog 999.20; broader styling refresh → backlog 999.21 (future milestone) (completed 2026-06-18)
## Phase Details
> v1.0 phase detail (Phases 16) is archived in [`milestones/v1.0-ROADMAP.md`](milestones/v1.0-ROADMAP.md).
### Phase 7: Mobile Test Harness
**Goal**: The assistant can drive the PWA in a mobile-emulated, authenticated browser context against the host-side dev stack, so mobile-only layout and flow defects can be caught automatically instead of only by the operator on real devices. This harness is also the artifact Phase 8 (CI) runs for UI regression.
**Mode:** standard
**Depends on**: Nothing (fully independent; goes first. One new dev dependency `@playwright/test` in `apps/pwa`; no backend changes).
**Requirements**: TEST-01, TEST-02
**Success Criteria** (what must be TRUE):
1. An automated run can load the PWA in a mobile-emulated viewport (device profile + mobile UA + touch) and assert on responsive layout / tap targets.
2. The automated run reaches the authenticated PWA via the existing `DEV_AUTH_BYPASS` on the host-side dev stack — no manual login and no Authelia/OIDC mocking.
3. The harness runs repeatably day-over-day without re-capturing any session state (no stale storage-state failures).
4. The harness specs are structured so they can run headlessly in CI (Phase 8) against a dev stack the runner brings up — no dependence on a developer's already-running host stack.
**Pitfalls this phase owns** (from PITFALLS.md):
- **No stale storage-state** (Pitfall 14): use `DEV_AUTH_BYPASS=true` for the automated harness rather than a checked-in storage-state.json with an expiring session cookie; decide the auth strategy before the first test.
- **Service worker block** (Pitfall 15): set `serviceWorkers: 'block'` (or explicitly unregister) in the context so a previous run's SW does not intercept requests / return stale cached responses; verify no SW-sourced responses in the trace.
- Hard constraints: targets the dev build via `DEV_AUTH_BYPASS` (DEV_AUTH_BYPASS user 1 has no CalDAV credential/calendars — verify layout/flows, not live event-create); real prod-service-worker / iOS-Safari-standalone mobile testing stays a human/device gate (out of scope).
**Plans**: 4 plans (3 waves)Plans:
**Wave 1**
- [x] 07-01-PLAN.md — Harness foundation: @playwright/test + WebKit/Chromium browsers, playwright.config.ts (iPhone/WebKit + Pixel/Chromium matrix, serviceWorkers block, env baseURL, vite webServer), vitest exclude, scripts (Wave 1)
**Wave 2** *(blocked on Wave 1 completion)*
- [x] 07-02-PLAN.md — global-setup.ts: /health readiness poll + deterministic mysql2 reset-and-seed (calendar id 10 INSERT IGNORE guard, list + items) + e2e README/guardrails (Wave 2)
**Wave 3** *(blocked on Wave 2 completion)*
- [x] 07-03-PLAN.md — layout.spec.ts: tap targets >=44px, no overflow, in-viewport, accessible names (UI-SPEC Rules 1-4) + harness self-validation injected-defect proofs (Wave 3)
- [x] 07-04-PLAN.md — calendar.spec.ts + lists.spec.ts: populated/empty/error states (Rules 4/5) + DEV_AUTH_BYPASS auth-reached + no-SW-controller precondition (Wave 3)
**UI hint**: yes
### Phase 8: Gitea CI
**Goal**: Every PR to `main` runs a full regression that gates the merge — lint, typecheck, unit, API-integration against a MariaDB service container, **and the Phase 7 mobile Playwright harness as a UI-regression step against a CI-hosted dev stack** — and a merge to `main` builds and publishes the API Docker image, all on the existing self-hosted Gitea Actions runner.
**Mode:** standard
**Depends on**: Phase 7 (the PR regression runs the Phase 7 mobile harness specs as its UI-regression step; without the harness there is nothing to run). No other code dependencies. Start with a runner-probe step.
**Requirements**: CI-01, CI-02
**Success Criteria** (what must be TRUE):
1. Opening or updating a PR targeting `main` triggers a workflow that runs lint, typecheck (both apps), unit tests, and API integration tests against a MariaDB service container — and a failing run blocks the merge.
2. The API integration tests connect to the service-container MariaDB (DB_HOST=127.0.0.1, service creds) and pass reliably on a cold first run, not only on re-run.
3. The same PR workflow brings up the dev stack inside the runner — the API dev server, the PWA dev server, and the MariaDB service container, with `DEV_AUTH_BYPASS=true` — and runs the Phase 7 mobile Playwright harness specs headlessly against that authed PWA; a harness failure blocks the merge.
4. The harness step waits for both the API and PWA dev servers to be ready (readiness probe / poll) before launching Playwright, so it does not flake on startup races.
5. On merge to `main`, the API Docker image is built and pushed to the Gitea container registry under a sensible tag.
6. Registry credentials never appear in plaintext in the CI logs.
**Pitfalls this phase owns** (from PITFALLS.md):
- **Runner-probe first** (Pitfall 12): the first workflow only probes `node --version` / `pnpm --version` / Docker access on the `self-hosted` runner before any test or build steps are designed; pin Node 22 explicitly, do not assume `actions/setup-node` works as on GitHub.
- **MariaDB readiness wait** (Pitfall 11): add an explicit readiness loop (e.g. `healthcheck.sh --connect --innodb_initialized`, NOT `mysqladmin ping` which is removed in MariaDB 11) before any `drizzle-kit migrate` / integration test step; healthy ≠ accepting connections.
- **Dev-stack readiness races (NEW for the harness step):** running the PWA and API dev servers *inside* CI adds startup/readiness races on top of the MariaDB-11 readiness race. The harness step must wait for **both** the API and PWA dev servers to be accepting connections (poll their URLs / health endpoints) before Playwright launches — do not race the browser against a not-yet-listening server. Run with `DEV_AUTH_BYPASS=true` so the harness reaches the authed PWA exactly as in Phase 7.
- **--password-stdin** (Pitfall 13): `docker login` via `--password-stdin` with the token piped from a registered Gitea secret (PAT with `write:package`); never `-p $TOKEN` on the command line.
- Hard constraints: API integration tests need a real MariaDB and live in `apps/api/tests/` (never `src/`); cache the pnpm store; Drizzle generate+migrate to set up the CI DB schema; the harness step reuses the Phase 7 specs unchanged (CI owns only the stack bring-up + readiness wait, not the spec content).
**Plans**: 4 plans (4 waves)Plans:
**Wave 1**
- [x] 08-01-PLAN.md — Runner probe + operator runner/PAT registration (W0; answers the Docker-vs-host fork)
**Wave 2** *(blocked on Wave 1 completion)*
- [x] 08-02-PLAN.md — ci.yml: fast-checks (lint/typecheck/PWA unit) + API job (MariaDB service + migrate + DB-backed tests)
**Wave 3** *(blocked on Wave 2 completion)*
- [x] 08-03-PLAN.md — ci.yml: harness job (dev-stack bring-up + readiness waits + Phase 7 Playwright specs, both profiles)
**Wave 4** *(blocked on Wave 3 completion)*
- [x] 08-04-PLAN.md — ci.yml: publish job (build production image + push :latest + :v1.1-<sha> via --password-stdin)
**UI hint**: yes
### Phase 9: Faster Write-Back
**Goal**: A created, edited, or deleted event reaches Fastmail within ~1-2 seconds (event-driven outbox drain) instead of waiting up to ~15s for the next interval tick — with every existing durability guarantee intact.
**Mode:** standard
**Depends on**: Nothing (fully independent; the only new artifact is a zero-dependency in-process EventEmitter, `lib/outboxTrigger.ts`). Can run in parallel with any other v1.1 track.
**Requirements**: CAL-15
**Success Criteria** (what must be TRUE):
1. After creating/editing/deleting an event, the change lands in Fastmail in ~1-2s in the common case (drain is signalled on enqueue, not waited-for on the interval) — observable as the change appearing in the Fastmail native app well before the old ~15s window.
2. The route handler still returns an optimistic 202 immediately and never makes a CalDAV call inline — the event-driven signal is fire-and-forget.
3. Edit-as-move still writes the new event before deleting the old one (create-before-delete ordering preserved); no event is ever lost when a move drains under rapid enqueues.
4. No duplicate CalDAV PUTs occur for the same outbox row when the signal and the 15s fallback interval overlap (exactly-once per uid preserved).
5. The 15s `setInterval` fallback still runs and recovers any rows missed by the signal path (startup catch-up, transient errors).
**Pitfalls this phase owns** (from PITFALLS.md):
- **No double-drain** (Pitfall 5): the trigger must set a `drainRequested` flag funnelled through the single setInterval-controlled path / the existing `isDraining` guard — never call `runOutboxDrain()` directly from the signal in a way that bypasses the guard or escapes the error-caught wrapper.
- **Create-before-delete under concurrent enqueues** (Pitfall 6): enqueue CREATE before DELETE; do not fire the signal between the two inserts of a move (publish after both inserts / after the transaction commits).
- Hard constraints: `setInterval` only (no node-cron); single-process by design — **no Redis** for the drain (Redis stays for list SSE); all outbox guarantees (fresh-etag-before-PUT, 412 conflict flow, per-uid exactly-once) unchanged.
**Plans**: 2 plans (2 waves)
Plans:
**Wave 1**
- [x] 09-01-PLAN.md — TDD: outboxTrigger.ts (zero-dep EventEmitter signal) + scheduleOutboxDrain wrapper / drainRequested trailing-re-drain loop + initOutboxTrigger in outboxWorker.ts; trigger-wiring tests (SC-1, SC-4/D-07, D-05) (Wave 1)
**Wave 2** *(blocked on Wave 1 completion)*
- [x] 09-02-PLAN.md — Four post-commit signalOutboxDrain() publish sites in events.ts (create / edit-as-move-after-transaction / same-cal update / delete) + initOutboxTrigger() startup wiring under isMainModule() in index.ts (Wave 2)
### Phase 10: Admin Role & Settings
**Goal**: An admin can manage household configuration that previously required manual DB writes — rotating a member's Fastmail app password and designating the shared family calendar — from a role-gated in-app Settings section, on top of the v1.1 DB foundation this phase introduces.
**Mode:** standard
**Depends on**: Nothing required upstream; this phase **carries the v1.1 DB migration** (users.is_admin, calendar_events.reminder_lead_minutes, app_config table) that Phases 11 and 12 build on. It is the head of the admin chain (10 → 11, 10 → 12).
**Requirements**: ADMIN-01, ADMIN-02, ADMIN-03
**Success Criteria** (what must be TRUE):
1. An admin sees an Admin section in Settings and can list household members with their credential status; a non-admin member never sees it and cannot invoke any `/api/admin/*` route (gets 403).
2. An admin can enter or rotate a member's Fastmail app password; it is validated against CalDAV (PROPFIND) before saving and stored encrypted — and the password is never displayed, echoed in a response, or logged.
3. An admin can pick which synced calendar is the shared family calendar from a list, and the `calendars.is_shared` flag updates accordingly (replacing the manual `UPDATE calendars SET is_shared=1` step).
4. The role check is role-agnostic and member-count-agnostic: it gates on `users.is_admin`, so more admins can be added later without reworking the guard.
5. The DB migration (is_admin, reminder_lead_minutes, app_config) is applied via generate+migrate and is in place for downstream phases (reminder_lead_minutes for Phase 11, app_config.setup_complete for Phase 12).
**Pitfalls this phase owns** (from PITFALLS.md):
- **Admin role check inside the sub-router** (Pitfall 9): apply `requireAdmin` with `.use('*', ...)` inside `adminRouter`, not only at the parent mount; integration test must assert 403 for a non-admin authenticated user.
- **App password never logged/echoed** (Pitfall 7): custom zod-validator `hook` returns a generic 400 (no Zod `received`/`value` field); no `console.log` of request bodies in `routes/admin*`.
- Hard constraints: Drizzle **generate+migrate, never push** (false destructive diff on populated MariaDB); reuse `broker/crypto.ts` `encryptPassword` (no changes to crypto); `/api/admin/credentials` and `/api/admin/calendars/:id/shared` are the single shared surface — do NOT duplicate them into `/api/setup/*` in Phase 12.
**Folded-in scope** (from backlog 999.5, self-service member onboarding): the credential surface this phase builds is the same one a member needs on first login. Expose a `needsProviderSetup` signal (member has no `member_credentials` row) and let a member enter/validate (CalDAV PROPFIND) + encrypt their **own** Fastmail app password — the self-service counterpart of the admin-managed flow, sharing the validation/encryption/initial-sync path. Non-technical-friendly instructions (link to Fastmail's app-password page, required Calendars/CalDAV scope) are the hard UX constraint. Member-scoped: a member can only set their own credential; never log/echo the password.
**Plans**: 4 plans (4 waves)Plans:
**Wave 1**
- [x] 10-01-PLAN.md — v1.1 DB foundation migration (is_admin, provider_type+unique, reminder_lead_minutes, app_config) + dev-bypass admin seed
**Wave 2** *(blocked on Wave 1 completion)*
- [x] 10-02-PLAN.md — requireAdmin guard + first-login-wins bootstrap + /api/me isAdmin/needsProviderSetup (TDD)
**Wave 3** *(blocked on Wave 2 completion)*
- [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)*
- [x] 10-04-PLAN.md — PWA /admin route + nav gating + CredentialSheet + SetupBanner (playwright-cli verified)
**UI hint**: yes
### Phase 11: Per-Event Reminders
**Goal**: A user can choose a reminder lead time per event (None / 5m / 10m / 15m / 30m / 1h / 2h / 1d / 2d, default None), serialized as a VALARM on the event, and the push scheduler fires at that exact lead — firing nothing when there is no alarm and never stripping reminders set in other clients.
**Mode:** standard
**Depends on**: Phase 10 (the `calendar_events.reminder_lead_minutes` column from the v1.1 migration is the scheduler's ground truth). Independent of Phases 7/8/9/12.
**Requirements**: CAL-13, CAL-14, NOTIF-04, NOTIF-05, NOTIF-06
**Success Criteria** (what must be TRUE):
1. When creating or editing a timed event, the user can pick a reminder lead from the preset list (None default); the choice round-trips to Fastmail as a VALARM and is visible/honored on re-open.
2. Editing an event that already has a reminder set in another client (Fastmail / Apple Calendar) preserves that VALARM — it is never silently dropped on round-trip.
3. A reminder push fires at the event's chosen lead time (e.g. T-30 for a 30-minute lead), not a hardcoded 15-minute lead.
4. An event with no reminder set produces no reminder push (no default 15-minute fire).
5. An all-day event's reminder fires at a sensible local time (9 AM on the alert day), not midnight; the reminder selector is disabled/hidden for all-day events in the UI; and reminder delivery stays exactly-once across catch-up scans and rescheduled events.
**Pitfalls this phase owns** (from PITFALLS.md):
- **Preserve-on-edit** (Pitfall 1): the update path extracts and preserves existing VALARM sub-components from `rawVevent` (mirroring the WR-01 RRULE-preserve pattern) — never rebuild-from-scratch and silently strip; `outboxPayloadSchema` distinguishes "no change" from explicit "no reminder".
- **No TRIGGER VALUE=TEXT** (Pitfall 2): build the trigger with `ICAL.Duration.fromSeconds(-n*60)`, not a bare string; unit-test that the ICS emits a DURATION trigger with no `VALUE=TEXT`.
- **All-day 9AM semantics** (Pitfall 3): guard `buildVeventString` (`if (!allDay && reminderMinutes > 0)`), disable the selector when allDay, keep the scheduler's all-day handling at 9 AM local.
- **uid:dtstartMs dedup** (Pitfall 4): change the scheduler dedup key from bare `uid` to compound `uid:dtstartMs` and widen the scan to a variable per-event window so long leads fire and rescheduled events re-fire; keep `eventFieldsSchema` and `outboxPayloadSchema` in sync (IN-03).
- Hard constraints: `setInterval` only; scheduler reads `reminder_lead_minutes` from the DB (ground truth), not the outbox payload; drop the `isShared`-only reminder restriction (a user who set an alarm wants it regardless of calendar).
**Plans**: 4 plans (3 waves)
Plans:
**Wave 1**
- [x] 11-01-PLAN.md — VALARM builders + classifier + extractor + computeAlertInstantUtc (vevent.ts, TDD)
**Wave 2** *(blocked on Wave 1 completion)*
- [x] 11-02-PLAN.md — Variable-lead scheduler: uid:dtstartMs dedup, drop isShared, all-day 9 AM, humanized body (TDD)
- [x] 11-03-PLAN.md — Backend plumbing: schema field, outbox preserve-on-edit, sync upsert, occurrence surfacing
**Wave 3** *(blocked on Wave 2 completion)*
- [x] 11-04-PLAN.md — EventForm reminder picker (allDay swap, edit pre-population) + client types + Playwright smoke
**UI hint**: yes
### Phase 12: Initial Setup Wizard
**Goal**: On first run (no admin/credentials configured), the operator is guided through a validated, step-by-step wizard to bootstrap the app — env presence, generated secrets to copy, DB/OIDC/VAPID/app-password validation — instead of hand-editing `.env` / `docker-compose.yml`; once complete, the setup endpoints lock.
**Mode:** standard
**Depends on**: Phase 10 (reuses the admin role + `/api/admin/credentials` and `/api/admin/calendars/:id/shared` routes; the wizard is the second frontend consumer of that surface, and `app_config` from the Phase 10 migration holds `setup_complete`). Goes last. Independent of Phases 7/8/9/11.
**Requirements**: SETUP-01, SETUP-02, SETUP-03, SETUP-04
**Success Criteria** (what must be TRUE):
1. On a fresh install with nothing configured, the operator reaches a setup wizard (via `GET /api/setup/status` mounted before the OIDC guard) and walks through bootstrap steps instead of editing files by hand.
2. Each input is validated before the step can complete: DB connects, VAPID private key decodes to exactly 32 bytes and pairs with the public key, OIDC discovery resolves, and the Fastmail app password reaches CalDAV (PROPFIND).
3. Generated secrets (session secret, encryption key, VAPID keypair) are displayed for the operator to copy into env; they are never written to the DB or returned in a way that persists, and `APP_PASSWORD_ENCRYPTION_KEY`/`VAPID_PRIVATE_KEY` never enter the DB at all.
4. After completion, the wizard-completing user is promoted to admin (`is_admin`), `app_config.setup_complete` is set, and any further call to a setup endpoint returns 423 Locked.
5. The 423 guard is enforced on every invocation (checked against member-credentials + VAPID env present), not only at startup.
**Pitfalls this phase owns** (from PITFALLS.md):
- **Guard on every invocation** (Pitfall 8): the "already set up" guard returns 423 from all setup routes once configured — implement and test the guard before the happy path; a second POST after completion must return 423, not 200.
- **Secrets stay in env, never in DB** (Pitfalls 8 & 10): the wizard validates secrets by performing a test operation (test encrypt/decrypt, structural VAPID check), never by accepting/storing the key value; no DB column for `vapid_private_key` or `app_password_encryption_key`; never log/echo the app password.
- Hard constraints: `GET /api/setup/status` mounts **before** the OIDC guard (like `/health`); do NOT create `/api/setup/credentials` — reuse the Phase 10 admin routes; Drizzle generate+migrate (any `app_config` seeding via migration).
**Plans**: 7 plans in 4 waves (4 original + 3 gap-closure for 12-UAT.md gaps 1-6)
Plans:
**Wave 1**
- [x] 12-01-PLAN.md — Schema migration (nullable OIDC + claimed) + generate-secrets helper (SETUP-03) + Wave-0 scaffolds
**Wave 2** *(blocked on Wave 1 completion)*
- [x] 12-02-PLAN.md — Pre-auth /api/setup/* router + isSetupLocked 423 guard + index mount + OIDC boot fallback (SETUP-01/02/04)
- [x] 12-03-PLAN.md — First-login-claims rework in upsertUser (D-08, SETUP-01)
**Wave 3** *(blocked on Wave 2 completion)*
- [x] 12-04-PLAN.md — PWA SetupPage wizard + App.tsx gate + UI-SPEC revision (SETUP-01/02)
**Wave 4 — Gap closure** *(UAT 12-UAT.md gaps 1-6; 06+07 parallel, 05 blocked on 06)*
- [x] 12-06-PLAN.md — Backend: validate/vapid asserts wizard key == env VAPID_PUBLIC_KEY (gap 2) + status exposes non-secret DB name (gap 3) (SETUP-02)
- [x] 12-07-PLAN.md — App.tsx: reverse-gate /setup post-completion (gap 5) + reconcile ['me'] so calendar banner clears after wizard (gap 6) (SETUP-01/04)
- [x] 12-05-PLAN.md — SetupPage: drop DB-vs-env aside (gap 1) + read-only DB-name field (gap 3) + persist fields across Back (gap 4) (SETUP-01) — depends on 12-06
**UI hint**: yes
### Phase 13: Real Lint Gate (ESLint)
**Goal**: The CI lint gate actually fails on lint violations. A real ESLint flat config (`eslint.config.js`, `typescript-eslint`; React + react-hooks plugins for `apps/pwa`) plus a package-level `lint` script in `apps/api` and `apps/pwa` makes the existing root `pnpm -r --if-present lint` run a real linter, replacing the hollow no-op gate that exits 0 because no linter exists.
**Mode:** standard
**Depends on**: Phase 8 (the CI `fast-checks` job already runs `pnpm lint`; this fills the slot Phase 8 shipped wired to auto-activate once a package `lint` script lands). Independent of all other phases.
**Requirements**: TBD (promoted from backlog 999.16)
**Success Criteria** (what must be TRUE):
1. `pnpm lint` runs ESLint across both `apps/api` and `apps/pwa` and exits non-zero on an introduced violation (verified by a deliberate test violation), where today it exits 0 with no linter present.
2. The CI `fast-checks` lint step blocks a PR to main on lint violations — the gate can now fail.
3. The first real run's existing violations are resolved (fix / warn / disable decided per rule) so the baseline gate ends green.
**Pitfalls this phase owns**:
- Pick a baseline ruleset (recommended vs strict-type-checked) deliberately — strict surfaces a large upfront cleanup; decide blocking vs advisory before flipping the gate to blocking.
- `typecheck`/tsc already gates type errors; ESLint should not duplicate type-checking rules unnecessarily.
**Plans**: 3 plans — all complete (scope expanded during planning to add a Prettier `format:check` gate)
- [x] 13-01-PLAN.md — Install ESLint/Prettier deps + flat config + package scripts + prove the gate fails (SC-1)
- [x] 13-02-PLAN.md — Fix all first-run lint violations across both apps, green `pnpm lint` (D-13-05/06)
- [x] 13-03-PLAN.md — Prettier reformat (isolated commit) + CI format:check step + green baseline (SC-2/SC-3)
**UI hint**: no
### Phase 14: Desktop E2E Coverage
**Goal**: The Phase 8 regression gate exercises the desktop layout and flows, not just mobile. A `desktop` Playwright project (`devices['Desktop Chrome']`, no touch, wide viewport) is added to `apps/pwa/playwright.config.ts`, and the existing mobile-authored specs are reviewed/adjusted (or appropriately skipped) so `pnpm test:e2e` passes on a no-touch desktop viewport as well as the `iphone`/`pixel` profiles.
**Mode:** standard
**Depends on**: Phase 7 (the harness it extends) and Phase 8 (CI runs `pnpm test:e2e` and picks up the new project automatically — no CI plumbing change needed beyond any desktop-profile runtime/wait). Independent of Phases 913.
**Requirements**: TBD (promoted from backlog 999.15)
**Success Criteria** (what must be TRUE):
1. A `desktop` project exists in `playwright.config.ts` (Desktop Chrome, wide viewport, no `hasTouch`).
2. The existing e2e specs pass (or are explicitly, justifiably skipped) on the desktop profile — touch-gesture / mobile-drawer / mobile-only-layout assumptions are handled.
3. `pnpm test:e2e` in CI runs and gates on both mobile and desktop profiles (blocking-vs-advisory for desktop decided when planned).
**Pitfalls this phase owns**:
- The real work is the spec-compat pass, not CI plumbing — Phase 8 reused the Phase 7 harness unchanged, so the config addition is small but specs authored for touch/mobile need per-spec review.
- Desktop WebKit is optional — the Apple member is already covered on mobile Safari via `iphone`; Desktop Chrome is likely sufficient for a shared/wall browser.
**Plans**: 1 plan
Plans:
- [x] 14-01-PLAN.md — Add the `desktop` Playwright project, desktop-skip the two mobile-only layout assertions (+ D-04 parity), update spec/README docs, and prove `pnpm test:e2e` is green on iphone + pixel + desktop with a blocking CI gate.
**UI hint**: no
### Phase 15: Doc-Only CI Skip
**Goal**: Doc-only PRs to `main` merge without running the slow `harness` (Playwright e2e + dev-stack bring-up, ~5 min) and `api` (MariaDB integration) jobs, while `fast-checks` (Prettier `format:check` + markdown linting) still runs — and branch protection never deadlocks on a required check that never reports. Docs get a *fast but real* gate: format + lint, none of the slow code jobs.
**Mode:** standard
**Depends on**: Phase 8 (the `.gitea/workflows/ci.yml` it modifies) and Phase 13 (the `fast-checks` job + `format:check` step this extends). Independent of Phases 912.
**Requirements**: TBD (promoted from backlog 999.17)
**Success Criteria** (what must be TRUE):
1. A doc-only PR to `main` (only `docs/` or `*.md` changed) skips the `api` and `harness` jobs but still runs `fast-checks`.
2. A PR touching code runs `fast-checks`, `api`, and `harness` as today; a failure in any blocks the merge.
3. Branch protection requires `CI / fast-checks` + an always-running `CI / gate` aggregate (passes when each heavy job is `success` OR `skipped`) — the direct `api`/`harness` requirements are dropped so a skipped heavy job never deadlocks the merge.
4. `fast-checks` runs a markdown linter (markdownlint-cli2) over `**/*.md`; an introduced markdown-lint violation fails the gate, and the existing markdown baseline passes (violations fixed or rules configured) so the gate starts green.
**Pitfalls this phase owns**:
- **Required-check deadlock** — never path-filter a required context directly; a required job that never reports blocks the PR forever. The always-running `gate` job (`if: always()`, passes on `success`/`skipped`) is the only safe gating surface.
- **Gitea skipped-status quirk** — Gitea may not emit a commit-status for a `skipped` job; rely on the always-running `gate`, not on marking `api`/`harness` skipped-but-required.
- **Prettier vs markdownlint overlap** — Prettier already owns markdown *formatting*; scope markdownlint to *content* rules (heading increments, no broken/duplicate link refs, list/code-fence conventions) and disable its purely-stylistic rules that fight Prettier (e.g. line-length, list-indent), so the two don't conflict on the same `.md`.
- **`.planning/*` is push-direct, never linted** — planning bookkeeping bypasses CI via the Unprotected file pattern, so markdownlint never sees it; scope the lint glob to `docs/` + repo-root/app `*.md` and exclude `.planning/**` (and any generated markdown) to avoid a baseline cleanup of churny bookkeeping files.
**Plans**: 3 plans (3 waves)
Plans:
**Wave 1**
- [x] 15-01-PLAN.md — markdownlint-cli2 + `.markdownlint-cli2.jsonc` + `md:lint` script + fast-checks step + fix 13 baseline violations (SC-4)
**Wave 2** *(blocked on Wave 1 completion)*
- [x] 15-02-PLAN.md — ci.yml: `changes` (dorny/paths-filter@v4) + conditional api/harness + always-running `gate` aggregate (SC-1/SC-2, SC-3 YAML)
**Wave 3** *(blocked on Wave 2 completion)*
- [x] 15-03-PLAN.md — operator branch-protection checkpoint (require `CI / fast-checks` + `CI / gate`, drop api/harness) + publish.yml comment update (SC-3)
**UI hint**: no
### Phase 16: CI Dependency Audit, Security Checks & Image Hygiene
**Goal**: The CI pipeline surfaces outdated and vulnerable dependencies, runs a baseline of additional security checks, and enforces a clean dev↔prod boundary in the images it publishes — so the two-person household app doesn't silently rot on stale/CVE-bearing packages, and no dev-only affordance, secret, or family-specific data ever ships in a production image. Extends the existing Gitea CI (Phase 8) workflow with dependency/security/image-hygiene gates rather than standing up a separate pipeline. **Absorbs backlog 999.17 (dev/prod image boundary).**
**Mode:** standard
**Depends on**: Phase 8 (Gitea CI — adds steps to the existing workflow + publish job; no admin-chain dependency). Independent of Phases 1012.
**Requirements**: SEC-01 (secret scanning), SEC-02 (static security lint), DEP-01 (vuln audit gate), DEP-02 (outdated advisory), IMG-01 (NODE_ENV+boot-guard), IMG-02 (.dockerignore), IMG-03 (publish image-hygiene assertions), CI-03 (security job + gate wiring)
**Candidate scope (to be sharpened in `/gsd-discuss-phase 16`):**
- **Outdated dependencies:** a CI step that reports dependencies behind their latest (e.g. `pnpm outdated -r`), surfaced on the PR. Decide gating vs advisory, and how to handle the pinned-version table in CLAUDE.md (the stack pins exact versions — "outdated" must not fight intentional pins).
- **Vulnerability audit:** `pnpm audit` (or equivalent) against the lockfile, failing on a chosen severity threshold (e.g. high/critical). Decide the threshold and an allowlist/waiver mechanism for unfixable transitive advisories.
- **Additional security checks (user is open to these — pick a sensible baseline, avoid over-build):** candidates — secret scanning on the diff (gitleaks/trufflehog), a CodeQL/`eslint-plugin-security` static pass, dependency-review on PRs, Dockerfile/image scan (e.g. trivy) of the published image.
- **Dev/prod boundary definition & enforcement (from 999.17):** the `DEV_AUTH_BYPASS` concept (and any dev-only affordance) must be provably confined to local dev — never to production, never baked into published images. Today the guard is runtime-only (`NODE_ENV !== 'production' && DEV_AUTH_BYPASS === 'true'` in `apps/api/src/auth/devBypass.ts`); add (a) explicit documentation of what "dev image" vs "shipped image" means, and (b) build-time / boot-time enforcement (a `production` image refuses to boot — or the build aborts — if dev-bypass is enabled) as defense-in-depth.
- **No data/secrets in published images (from 999.17):** audit the Dockerfile(s) + the Phase 8 publish job (`publish.yml`) to confirm `.env`, dev seed SQL, local DB dumps, encryption keys, OIDC secrets, the `DEV_USER` seed, and family-specific fixtures are `.dockerignore`d and never `COPY`'d. Add a CI assertion that fails the publish if a dev-bypass code path is active, a forbidden env/secret is present, or personal/seed data is staged into the image context. The dev-stack seed path (`DEV_USER` id 1 + sample calendar/list data) must be unreachable from the production image/compose.
- **Noise control:** these gates are notorious for flaky/advisory-churn failures; decide blocking-on-merge vs warn-only per check, and where results surface (PR annotation vs job log), mirroring Phase 15's gate-aggregation approach.
**Boundary:** Extends the existing Gitea CI workflow + publish job; does not remove dev-bypass (still needed for local verification and the Phase 7/8 harness) and does not add a new external service or a runtime dependency to the app. Automated dependency *upgrades* (e.g. Renovate/Dependabot bots) are a separate concern — decide in discuss whether they're in scope or deferred.
**Plans**: 6 plans in 2 waves
Plans:
**Wave 1**
- [x] 16-01-PLAN.md — Image-hygiene runtime: bake NODE_ENV=production + boot-time refuse-to-boot guard (IMG-01)
- [x] 16-02-PLAN.md — pnpm audit gate + waiver allowlist + advisory-only tiered outdated report (DEP-01, DEP-02)
- [x] 16-03-PLAN.md — Fold eslint-plugin-security into the lint gate as blocking errors + triage (SEC-02)
- [x] 16-04-PLAN.md — gitleaks config + full-history baseline + .dockerignore (SEC-01, IMG-02)
**Wave 2** *(blocked on Wave 1 completion)*
- [x] 16-05-PLAN.md — Add the security job to ci.yml (gitleaks always; audit/outdated code-gated) + gate wiring (CI-03)
- [x] 16-06-PLAN.md — publish.yml static image-hygiene assertion + boot-smoke before push (IMG-03)
**UI hint**: no
### Phase 17: UI Optimization & Polish
**Goal**: A visual-identity & polish pass for the PWA spanning three workstreams: **(A) phone-layout polish** so the phone (≤767px) layout has no fixed-chrome overlap and small-viewport spacing reads cleanly — starting with the long-standing BottomTabBar overlap that hides the New Event FAB and the colour legend, plus a small-viewport sweep; **(B) branding assets** — generate a real FamilySync logo into the existing `BrandSlot` seam (`apps/pwa/src/components/BrandSlot.tsx`) and a complete favicon/PWA-icon set replacing the placeholder stubs in `apps/pwa/public/`; **(C) theme-token groundwork** — restructure `apps/pwa/src/styles/tokens.css` into a themeable semantic-token layer (swappable by `data-theme`/`prefers-color-scheme`), light staying the only shipped theme, so a future dark theme is cheap.
**Mode:** standard
**Depends on**: Nothing structural (CSS/layout + assets only). Best sequenced after Phase 10 merges (the BottomTabBar gained an Admin tab and the new SetupBanner adds top pressure on phone), but otherwise independent of the admin chain.
**Requirements**: No REQ-IDs — decisions D-01…D-10 (17-CONTEXT.md) stand in. Coverage: D-01/D-02 (A, phone overlap+guard) → 17-03; D-03/D-04 (B, assets) → 17-02; D-04/D-05 (B, wiring) → 17-04; D-06 (C, token groundwork) → 17-01; D-07/D-09 (D, logout+sheet centering) → 17-05; D-08/D-09/D-10 (D, admin toasts+reset-sheet+two-tab nav) → 17-06.
**Scope boundary (set in `/gsd-discuss-phase 17`, 2026-06-17):** Workstream C ships token groundwork **only** — no dark palette, no theme toggle (→ backlog **999.20**). A broader "modern styling" visual refresh is **out of scope** and routed to backlog **999.21** (future milestone). Keep Phase 17 a focused polish + branding + groundwork pass, not a redesign.
**Seed defect — phone-layout bottom-bar overlap (documented 2026-06-13; long-standing, NOT introduced by Phase 10 — the BottomTabBar dates to Phase 04):**
At ≤767px (`window.matchMedia('(max-width: 767px)')` in `apps/pwa/src/App.tsx`) the layout switches to a 48px top AppNav + a `position: fixed` BottomTabBar (`height: calc(56px + env(safe-area-inset-bottom))`, z-index 200; `apps/pwa/src/components/BottomTabBar.tsx`) + a floating "New Event" FAB (`position: fixed; bottom: var(--space-6); right: var(--space-6)`; `apps/pwa/src/components/CalendarShell.tsx`). Two problems:
1. **FAB sits inside the bar** — the FAB's `bottom` offset (~`--space-6`, ≈24px) is smaller than the bar's 56px height, so the round New Event button overlaps the bottom tab bar (lands on the Admin tab).
2. **Content occluded** — the content area (`contentStyle` in `App.tsx`) reserves no `padding-bottom` for the fixed bar, so the bottom of the calendar and the colour-legend chips (e.g. the "Dev User" / member legend) slide under the bar and are partially hidden.
**Fix sketch (CSS-only, no behaviour change):** on phone, lift the FAB to `bottom: calc(56px + env(safe-area-inset-bottom, 0px) + var(--space-6))` and add a matching `padding-bottom: calc(56px + env(safe-area-inset-bottom, 0px))` to the phone content/scroll area (or reduce the `100dvh` column by the bar height). Verify across the `iphone`/`pixel`/`desktop` Playwright profiles and a real narrow Chromium via playwright-cli.
**Evidence:** reproduced 2026-06-13 with playwright-cli at 390×844 (FAB over the Admin tab; "Dev User" legend clipped) vs 1280×800 (desktop sidebar, no overlap). Full detail in todo `2026-06-13-pwa-phone-bottombar-overlap.md`.
**Candidate scope (to sharpen in `/gsd-discuss-phase 17`):** the seed defect above, plus a sweep for other small-viewport spacing / tap-target / overlap issues (the Phase 7 `layout.spec.ts` tap-target/overflow assertions are a ready checklist) and any phone/desktop visual inconsistencies noticed in use. Keep it a focused polish pass, not a redesign.
**Plans**: 6/6 plans complete
Plans:
**Wave 1**
- [x] 17-01-PLAN.md — C: tokens.css themeable-layer groundwork + --bottom-chrome-h token (D-06) [Wave 1]
- [x] 17-02-PLAN.md — B: generate logo + full icon set, operator approval checkpoint (D-03, D-04) [Wave 1]
**Wave 2** *(blocked on Wave 1 completion)*
- [x] 17-03-PLAN.md — A: phone FAB/BottomTabBar overlap fix + sweep + CI overlap assertion (D-01, D-02) [Wave 2, dep 01]
- [x] 17-04-PLAN.md — B: wire logo into BrandSlot + index.html favicons + manifest maskable + accent (D-04, D-05) [Wave 2, dep 01,02]
- [x] 17-05-PLAN.md — D: logout control + sheet desktop-centering (SettingsSheet/CredentialSheet) (D-07, D-09) [Wave 2, dep 01]
- [x] 17-06-PLAN.md — D: admin success toasts + two-tab ARIA nav + reset-sheet centering + admin.spec.ts (D-08, D-09, D-10) [Wave 2, dep 01]
**UI hint**: yes
## Progress
| Phase | Milestone | Plans Complete | Status | Completed |
| ----- | --------- | -------------- | -------- | ---------- |
| 1. Foundation + Broker Spike | v1.0 | 4/4 | Complete | 2026-06-04 |
| 2. Calendar Display | v1.0 | 5/5 | Complete | 2026-06-05 |
| 3. Event Write-Back + PWA Install| v1.0 | 12/12 | Complete | 2026-06-07 |
| 4. Shared Lists + Live Sync | v1.0 | 7/7 | Complete | 2026-06-09 |
| 5. Web Push Notifications | v1.0 | 8/8 | Complete | 2026-06-10 |
| 6. UX Polish | v1.0 | 6/6 | Complete | 2026-06-10 |
| 7. Mobile Test Harness | v1.1 | 4/4 | Complete | 2026-06-11 |
| 8. Gitea CI | v1.1 | 4/4 | Complete | 2026-06-11 |
| 9. Faster Write-Back | v1.1 | 2/2 | Complete | 2026-06-12 |
| 10. Admin Role & Settings | v1.1 | 4/4 | Complete | 2026-06-13 |
| 11. Per-Event Reminders | v1.1 | 5/5 | Complete | 2026-06-14 |
| 12. Initial Setup Wizard | v1.1 | 7/7 | Complete | 2026-06-16 |
| 13. Real Lint Gate (ESLint) | v1.1 | 3/3 | Complete | 2026-06-12 |
| 14. Desktop E2E Coverage | v1.1 | 1/1 | Complete | 2026-06-12 |
| 15. Doc-Only CI Skip + MD Lint | v1.1 | 3/3 | Complete | 2026-06-12 |
| 16. CI Dep Audit, Sec & Img Hyg | v1.1 | 6/6 | Complete | 2026-06-13 |
| 17. UI Optimization & Polish | v1.1 | 6/6 | Complete | 2026-06-18 |
| 18. Auto Timezone Detection | v1.1 | 4/4 | Complete | 2026-06-14 |
| 19. Local Auth (No-OIDC Mode) | v1.1 | 5/5 | Complete | 2026-06-17 |
| 20. Admin Member Editor & Declutter | v1.1 | 3/3 | Complete | 2026-06-18 |
## Backlog
### Phase 999.1: Treat Fastmail as one calendar provider; framework supports adding more providers (BACKLOG)
**Goal:** [Captured for future planning] Abstract the calendar backend behind a provider interface so Fastmail/CalDAV is one implementation among potentially many. Shipping with a single provider is fine, but the broker, sync, and event-expansion layers should be structured so additional providers (e.g. other CalDAV hosts, Google Calendar, generic ICS feeds) can be added without rework. Captures the "provider" seam as an explicit architectural concern.
**Requirements:** TBD
**Plans:** 6/6 plans complete
Plans:
- [ ] TBD (promote with /gsd-review-backlog when ready)
### Phase 999.4: Per-event reminder configuration (VALARM authoring + scheduler honors it) (BACKLOG)
**Goal:** [Captured for future planning] End-to-end per-event reminders — let the user choose *when* (or whether) to be reminded per event, and make the push scheduler honor that choice instead of a hardcoded lead.
**Half A — author the VALARM (event form):** The event create/edit form has no UI to set a reminder ("remind me 10 min / 1 hour / 1 day before", or **no reminder**), so the written `.ics` carries no `VALARM` and no reminder can fire — in native clients or via web push. Add a reminder selector (including an explicit "none"), serialize chosen offsets as `VALARM` (TRIGGER) on write-back, and parse existing `VALARM`s on read so edits preserve them. Feeds the Phase 5 web-push requirement (push needs reminder data to notify about).
**Half B — scheduler honors the provider's value (NEW, surfaced 2026-06-10):** Today `apps/api/src/broker/reminderScheduler.ts` runs a **hardcoded 15-minute** scan for shared timed events (`index.ts:139` "starting in ~15 min"; reminderScheduler header "15-min reminder scan") and never reads the event's actual alarm. So every reminder fires 15 min before regardless of what the event (or the calendar provider) specifies, and an event with **no** alarm still gets a 15-min push. Change the scheduler to read each event's `VALARM` `TRIGGER` (the value written in Half A / set in Fastmail or another native client) and fire at that lead — and fire **nothing** when the event has no alarm. The current fixed 15-min window/dedup logic (catch-up scan, per-uid exactly-once — see quick 260610-hbu) must be generalized to a variable per-event lead.
**Boundary:** preserve the reminder scheduler's resilience guarantees (catch-up on a missed tick, per-uid exactly-once dedup). This makes the lead per-event/variable rather than constant; it is not a rewrite of the scan/dedup design.
**Severity:** medium — feature gap surfaced during Phase 03 Gate 2 testing; Half B surfaced 2026-06-10. Tags: phase-03, phase-05, calendar, write-back, reminders, valarm, push, scheduler, phase-05-dependency.
**Requirements:** TBD
**Plans:** 0 plans
> **Promoted into v1.1 Phase 11 (Per-Event Reminders) — CAL-13/CAL-14/NOTIF-04/05/06.** Backlog entry retained for history.
Plans:
- [ ] TBD (promote with /gsd-review-backlog when ready)
### Phase 999.10: Admin Settings / Administration section — manage app passwords + designate the shared calendar via UI (BACKLOG)
**Goal:** [Captured for future planning] Add an in-app **Settings/Administration** section, gated to an administrator role, for configuration that today requires manual backend/DB steps:
- **View/update per-member Fastmail app passwords** (stored encrypted via `APP_PASSWORD_ENCRYPTION_KEY`, existing crypto path) — rotate or re-enter a member's credential and re-trigger sync.
- **Designate which synced calendar is the "shared" calendar** by toggling `calendars.is_shared` from the UI. Today this is a manual DB write: e.g. `UPDATE calendars SET is_shared=1 WHERE id=<row>` — done by hand on 2026-06-10 to mark the "FamilySync" calendar (id 10) shared after the poller synced it (D-16). The admin should pick the shared calendar from a list of synced collections instead of relying on a backend process. (The poller's upsert already leaves `is_shared` untouched, so a UI-set flag persists.)
**Context:** Motivated by the manual D-16 resolution (2026-06-10). **Related:** 999.5 (per-member first-login app-password onboarding) — this is the ongoing admin-managed counterpart; and 999.11 (initial setup wizard) — bootstrap-time vs. ongoing config. Tags: admin, settings, calendar, app-passwords, D-16.
> **Promoted into v1.1 Phase 10 (Admin Role & Settings) — ADMIN-01/ADMIN-02/ADMIN-03.** Backlog entry retained for history.
**Requirements:** TBD
**Plans:** 0 plans
Plans:
- [ ] TBD (promote with /gsd-review-backlog when ready)
### Phase 999.11: Initial setup wizard — first-run config of env vars, app passwords, DB connection (BACKLOG)
**Goal:** [Captured for future planning] Add a first-run **setup wizard** that walks the administrator through defining all bootstrap configuration instead of hand-editing `.env` / `docker-compose.yml`:
- **App environment variables:** OIDC client id/secret/issuer/redirect URI + external URL, session signing secret (`OIDC_AUTH_SECRET`), `APP_PASSWORD_ENCRYPTION_KEY`, and the **VAPID keypair** (subject + public + private).
- **MariaDB connection:** host/port/user/password/db, with a connectivity test.
- **First Fastmail app password** for the initial member, encrypted on save.
Wizard should **validate inputs before completing** — e.g. VAPID private key decodes to 32 bytes AND pairs with the public key, OIDC discovery resolves, DB connects, app-password reaches CalDAV.
**Context:** Motivated by setup friction observed 2026-06-10 — a VAPID private key truncated on paste into `.env` silently broke push (`setVapidDetails failed — 32 bytes`), and `DB_HOST` / dev overrides must currently be set by hand. A guided + validated wizard would have caught these. **Related:** 999.10 (ongoing admin Settings) and 999.5 (member onboarding). Tags: onboarding, setup, install, env, vapid, mariadb, oidc.
> **Promoted into v1.1 Phase 12 (Initial Setup Wizard) — SETUP-01/02/03/04.** Backlog entry retained for history.
**Requirements:** TBD
**Plans:** 0 plans
Plans:
- [ ] TBD (promote with /gsd-review-backlog when ready)
### Phase 999.12: Assistant-driven mobile-browser UI testing (mobile viewport + authed PWA) (BACKLOG)
**Goal:** [Captured for future planning] Give the assistant a way to validate UI/UX changes in a **mobile** browser experience, not just desktop Chromium. Today `playwright-cli` drives a desktop viewport, and the prod stack enforces OIDC (Authelia) so the authed PWA can't be reached headlessly — which is exactly why a string of mobile-only defects this milestone (silent Android notifications, the dead "How to enable" link, iOS/Android session-cookie persistence, install/standalone behaviour) could only be found by the operator on real devices, not by the assistant.
**What this needs (any subset):**
- **Mobile viewport + UA emulation** in the browser harness (e.g. Playwright device descriptors — iPhone/Pixel viewport, touch, mobile user-agent) so layout, tap targets, and responsive behaviour can be checked.
- **An authenticated entry path for automated runs** so the assistant can reach the real PWA past Authelia — e.g. a reusable saved storage-state/cookie, a test-only bypass on a non-prod host, or driving the Authelia login once and reusing the session. (Note: this overlaps the existing `DEV_AUTH_BYPASS`, but that only works on the host-side dev stack, not the prod-mode PWA that has the real service worker. A mobile, authed, SW-enabled target is the gap.)
- Optionally: a documented way to point the harness at the Pangolin HTTPS URL with a persisted session, and/or remote-debug a real device.
**Boundary:** genuinely device-only behaviour (iOS-Safari standalone push, real APNs/FCM delivery, OS notification-channel importance) still needs a human — this item is about everything SHORT of that (responsive layout, tap flows, in-page notification UI states, auth redirects) which a mobile-emulated authed browser *could* cover but currently can't.
**Context:** Surfaced 2026-06-10 during Phase 5 UAT — repeated mobile-only bugs were caught only by the operator because the assistant had no mobile, authenticated browser to test in. **Related:** [[feedback-playwright-verify]] (use playwright-cli over manual verification — this extends it to mobile/authed). Tags: testing, playwright, mobile, pwa, oidc, dx.
> **Promoted into v1.1 Phase 7 (Mobile Test Harness) — TEST-01/TEST-02.** v1.1 scopes the `DEV_AUTH_BYPASS` dev-build path; the prod-SW authed-mobile target stays deferred. Backlog entry retained for history.
**Requirements:** TBD
**Plans:** 0 plans
Plans:
- [ ] TBD (promote with /gsd-review-backlog when ready)
### Phase 999.13: Reduce event write-back latency to the calendar provider (outbox drain) (BACKLOG)
**Goal:** [Captured for future planning] Calendar create/edit/delete writes are enqueue-only (`calendarOutbox`, 202 optimistic-accept; D-12/D-05 — no Fastmail call in the route) and flushed to Fastmail by `runOutboxDrain` on a **15-second `setInterval`** (`apps/api/src/broker/outboxWorker.ts`). So a change can take up to ~15s to land in Fastmail (and longer to reflect back in the app, which depends on the separate 5-min poller). Reduce that perceived sync delay so edits feel near-immediate.
**Options to weigh when picking this up:**
- **Event-driven drain (preferred):** trigger an outbox drain immediately after a successful enqueue (in-process signal, or Redis pub/sub which is already available) so the write fires within ~1s instead of waiting for the next tick — keep the 15s `setInterval` as a fallback/retry sweep. Must preserve the existing per-row etag/412 handling and the rapid-successive-edit ordering (see outboxWorker comments ~L312 — each edit carries its enqueue-time etag).
- **Shorter interval:** simplest, but more idle DB polling; a floor (e.g. 35s) trades latency for load.
- **Faster read-back too:** the user also sees latency from the 5-min poller reflecting the change back. Consider invalidating/short-poll after a local write, or optimistic UI already covering it — confirm whether the perceived delay is the write (15s) or the read-back (5min).
**Boundary:** the optimistic 202 + outbox durability design (create-before-delete, drain concurrency guard, fresh-etag-before-PUT) must be preserved — this is a latency tune, not a rewrite of the write path.
**Context:** Surfaced 2026-06-10. Tags: calendar, write-back, outbox, latency, redis, performance.
> **Promoted into v1.1 Phase 9 (Faster Write-Back) — CAL-15.** In-process EventEmitter chosen (not Redis); the drain is single-process by design. Backlog entry retained for history.
**Requirements:** TBD
**Plans:** 0 plans
Plans:
- [ ] TBD (promote with /gsd-review-backlog when ready)
### Phase 999.14: Gitea CI — full regression on PR to main + build/publish Docker image (BACKLOG)
**Goal:** [Captured for future planning] The repo is committed against a self-hosted Gitea instance with a registered Actions runner, but there is no CI yet (no `.gitea/workflows/` or `.github/workflows/`). Two things should run automatically: (1) **full regression** on every PR targeting `main` — gating the merge; (2) **build the app's Docker image and publish it** to the Gitea container registry.
**Options / decisions to make when picking this up:**
- **Test scope:** "full regression" = lint + typecheck + unit + the API integration tests. Integration tests need a real MariaDB (see [[api-integration-test-db]]) — the workflow must spin up a MariaDB service container, bind it, and set `DB_HOST=127.0.0.1` + `.env` creds. The PWA build/test also runs.
- **Monorepo:** pnpm workspace (`apps/api`, `apps/pwa`, shared). Cache the pnpm store.
- **Docker images:** only `apps/api/Dockerfile` exists today — there is no PWA Dockerfile yet. Decide one image (API) vs. also building/serving the PWA. Tag scheme + when to publish (only on merge to `main`? on tags? per-PR?).
- **Registry auth:** push to the Gitea registry using the runner's Gitea-provided token or a dedicated package-write token.
- Gitea Actions are GitHub-Actions-compatible syntax but run on the self-hosted runner — confirm runner labels and available images, and that Actions is enabled, before authoring.
**Likely shape:** a `.gitea/workflows/ci.yml``on: pull_request` (to `main`) → install (pnpm), lint, typecheck, unit, API integration vs. a `mariadb` service container, PWA build; `on: push` to `main`/tag → `docker build apps/api/Dockerfile`, login, push tagged image.
**Context:** Promoted from STATE.md pending todo (`.planning/todos/pending/2026-06-10-gitea-ci-regression-and-docker-publish.md`), surfaced 2026-06-10. Tags: tooling, ci, gitea, docker, mariadb, monorepo.
> **Promoted into v1.1 Phase 8 (Gitea CI) — CI-01/CI-02.** v1.1 also extends CI-01 to run the Phase 7 mobile harness as a UI-regression step (CI brings up the dev stack in the runner). Backlog entry retained for history.
**Requirements:** TBD
**Plans:** 0 plans
Plans:
- [ ] TBD (promote with /gsd-review-backlog when ready)
### Phase 999.15: Desktop e2e coverage — add a Desktop Playwright profile + desktop-safe specs (BACKLOG)
**Goal:** [Captured for future planning] The Playwright harness (`apps/pwa/playwright.config.ts`) defines only **mobile** device profiles — `iphone` (iPhone 14 / WebKit) and `pixel` (Pixel 7 / Chromium), both with touch and a mobile viewport. The Phase 8 CI regression gate runs `pnpm test:e2e`, so it currently validates the **mobile experience only**. Add desktop coverage so the regression gate exercises the desktop layout/flows as well.
**Options / decisions to make when picking this up:**
- **Add a Desktop profile:** a new `desktop` project in `playwright.config.ts` (e.g. `devices['Desktop Chrome']`, no `hasTouch`, wide viewport). Optionally a Desktop WebKit/Safari profile too — but the family's Apple member is already covered on mobile Safari via `iphone`; Desktop Chrome is likely sufficient for a shared/wall browser.
- **Spec-compat pass (the real work):** the existing e2e specs were authored for mobile — they may assume touch gestures, a mobile nav/drawer, or mobile-only layout. Each spec needs review/adjustment so it passes (or is appropriately skipped) on a no-touch, wide-viewport desktop. This is harness/spec work, not CI plumbing.
- **Gating choice:** decide whether desktop runs block the merge immediately, or run advisory (non-blocking) until the specs are confirmed desktop-safe.
**Boundary:** Phase 8 deliberately reused the Phase 7 harness **unchanged** (CI owns only stack bring-up + readiness waits, not spec content), which is why this was deferred. Once a Desktop project is added to the config, Phase 8 CI picks it up automatically via `pnpm test:e2e` — no CI changes needed beyond whatever runtime/wait the desktop profile requires.
**Context:** Deferred from Phase 8 (Gitea CI) planning, 2026-06-11 — user wants both mobile and desktop validated, but desktop needs a config addition + spec review that is out of Phase 8's CI-plumbing scope. Tags: testing, playwright, e2e, desktop, harness, ci.
> **Promoted into v1.1 Phase 14 (Desktop E2E Coverage) — 2026-06-11.** Backlog entry retained for history.
**Requirements:** TBD
**Plans:** 0 plans
Plans:
- [ ] TBD (promote with /gsd-review-backlog when ready)
### Phase 999.16: Wire a real linter (ESLint) so the CI lint gate actually fails on violations (BACKLOG)
**Goal:** [Captured for future planning] The Phase 8 CI `fast-checks` job runs `pnpm lint`, but **no linter exists** in the repo — the root `lint` script is `pnpm -r --if-present lint`, which finds no package-level lint script and exits 0. The lint gate is a hollow placeholder that can never fail. Wire up a real linter so it runs and gates merges on lint violations. (`typecheck`/tsc already gates type errors meanwhile.)
**Options / decisions to make when picking this up:**
- **Tooling:** ESLint flat config (`eslint.config.js`) with `typescript-eslint`; add React + react-hooks plugins for `apps/pwa`. Add `eslint` (+ plugins) as devDeps and a `lint` script to `apps/api` and `apps/pwa``pnpm -r --if-present lint` then picks them up automatically, no CI change needed.
- **Rule strictness:** pick a baseline (recommended vs strict-type-checked). Stricter = more upfront violations to fix.
- **Violation cleanup (the real work):** the first run surfaces existing violations across both apps. Decide per-rule: fix, downgrade to warn, or disable. The gate must end green.
- **Gating choice:** blocking on merge immediately, or advisory (warn-only) until the codebase is clean.
**Boundary:** Phase 8 deliberately scoped lint wiring out (CI-plumbing-only); it shipped the gate slot wired to auto-activate once a package `lint` script lands. This item is that follow-up.
**Context:** Raised during Phase 8 execution, 2026-06-11 — user noted the `--if-present` lint step "didn't fix the linter, just made it so it didn't have to exist to proceed" and wants a lint gate that actually fails. Tags: ci, lint, eslint, typescript-eslint, quality, gitea.
> **Promoted into v1.1 Phase 13 (Real Lint Gate / ESLint) — 2026-06-11.** Backlog entry retained for history.
**Requirements:** TBD
**Plans:** 0 plans
Plans:
- [ ] TBD (promote with /gsd-review-backlog when ready)
### Phase 999.18: Update dependencies as found during CI (BACKLOG)
**Goal:** [Captured for future planning] When the CI dependency-audit gate (Phase 16) surfaces outdated or vulnerable packages, bump them rather than letting the report accumulate. Establish a lightweight, recurring "act on the CI dependency report" loop so the two-person household app doesn't drift onto stale/CVE-bearing deps. Scope is the upkeep workflow (review → bump → verify gate green), not a one-time audit.
**Context:** Captured 2026-06-13 during Phase 10 work. Companion to the audit *reporting* shipped in Phase 16 (CI Dependency Audit) — that phase makes outdated/vulnerable deps *visible*; this item is the standing follow-through to *resolve* what it finds. Tags: ci, dependencies, maintenance, security, upkeep.
**Requirements:** TBD
**Plans:** 0 plans
Plans:
- [ ] TBD (promote with /gsd-review-backlog when ready)
### Phase 999.19: Dev user exercises full app functionality without syncing to a real calendar (BACKLOG)
**Goal:** [Captured for future planning] Let the `DEV_AUTH_BYPASS` dev user (currently hardcoded `DEV_USER` id 1 in `apps/api/src/auth/devBypass.ts`) exercise the full app — create/edit/delete events, set per-event reminders, manage lists — against a local/in-app calendar store, WITHOUT requiring a connected Fastmail/CalDAV provider and WITHOUT writing anything to a real calendar. Today the dev user has no `member_credentials` row and no `calendars`, so `writable-calendars` is empty and `POST /api/events/create` returns `422 "No writable calendar found for user"` — making hands-on UAT of event/reminder features impossible in dev. Options to explore: seed the dev user a fake local calendar + short-circuit the outbox/CalDAV write path under dev-bypass (no Fastmail round-trip), or a dev-only in-memory calendar provider. Must stay strictly dev-only (same hard `NODE_ENV !== 'production'` guard) and never ship in production images.
**Context:** Captured 2026-06-14 during Phase 11 (Per-Event Reminders) UAT. The reminder picker and backend were verified via automated tests + a route-mocked playwright smoke, but the operator could not manually create an event to see reminders end-to-end because no provider is connected in the dev DB (`needsProviderSetup: true`). This is a recurring dev-testability friction (see MEMORY: "Dev user 1 has no calendars"). Tags: dev-tooling, dev-bypass, testability, calendars, outbox, uat.
**Requirements:** TBD
**Plans:** 0 plans
Plans:
- [ ] TBD (promote with /gsd-review-backlog when ready)
### Phase 999.20: PWA dark mode / theming — ship a full dark theme + light/dark/system switch (BACKLOG)
**Goal:** [Captured for future planning] Ship a complete dark theme for the PWA plus a light/dark/system theme switch. **Phase 17 lays the token-architecture groundwork** — it restructures `apps/pwa/src/styles/tokens.css` from a single light `:root` into a themeable semantic-token layer that can be swapped via `data-theme` / `prefers-color-scheme`, with light staying the default and only-shipped theme. This backlog item is the follow-through that consumes that seam: author the actual dark palette values (including the Schedule-X `--sx-color-*` calendar overrides at the bottom of tokens.css), wire `prefers-color-scheme`, add a persisted in-app toggle in the /admin or Settings surface (light / dark / system), and verify both themes render cleanly across every route (calendar, lists, admin, settings sheet, login) via `playwright-cli` + the Phase 7 `layout.spec` profiles.
**Context:** Deferred out of Phase 17 (2026-06-17) during `/gsd-discuss-phase 17` to keep that phase scoped to phone-layout polish + branding assets. Phase 17's token restructure is the explicit enabling groundwork, so this should be cheap to pick up afterward. Related: Phase 17 (UI Optimization & Polish — the groundwork), 999.21 (modern styling refresh). Tags: pwa, theming, dark-mode, tokens, accessibility, settings, prefers-color-scheme.
**Requirements:** TBD
**Plans:** 0 plans
Plans:
- [ ] TBD (promote with /gsd-review-backlog when ready)
### Phase 999.21: PWA modern visual styling refresh — contemporary look across the app (BACKLOG)
**Goal:** [Captured for future planning] A broader "more modern, visually appealing" styling pass across the PWA — beyond the bounded in-system polish of Phase 17. Candidate scope: a contemporary refresh of high-visibility surfaces (login, calendar shell, event form, lists, admin), revisiting elevation/shadows, radii, spacing rhythm, typography scale, and control states, potentially reworking specific component layouts. Explicitly **flagged for a future milestone**, not v1.1 — it is a visual-overhaul track with real redesign risk and should be scoped/sequenced on its own rather than bolted onto a polish phase. Best sequenced after the Phase 17 token groundwork and 999.20 (dark mode) so the refresh is theme-aware from the start.
**Context:** Deferred out of Phase 17 (2026-06-17) during `/gsd-discuss-phase 17`. The user scoped Phase 17 to layout polish + branding (logo/favicon/icon assets) + theme-token groundwork, and routed the open-ended styling refresh here for a future milestone to avoid an unbounded redesign inside a polish phase. Related: Phase 17 (the polish baseline), 999.20 (dark mode / theming). Tags: pwa, ui, styling, redesign, design-system, future-milestone.
**Requirements:** TBD
**Plans:** 0 plans
Plans:
- [ ] TBD (promote with /gsd-review-backlog when ready)
### Phase 18: Auto timezone detection and ability to change timezone
**Goal:** Make the household timezone an explicit, stored, user-changeable setting — auto-detected from the browser at first run, changeable from the role-gated /admin Settings — and route the server-side all-day "9 AM local" reminder computation through it (replacing the implicit `process.env.TZ` fallback), without touching the already-correct browser-local display/timed-write path.
**Requirements**: TBD (decision contract D-01..D-07 from 18-CONTEXT.md)
**Depends on:** Phase 10 (admin role + `/admin` Settings + `app_config`); Phase 11 (all-day reminder computation this rewires). Independent of Phase 17. Phase 12 (setup wizard) not required — seeding is self-contained.
**Plans:** 4/4 plans complete
Plans:
**Wave 1**
- [x] 18-01-PLAN.md — TDD: getHouseholdTimezone(db) accessor + isValidIanaTimezone (D-05/D-06)
**Wave 2** *(blocked on Wave 1 completion)*
- [x] 18-02-PLAN.md — TDD: admin GET/PUT/seed timezone endpoints on adminRouter, requireAdmin + IANA validation + no-overwrite seed (D-01/D-02/D-03/D-04)
- [x] 18-03-PLAN.md — TDD: route all-day reminder TZ at reminderScheduler:247 + outboxWorker:501,607 through the accessor (D-05/D-06/D-07)
**Wave 3** *(blocked on Wave 2 completion)*
- [x] 18-04-PLAN.md — PWA Timezone section in /admin Settings (searchable IANA picker + detected-zone seed) + client fns (D-02/D-04)
**UI hint**: yes
### Phase 19: Local Auth (No-OIDC Mode)
**Goal:** Let an operator run FamilySync entirely on **local DB users with no OIDC** — username/password accounts and a local login flow that coexists with the Authelia OIDC path — and **optionally wire OIDC in later** by claiming/linking an existing local user to an OIDC identity. Removes the hard dependency on a deployed Authelia for small/solo self-hosters.
**Mode:** standard
**Depends on:** Phase 12 (Initial Setup Wizard) — builds directly on the pre-OIDC **local-user foundation** introduced there: nullable `users.oidc_iss`/`oidc_sub` + the claimed/pending marker, and the first-login-claims merge. Phase 19 generalizes that single bootstrap local user into a full local-account model + login.
**Requirements**: AUTH-LOCAL-01..AUTH-LOCAL-20 (derived during planning 2026-06-17) — local_credentials schema (01), scrypt hash/verify (02), login route (03), localAuthMiddleware (04), auth-mode endpoint (05), logout (06), admin create-member (07), admin reset (08), self-change (09), OIDC-link (10), break-glass CLI (11), LoginPage (12), admin UI (13), settings UI (14), routing gate (15), dev-bypass/harness rework (16), hasLocalCredential (17), de-Authelia copy (18), rate-limit/lockout (19), auth unit tests (20). Plus `LOCAL_SESSION_SECRET` env + boot assertion (D-05).
**Plans:** 5/5 plans complete
**Provenance:** Deferred from the Phase 12 discussion (2026-06-15) — see `.planning/phases/12-initial-setup-wizard/12-CONTEXT.md` §Deferred Ideas. The operator runs FamilySync this way themselves and wants no-OIDC operation as a first-class mode.
**Open questions for discuss/spec:**
- Password hashing/storage choice (e.g. argon2id/bcrypt) and how it sits alongside the env-only secret kernel from Phase 12.
- How local login coexists with `oidcAuthMiddleware` ordering in `apps/api/src/index.ts` (route-level auth strategy selection vs. a mode flag in `app_config`).
- The OIDC-link flow: claiming an existing local user into an `oidc_iss+oidc_sub` identity without violating the D-10 "identity is OIDC, never email" rule.
- Whether "local mode vs OIDC mode" is a deploy-time switch or both can be live simultaneously.
Plans:
**Wave 1**
- [x] 19-01-PLAN.md — Foundation (TDD): local_credentials schema + 0003 migration, scrypt hash/verify, local-session JWT helpers, LOCAL_SESSION_SECRET boot guard + generate-secrets, .dockerignore scripts exclusion (AUTH-LOCAL-01/02)
**Wave 2** *(blocked on Wave 1)*
- [x] 19-02-PLAN.md — Backend account mgmt (TDD): admin create/reset member, self-change password, hasLocalCredential, linkOidcToUser helper + /api/me/link-oidc (AUTH-LOCAL-07/08/09/10/17)
**Wave 3** *(blocked on Wave 2)*
- [x] 19-03-PLAN.md — Middleware + routes + wiring (TDD): localAuthMiddleware, /api/auth/mode, login (rate-limit/lockout) + logout, index.ts mount + OIDC-guard skip + /callback link branch, de-Authelia comments (AUTH-LOCAL-03/04/05/06/18/19/20)
**Wave 4** *(blocked on Wave 3; 04 + 05 parallel)*
- [x] 19-04-PLAN.md — PWA: LoginPage + BrandSlot + App.tsx gate + client.ts + AdminPage + SettingsSheet (AUTH-LOCAL-12/13/14/15)
- [x] 19-05-PLAN.md — Dev-bypass Option C + break-glass CLI + harness/CI rework + login.spec.ts (AUTH-LOCAL-11/16)
### Phase 20: Admin Member Editor & Form Declutter
**Goal:** Replace the per-member-row action buttons (Rotate/Add credential + Reset password) in the admin Members panel with a single edit affordance — clicking a member's name or an edit button opens a member-detail editor where an admin modifies all of that member's details in one place: display name, local-login password, and the Fastmail/CalDAV app password (calendar credential) — using clear, non-jargon labels that retire the confusing "Rotate" term. Also collapse the "Add member" section so its input fields are hidden behind a single "Add member" trigger by default, decluttering the panel. Client-side AdminPage + CredentialSheet rework over the existing `/api/admin` endpoints; no new auth/authorization boundary (seeded by the gripe that "Rotate" for the app password is not intuitive).
**Requirements**: TBD (refine in /gsd-discuss-phase 20 — open scope: which fields count as "all" (color swatch? admin toggle? OIDC link?), whether to keep any standalone reset-password flow, and the exact edit affordance — clickable name vs. row edit button)
**Depends on:** Phase 19
**Plans:** 3/3 plans complete
Plans:
**Wave 1**
- [x] 20-01-PLAN.md — Server: PATCH /api/admin/members/:id (displayName + is_admin) with last-admin demotion guard (TDD) + isAdmin in GET /members
- [x] 20-02-PLAN.md — PWA API client: AdminMember.isAdmin field + updateMemberProfile fetcher (last-admin sentinel)
**Wave 2** *(blocked on Wave 1 completion)*
- [x] 20-03-PLAN.md — PWA: unified MemberEditorSheet (edit/create, per-section saves) + decluttered tappable Members panel; retire Rotate/Reset-password buttons
@@ -0,0 +1,125 @@
---
phase: 17-ui-optimization-polish
fixed_at: 2026-06-18T00:00:00Z
review_path: .planning/phases/17-ui-optimization-polish/17-REVIEW.md
iteration: 1
findings_in_scope: 15
fixed: 15
skipped: 0
status: all_fixed
---
# Phase 17: Code Review Fix Report
**Fixed at:** 2026-06-18T00:00:00Z
**Source review:** .planning/phases/17-ui-optimization-polish/17-REVIEW.md
**Iteration:** 1
**Summary:**
- Findings in scope: 15 (fix_scope: all — includes Info)
- Fixed: 15
- Skipped: 0
All fixes were verified with `tsc --noEmit` (clean) and `eslint --max-warnings 0`
(clean) on every touched file; the PWA also builds (`vite build` succeeds). A new
shared hook `apps/pwa/src/hooks/useIsPhone.ts` was created to back WR-05/IN-03.
## Fixed Issues
### WR-01: Modal dialogs declare `aria-modal="true"` but do not trap focus
**Files modified:** `apps/pwa/src/components/CredentialSheet.tsx`, `apps/pwa/src/components/SettingsSheet.tsx`, `apps/pwa/src/routes/AdminPage.tsx`
**Commit:** fb30800
**Applied fix:** Reused the existing `useFocusTrap(dialogRef)` hook (already used by EventForm/SeriesEditPrompt). Added a `dialogRef` + `onKeyDown={handleDialogKeyDown}` to every modal sheet that asserts `aria-modal="true"`: CredentialSheet, SettingsSheet, ChangePasswordSheet, LinkOidcSheet, and ResetPasswordSheet. Tab/Shift-Tab now cycle within the dialog instead of escaping to occluded background controls.
### WR-02: Admin tab strip keyboard nav is incomplete (no Home/End, no explicit wrap)
**Files modified:** `apps/pwa/src/routes/AdminPage.tsx`
**Commit:** f601c0c
**Applied fix:** Rewrote `handleTabKeyDown` to the full WAI-ARIA tabs pattern: ArrowLeft/Right now wrap around the ends using modular arithmetic over the `['members','settings']` order, and Home/End jump to the first/last tab.
### WR-03: Success toast does not re-announce repeated identical messages
**Files modified:** `apps/pwa/src/routes/AdminPage.tsx`
**Commit:** f601c0c
**Applied fix:** Changed toast state from `string | null` to `{ id: number; msg: string } | null` with a `showToast(msg)` helper that mints a fresh `id` (Date.now()) per call. The rendered toast `<div>` is now keyed on `toast.id` so an identical repeated message remounts and `aria-live` re-announces it; the auto-dismiss effect depends on the fresh object reference so the 3s timer restarts.
### WR-04: Toast `whiteSpace: nowrap` is a latent horizontal-overflow regression
**Files modified:** `apps/pwa/src/routes/AdminPage.tsx`
**Commit:** f601c0c
**Applied fix:** Removed `whiteSpace: 'nowrap'` from the toast style so a longer/localized message wraps within `maxWidth: 90vw` instead of overflowing `documentElement.scrollWidth` (which would trip the layout suite's no-horizontal-overflow rule).
### WR-05: `matchMedia(...)` read at render time does not react to resize/orientation
**Files modified:** `apps/pwa/src/hooks/useIsPhone.ts` (new), `apps/pwa/src/App.tsx`, `apps/pwa/src/components/CalendarShell.tsx`, `apps/pwa/src/components/SettingsSheet.tsx`, `apps/pwa/src/components/CredentialSheet.tsx`, `apps/pwa/src/routes/AdminPage.tsx`
**Commit:** a4a7438
**Applied fix:** Added a resize-aware `useMediaQuery`/`useIsPhone` hook backed by `matchMedia.addEventListener('change', …)`. Replaced all six synchronous `matchMedia('(max-width: 767px)')` render-time reads with `useIsPhone()`. Hook calls were placed before any early `return null` to respect the Rules of Hooks. Components now re-render when the 767px breakpoint is crossed (iPad rotation, desktop resize).
### WR-06: Timezone combobox `aria-activedescendant`/highlight can desync after filtering
**Files modified:** `apps/pwa/src/routes/AdminPage.tsx`
**Commit:** 1c0f357
**Applied fix:** Derived a clamped `tzActiveIndexClamped = Math.min(tzActiveIndex, max(0, filteredZones.length - 1))` in render and used it for `aria-activedescendant`, the Enter-to-commit lookup, and the visual highlight (`i === tzActiveIndexClamped`). ArrowUp/ArrowDown clamp the current index before moving so they never start from a stale position past the end of a freshly-shrunk list.
**Note:** Combobox interaction logic — recommend a quick manual/keyboard pass (type to filter, arrow, Enter) to confirm behavior.
### WR-07: Timezone combobox drops Tab-to-commit and relies on a fragile blur timeout
**Files modified:** `apps/pwa/src/routes/AdminPage.tsx`
**Commit:** 1c0f357
**Applied fix:** Added a `Tab` branch to the combobox `onKeyDown` that commits the highlighted option WITHOUT `preventDefault` (focus still advances to Save). Added an unmount cleanup effect that clears `tzBlurTimer`. Reduced the blur-close `setTimeout` from 120ms to 0ms now that options `preventDefault()` on `onMouseDown` (so a click never blurs the input first).
**Note:** Interaction logic — recommend a manual check that tabbing out of the open listbox commits the highlighted zone and that clicking an option still selects it.
### WR-08: `pwa:icons` script is non-portable and silently coupled to generated filenames
**Files modified:** `apps/pwa/scripts/copy-pwa-icons.mjs` (new), `apps/pwa/package.json`, `apps/pwa/vite.config.ts`
**Commit:** dd0b761
**Applied fix:** Replaced the five-`cp` Unix-only chain with a cross-platform Node script (`fs.copyFileSync`) that maps each generated filename to its stable manifest name and fails loudly with a named error if a generated file is missing (generator rename guard). Added a discoverability comment beside the manifest `icons` array in `vite.config.ts` pointing at the script's COPIES table.
### IN-01: `OidcRedirect` navigates as a render-phase side effect
**Files modified:** `apps/pwa/src/App.tsx`
**Commit:** a4a7438
**Applied fix:** Moved `window.location.replace('/api/login')` into a `useEffect(() => {...}, [])` so the navigation is no longer a render-phase side effect.
### IN-02: Inconsistent `exhaustive-deps` disables across sibling dialogs
**Files modified:** `apps/pwa/src/components/CredentialSheet.tsx`, `apps/pwa/src/components/SettingsSheet.tsx`
**Commit:** 3f4b7ea
**Applied fix:** Wrapped `handleClose` in `useCallback` in CredentialSheet and ChangePasswordSheet, added it to the Escape effect's dependency array, and removed the `// eslint-disable-line react-hooks/exhaustive-deps` comments — matching the LinkOidc/Reset sheet pattern.
### IN-03: `isPhone`/`phone` 767px check duplicated across ~6 sites
**Files modified:** (same as WR-05)
**Commit:** a4a7438
**Applied fix:** Resolved together with WR-05 — the single `useIsPhone()` hook now backs all call sites, and the `(max-width: 767px)` query lives in one place (`PHONE_MAX_QUERY` in the hook). The old standalone `isPhone()` helpers in App.tsx and CalendarShell.tsx were deleted.
### IN-04: Dead placeholder brand tokens retained
**Files modified:** `apps/pwa/src/styles/tokens.css`
**Commit:** 2317833
**Applied fix:** Removed the unused `--brand-logo-bg`, `--brand-logo-text`, and `--brand-app-name` declarations (verified via grep that nothing references them); left a short comment explaining the removal and that BrandSlot only reads `--brand-logo-size`/`--brand-logo-border-radius`.
### IN-05: Admin members-panel JSX has inconsistent indentation / stacked bottom margins
**Files modified:** `apps/pwa/src/routes/AdminPage.tsx`
**Commit:** 4bc1e2a
**Applied fix:** Ran Prettier (project `.prettierrc`) over AdminPage.tsx, normalizing the members-panel indentation and the rest of the file's drift; `prettier --check` now passes on the file. The stacked `marginBottom: var(--space-8)` on the last panel section was left intentionally — the review flagged it only as a minor cosmetic note, and changing section spacing risks a visual regression outside the finding's scope.
### IN-06: Toast and dialog `zIndex` overlap (300/301)
**Files modified:** `apps/pwa/src/routes/AdminPage.tsx`
**Commit:** f601c0c
**Applied fix:** Raised the toast `zIndex` from 300 to 400 so it always paints above sheet backdrops (300) and sheets (301), removing the DOM-order-dependent paint ambiguity.
### IN-07: `Intl.DateTimeFormat()` recomputed every render in the calendar-config path
**Files modified:** `apps/pwa/src/components/CalendarShell.tsx`, `apps/pwa/src/routes/AdminPage.tsx`
**Commit:** 11b6b36
**Applied fix:** Wrapped both `Intl.DateTimeFormat().resolvedOptions().timeZone` reads in `useMemo(…, [])``displayTimeZone` in CalendarShell (feeds the stable `useCalendarApp` config) and `detectedTz` in AdminPage.
---
_Fixed: 2026-06-18T00:00:00Z_
_Fixer: Claude (gsd-code-fixer)_
_Iteration: 1_
@@ -0,0 +1,125 @@
---
phase: 17-ui-optimization-polish
fixed_at: 2026-06-18T00:00:00Z
review_path: .planning/phases/17-ui-optimization-polish/17-REVIEW.md
iteration: 1
findings_in_scope: 15
fixed: 15
skipped: 0
status: all_fixed
---
# Phase 17: Code Review Fix Report
**Fixed at:** 2026-06-18T00:00:00Z
**Source review:** .planning/phases/17-ui-optimization-polish/17-REVIEW.md
**Iteration:** 1
**Summary:**
- Findings in scope: 15 (fix_scope: all — includes Info)
- Fixed: 15
- Skipped: 0
All fixes were verified with `tsc --noEmit` (clean) and `eslint --max-warnings 0`
(clean) on every touched file; the PWA also builds (`vite build` succeeds). A new
shared hook `apps/pwa/src/hooks/useIsPhone.ts` was created to back WR-05/IN-03.
## Fixed Issues
### WR-01: Modal dialogs declare `aria-modal="true"` but do not trap focus
**Files modified:** `apps/pwa/src/components/CredentialSheet.tsx`, `apps/pwa/src/components/SettingsSheet.tsx`, `apps/pwa/src/routes/AdminPage.tsx`
**Commit:** fb30800
**Applied fix:** Reused the existing `useFocusTrap(dialogRef)` hook (already used by EventForm/SeriesEditPrompt). Added a `dialogRef` + `onKeyDown={handleDialogKeyDown}` to every modal sheet that asserts `aria-modal="true"`: CredentialSheet, SettingsSheet, ChangePasswordSheet, LinkOidcSheet, and ResetPasswordSheet. Tab/Shift-Tab now cycle within the dialog instead of escaping to occluded background controls.
### WR-02: Admin tab strip keyboard nav is incomplete (no Home/End, no explicit wrap)
**Files modified:** `apps/pwa/src/routes/AdminPage.tsx`
**Commit:** f601c0c
**Applied fix:** Rewrote `handleTabKeyDown` to the full WAI-ARIA tabs pattern: ArrowLeft/Right now wrap around the ends using modular arithmetic over the `['members','settings']` order, and Home/End jump to the first/last tab.
### WR-03: Success toast does not re-announce repeated identical messages
**Files modified:** `apps/pwa/src/routes/AdminPage.tsx`
**Commit:** f601c0c
**Applied fix:** Changed toast state from `string | null` to `{ id: number; msg: string } | null` with a `showToast(msg)` helper that mints a fresh `id` (Date.now()) per call. The rendered toast `<div>` is now keyed on `toast.id` so an identical repeated message remounts and `aria-live` re-announces it; the auto-dismiss effect depends on the fresh object reference so the 3s timer restarts.
### WR-04: Toast `whiteSpace: nowrap` is a latent horizontal-overflow regression
**Files modified:** `apps/pwa/src/routes/AdminPage.tsx`
**Commit:** f601c0c
**Applied fix:** Removed `whiteSpace: 'nowrap'` from the toast style so a longer/localized message wraps within `maxWidth: 90vw` instead of overflowing `documentElement.scrollWidth` (which would trip the layout suite's no-horizontal-overflow rule).
### WR-05: `matchMedia(...)` read at render time does not react to resize/orientation
**Files modified:** `apps/pwa/src/hooks/useIsPhone.ts` (new), `apps/pwa/src/App.tsx`, `apps/pwa/src/components/CalendarShell.tsx`, `apps/pwa/src/components/SettingsSheet.tsx`, `apps/pwa/src/components/CredentialSheet.tsx`, `apps/pwa/src/routes/AdminPage.tsx`
**Commit:** a4a7438
**Applied fix:** Added a resize-aware `useMediaQuery`/`useIsPhone` hook backed by `matchMedia.addEventListener('change', …)`. Replaced all six synchronous `matchMedia('(max-width: 767px)')` render-time reads with `useIsPhone()`. Hook calls were placed before any early `return null` to respect the Rules of Hooks. Components now re-render when the 767px breakpoint is crossed (iPad rotation, desktop resize).
### WR-06: Timezone combobox `aria-activedescendant`/highlight can desync after filtering
**Files modified:** `apps/pwa/src/routes/AdminPage.tsx`
**Commit:** 1c0f357
**Applied fix:** Derived a clamped `tzActiveIndexClamped = Math.min(tzActiveIndex, max(0, filteredZones.length - 1))` in render and used it for `aria-activedescendant`, the Enter-to-commit lookup, and the visual highlight (`i === tzActiveIndexClamped`). ArrowUp/ArrowDown clamp the current index before moving so they never start from a stale position past the end of a freshly-shrunk list.
**Note:** Combobox interaction logic — recommend a quick manual/keyboard pass (type to filter, arrow, Enter) to confirm behavior.
### WR-07: Timezone combobox drops Tab-to-commit and relies on a fragile blur timeout
**Files modified:** `apps/pwa/src/routes/AdminPage.tsx`
**Commit:** 1c0f357
**Applied fix:** Added a `Tab` branch to the combobox `onKeyDown` that commits the highlighted option WITHOUT `preventDefault` (focus still advances to Save). Added an unmount cleanup effect that clears `tzBlurTimer`. Reduced the blur-close `setTimeout` from 120ms to 0ms now that options `preventDefault()` on `onMouseDown` (so a click never blurs the input first).
**Note:** Interaction logic — recommend a manual check that tabbing out of the open listbox commits the highlighted zone and that clicking an option still selects it.
### WR-08: `pwa:icons` script is non-portable and silently coupled to generated filenames
**Files modified:** `apps/pwa/scripts/copy-pwa-icons.mjs` (new), `apps/pwa/package.json`, `apps/pwa/vite.config.ts`
**Commit:** dd0b761
**Applied fix:** Replaced the five-`cp` Unix-only chain with a cross-platform Node script (`fs.copyFileSync`) that maps each generated filename to its stable manifest name and fails loudly with a named error if a generated file is missing (generator rename guard). Added a discoverability comment beside the manifest `icons` array in `vite.config.ts` pointing at the script's COPIES table.
### IN-01: `OidcRedirect` navigates as a render-phase side effect
**Files modified:** `apps/pwa/src/App.tsx`
**Commit:** a4a7438
**Applied fix:** Moved `window.location.replace('/api/login')` into a `useEffect(() => {...}, [])` so the navigation is no longer a render-phase side effect.
### IN-02: Inconsistent `exhaustive-deps` disables across sibling dialogs
**Files modified:** `apps/pwa/src/components/CredentialSheet.tsx`, `apps/pwa/src/components/SettingsSheet.tsx`
**Commit:** 3f4b7ea
**Applied fix:** Wrapped `handleClose` in `useCallback` in CredentialSheet and ChangePasswordSheet, added it to the Escape effect's dependency array, and removed the `// eslint-disable-line react-hooks/exhaustive-deps` comments — matching the LinkOidc/Reset sheet pattern.
### IN-03: `isPhone`/`phone` 767px check duplicated across ~6 sites
**Files modified:** (same as WR-05)
**Commit:** a4a7438
**Applied fix:** Resolved together with WR-05 — the single `useIsPhone()` hook now backs all call sites, and the `(max-width: 767px)` query lives in one place (`PHONE_MAX_QUERY` in the hook). The old standalone `isPhone()` helpers in App.tsx and CalendarShell.tsx were deleted.
### IN-04: Dead placeholder brand tokens retained
**Files modified:** `apps/pwa/src/styles/tokens.css`
**Commit:** 2317833
**Applied fix:** Removed the unused `--brand-logo-bg`, `--brand-logo-text`, and `--brand-app-name` declarations (verified via grep that nothing references them); left a short comment explaining the removal and that BrandSlot only reads `--brand-logo-size`/`--brand-logo-border-radius`.
### IN-05: Admin members-panel JSX has inconsistent indentation / stacked bottom margins
**Files modified:** `apps/pwa/src/routes/AdminPage.tsx`
**Commit:** 4bc1e2a
**Applied fix:** Ran Prettier (project `.prettierrc`) over AdminPage.tsx, normalizing the members-panel indentation and the rest of the file's drift; `prettier --check` now passes on the file. The stacked `marginBottom: var(--space-8)` on the last panel section was left intentionally — the review flagged it only as a minor cosmetic note, and changing section spacing risks a visual regression outside the finding's scope.
### IN-06: Toast and dialog `zIndex` overlap (300/301)
**Files modified:** `apps/pwa/src/routes/AdminPage.tsx`
**Commit:** f601c0c
**Applied fix:** Raised the toast `zIndex` from 300 to 400 so it always paints above sheet backdrops (300) and sheets (301), removing the DOM-order-dependent paint ambiguity.
### IN-07: `Intl.DateTimeFormat()` recomputed every render in the calendar-config path
**Files modified:** `apps/pwa/src/components/CalendarShell.tsx`, `apps/pwa/src/routes/AdminPage.tsx`
**Commit:** 11b6b36
**Applied fix:** Wrapped both `Intl.DateTimeFormat().resolvedOptions().timeZone` reads in `useMemo(…, [])``displayTimeZone` in CalendarShell (feeds the stable `useCalendarApp` config) and `detectedTz` in AdminPage.
---
_Fixed: 2026-06-18T00:00:00Z_
_Fixer: Claude (gsd-code-fixer)_
_Iteration: 1_
@@ -0,0 +1,56 @@
---
phase: 17-ui-optimization-polish
fixed_at: 2026-06-18T14:04:00Z
review_path: .planning/phases/17-ui-optimization-polish/17-REVIEW.md
iteration: 3
findings_in_scope: 2
fixed: 2
skipped: 0
status: all_fixed
---
# Phase 17: Code Review Fix Report (Iteration 3)
**Fixed at:** 2026-06-18T14:04:00Z
**Source review:** .planning/phases/17-ui-optimization-polish/17-REVIEW.md
**Iteration:** 3
**Summary:**
- Findings in scope: 2 (fix_scope: all — includes Info)
- Fixed: 2
- Skipped: 0
**Gate status after fixes (all pass):**
- `pnpm --filter @familysync/pwa test` → pass (22 files, 266 passed / 0 failed)
- `pnpm --filter @familysync/pwa typecheck` → pass (tsc + e2e tsconfig)
- `pnpm --filter @familysync/pwa lint` → pass (eslint `--max-warnings 0`)
## Fixed Issues
### CR-01: `useFocusTrap` visibility filter excluded all focusables under jsdom (CI gate failed)
**Files modified:** `apps/pwa/src/hooks/useFocusTrap.ts`
**Commit:** 287ecae
**Applied fix:** The prior iter-3 auto-fix (WR-01) rejected every focusable under jsdom because there `getBoundingClientRect()` returns all-zero geometry and `offsetParent` is `null` for every node, which short-circuited the trap (`focusable.length === 0`) and broke the two pre-existing WR-07 focus-trap regression tests — making `pnpm test` (a CI gate) fail at 2 failed / 264 passed.
Made the visibility heuristic tolerant of a non-layout environment: it now derives `hasLayout = r.width > 0 || r.height > 0 || el.offsetParent !== null`, and when there is no evidence of a layout engine (jsdom) it treats the node as visible instead of filtering it. Only when a real layout exists does it apply the `offsetParent === null` / zero-geometry exclusion, so genuinely hidden/collapsed nodes are still excluded in a real browser. The `hidden`-attribute exclusion is unambiguous regardless of layout, so it was hoisted out and kept unconditional. Result: all 266 PWA tests pass, including both WR-07 cases.
### IN-01: Focus-trap containment guard was unreachable as wired (harmless dead branch)
**Files modified:** `apps/pwa/src/hooks/useFocusTrap.ts`
**Commit:** 287ecae
**Applied fix:** The handler is wired only to each dialog's own `onKeyDown`, so it can only run while focus is already inside the dialog subtree; the `!dialogRef.current.contains(document.activeElement)` containment branch could therefore never evaluate true and delivered no actual containment guarantee. Per the review's recommendation, removed the inert branch and replaced its misleading comment with an accurate note: this is a deliberate boundary-only trap (a `document`-level `keydown`/`focusin` listener would be required for true containment, and is unnecessary for the current always-focus-the-heading-on-open flows). No behavior change in any real scenario — it only removes a comment that implied a guarantee the wiring cannot provide.
## Skipped Issues
None.
## Prior Iterations
Iterations 1 and 2 fixed the earlier batches of findings (15 in iter-1, then the iter-3 review's WR-01/IN-02/IN-03 set). The IN-02 (favicon.ico coupling) and IN-03 (OidcRedirect visible status) fixes were confirmed clean by the final re-review. This iteration-3 report supersedes those and records the final state: the WR-01 regression (CR-01) and its inert containment guard (IN-01) are now resolved, with all CI gates green.
---
_Fixed: 2026-06-18T14:04:00Z_
_Fixer: Claude (gsd-code-fixer)_
_Iteration: 3_
@@ -0,0 +1,167 @@
---
phase: 17-ui-optimization-polish
reviewed: 2026-06-18T00:00:00Z
depth: deep
files_reviewed: 13
files_reviewed_list:
- apps/pwa/e2e/admin.spec.ts
- apps/pwa/e2e/layout.spec.ts
- apps/pwa/index.html
- apps/pwa/package.json
- apps/pwa/pwa-assets.config.ts
- apps/pwa/src/App.tsx
- apps/pwa/src/components/BrandSlot.tsx
- apps/pwa/src/components/CalendarShell.tsx
- apps/pwa/src/components/CredentialSheet.tsx
- apps/pwa/src/components/SettingsSheet.tsx
- apps/pwa/src/routes/AdminPage.tsx
- apps/pwa/src/styles/tokens.css
- apps/pwa/vite.config.ts
findings:
critical: 0
warning: 8
info: 7
total: 15
status: issues_found
---
# Phase 17: Code Review Report
**Reviewed:** 2026-06-18T00:00:00Z
**Depth:** deep
**Files Reviewed:** 13
**Status:** issues_found
## Summary
Phase 17 is UI optimization/polish: brand logo swap, PWA manifest/icon hand-maintenance, an admin two-tab ARIA strip, a success toast, and a searchable timezone combobox, plus structural layout/admin Playwright suites. No structural-findings pre-pass was provided.
The code is generally careful — XSS surfaces are plain-text JSX, password fields use `new-password` autocomplete and are never pre-filled, the OIDC `authorizationUrl` null is guarded before navigation, and touch targets are consistently ≥44px. I found **no BLOCKERs** (no injection, no secret leakage, no data-loss path, no crash on the happy path).
There are real correctness/robustness defects worth fixing before ship: the **admin tab keyboard handler half-implements the WAI-ARIA tabs pattern** (no Home/End, no wrap); the **timezone combobox `aria-activedescendant`/highlight can desync after filtering** and **drops Tab-to-commit**; the **success toast does not re-announce** repeated identical messages and its `whiteSpace: nowrap` is a latent Rule-2 horizontal-overflow hazard against the project's own layout suite; **none of the modal dialogs trap focus** despite `aria-modal="true"`; and several `window.matchMedia` reads at render time **do not react to resize/orientation**, a stale-UI class this project explicitly cares about (iPad rotation).
## Warnings
### WR-01: Modal dialogs declare `aria-modal="true"` but do not trap focus
**File:** `apps/pwa/src/components/CredentialSheet.tsx:173-176`, `apps/pwa/src/components/SettingsSheet.tsx:199-202` (plus ChangePasswordSheet ~667-701 and LinkOidcSheet ~979-1013), `apps/pwa/src/routes/AdminPage.tsx:1420-1423` (ResetPasswordSheet)
**Issue:** Every sheet sets `role="dialog"` + `aria-modal="true"` and focuses the heading/close button on open, but none implements a focus trap. Tab/Shift-Tab can move focus out of the dialog to content behind the backdrop (still in the DOM). `aria-modal="true"` asserts to assistive tech that focus is contained — it is not. The app's stated UX hard-constraint is "slick and low-friction for a non-technical Apple member"; VoiceOver/keyboard users will escape the dialog silently and interact with occluded background controls.
**Fix:** Add a focus trap — capture Tab/Shift-Tab in the dialog keydown handler and cycle between first/last focusable descendants, ideally as a shared `useFocusTrap(ref)` hook reused by all sheets:
```tsx
onKeyDown={(e) => {
if (e.key !== 'Tab') return;
const f = dialogRef.current?.querySelectorAll<HTMLElement>(
'a[href],button:not([disabled]),input:not([disabled]),[tabindex]:not([tabindex="-1"])');
if (!f?.length) return;
const first = f[0], last = f[f.length - 1];
if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
}}
```
### WR-02: Admin tab strip keyboard nav is incomplete (no Home/End, no explicit wrap)
**File:** `apps/pwa/src/routes/AdminPage.tsx:205-225`
**Issue:** `handleTabKeyDown` handles only `ArrowRight`/`ArrowLeft`, and the next/prev computation is a two-state toggle that does not wrap (ArrowRight on the Settings tab is a no-op rather than wrapping to Members). The WAI-ARIA tabs pattern requires `Home`/`End` to jump to first/last tab. `admin.spec.ts` (lines 134-152) only exercises the Arrow keys, so this gap is untested and ships a half-pattern.
**Fix:** Handle `Home`/`End` and decide wrap behavior explicitly:
```tsx
const order = ['members', 'settings'] as const;
const idx = order.indexOf(current);
let next: typeof order[number] | null = null;
if (e.key === 'ArrowRight') next = order[(idx + 1) % order.length];
else if (e.key === 'ArrowLeft') next = order[(idx - 1 + order.length) % order.length];
else if (e.key === 'Home') next = order[0];
else if (e.key === 'End') next = order[order.length - 1];
if (next) { e.preventDefault(); setActiveTab(next); /* focus #admin-tab-${next} */ }
```
### WR-03: Success toast does not re-announce repeated identical messages
**File:** `apps/pwa/src/routes/AdminPage.tsx:62-69`, `1028-1065`
**Issue:** The toast is a single `role="status" aria-live="polite"` region rendering the `toast` string. If the same message fires twice (two password resets, two "Member added.") and the second `setToast('…')` lands before the first cleared, React's state-equality short-circuit means the DOM text does not change, so `aria-live` does not re-announce — the second success is silent to screen-reader users, and the 3s auto-dismiss timer (keyed on `toast` identity) does not reset for an identical string.
**Fix:** Make each toast a distinct value and remount it so AT re-announces and the timer resets:
```tsx
const [toast, setToast] = useState<{ id: number; msg: string } | null>(null);
const show = (msg: string) => setToast({ id: Date.now(), msg });
// effect dep: [toast?.id]; render: <div key={toast.id} role="status" ...>{toast.msg}</div>
```
### WR-04: Toast `whiteSpace: nowrap` is a latent horizontal-overflow regression against Rule 2
**File:** `apps/pwa/src/routes/AdminPage.tsx:1054-1055`
**Issue:** The toast sets `whiteSpace: 'nowrap'` with `maxWidth: '90vw'`. `nowrap` + `maxWidth` does not shrink text; it overflows. A longer/localized toast on a 390px viewport will exceed 90vw and, because the toast is `position: fixed`, contribute to `documentElement.scrollWidth` — violating `layout.spec.ts` Rule 2 (lines 176-200), which asserts no horizontal overflow on `/calendar` and `/lists`. Current strings are short, so the bug is latent, not active, but it is a direct hazard to the project's own quality bar.
**Fix:** Remove `whiteSpace: 'nowrap'` (let it wrap), or bound the width and use `overflow:hidden; text-overflow:ellipsis`. Wrapping is safer for a toast that may localize.
### WR-05: `matchMedia(...)` read at render time does not react to resize/orientation
**File:** `apps/pwa/src/App.tsx:64-66` (`isPhone()`), `apps/pwa/src/components/CalendarShell.tsx:74-76`, `apps/pwa/src/components/SettingsSheet.tsx:135`, `apps/pwa/src/components/CredentialSheet.tsx:156`, `apps/pwa/src/routes/AdminPage.tsx:59`, `1378-1379`
**Issue:** These components compute `phone` once per render via synchronous `matchMedia('(max-width: 767px)').matches`, with no `change` listener. Rotating an iPad across 767px (or resizing a desktop window across the breakpoint) does not trigger a re-render, so the layout (FAB vs toolbar button in `CalendarShell`, bottom-sheet vs centered modal in the sheets, content `paddingBottom` in `App`) stays stale until an unrelated state change forces a re-render. The developer profile explicitly flags resize/orientation correctness; iPad rotation is a realistic trigger for this cross-ecosystem app.
**Fix:** Use a `useMediaQuery` hook backed by `matchMedia.addEventListener('change', …)` so components re-render on breakpoint crossing; share a single `phone` value through context/hook so all call sites stay consistent.
### WR-06: Timezone combobox `aria-activedescendant`/highlight can desync after filtering
**File:** `apps/pwa/src/routes/AdminPage.tsx:819-821`, `833-837`, `839-845`, `912-919`
**Issue:** `onChange` resets `tzActiveIndex` to 0 while `ArrowDown` clamps against `filteredZones.length - 1` from the *current render closure*. With batched updates, interleavings exist where `tzActiveIndex` (and thus `aria-activedescendant={tz-opt-${tzActiveIndex}}`, line 820) references an option index that no longer exists after the filtered list shrinks (e.g., active 12, then a keystroke filters to 3 rows before re-clamp). Separately, the visual highlight uses `i === tzActiveIndex` (line 913) while `aria-selected` uses `tz === effectiveTimezoneInput` (line 919) — two different bases, so the highlighted row and the AT-announced row can disagree.
**Fix:** Derive a clamped active index in render and use it everywhere (visual + `aria-activedescendant`): `const activeIndex = Math.min(tzActiveIndex, Math.max(0, filteredZones.length - 1))`, or reset `tzActiveIndex` to 0 in a `useEffect` keyed on `tzSearch`.
### WR-07: Timezone combobox drops Tab-to-commit and relies on a fragile blur timeout
**File:** `apps/pwa/src/routes/AdminPage.tsx:838-865`, `927`
**Issue:** (1) `onKeyDown` handles ArrowUp/Down/Enter/Escape but not `Tab`. Tabbing out with the listbox open and an option highlighted moves focus to Save without committing — the input/`effectiveTimezoneInput` still holds the raw search text, so the admin can attempt to save a partial string (server 400s, but the UX is a confusing failure). (2) The `onBlur` 120ms `setTimeout` to let an option's `onClick` fire is a race; since options already `onMouseDown` `preventDefault()` (line 927), blur won't fire on option click, so the 120ms hack may be unnecessary. The `tzBlurTimer` is cleared on focus/select but not on unmount.
**Fix:** Commit the active option on `Tab` (without `preventDefault`, so focus still advances); clear `tzBlurTimer` in an unmount cleanup effect; reassess/remove the 120ms blur delay now that `onMouseDown` preventDefault is in place.
### WR-08: `pwa:icons` script is non-portable and silently coupled to generated filenames
**File:** `apps/pwa/package.json:16`
**Issue:** `pwa:icons` chains the assets generator with five `cp` commands. (1) `cp` is Unix-only — breaks on Windows contributors and minimal CI containers. (2) It hard-codes the generator's output names (`pwa-192x192.png`, `maskable-icon-512x512.png`, `apple-touch-icon-180x180.png`); a generator version bump that renames outputs breaks it with an opaque `cp: cannot stat`. (3) The manifest icon entries in `vite.config.ts:38-42` (`/icon-192.png`, etc.) only stay in sync because of these manual renames — an invisible coupling with no test. Regenerating icons without running the full script leaves the manifest referencing stale files.
**Fix:** Configure the generator to emit the final filenames directly, or replace the `cp` chain with a small cross-platform Node script (`fs.copyFileSync`). At minimum, add a comment in `vite.config.ts` by the icon entries pointing at the `pwa:icons` rename step so the coupling is discoverable.
## Info
### IN-01: `OidcRedirect` navigates as a render-phase side effect
**File:** `apps/pwa/src/App.tsx:77-80`
**Issue:** `OidcRedirect` calls `window.location.replace('/api/login')` directly in the function body (render phase). React may render a component more than once (StrictMode double-invoke in dev, concurrent re-renders); side effects in render are an anti-pattern. It works because `replace` is idempotent and the page unloads, but it is fragile.
**Fix:** Move the navigation into `useEffect(() => { window.location.replace('/api/login'); }, [])` and render the placeholder.
### IN-02: Inconsistent `exhaustive-deps` disables across sibling dialogs
**File:** `apps/pwa/src/components/CredentialSheet.tsx:95`, `apps/pwa/src/components/SettingsSheet.tsx:605`
**Issue:** The Escape `useEffect` disables `react-hooks/exhaustive-deps` (because `handleClose` is referenced but not listed), while the `LinkOidcSheet`/`ResetPasswordSheet` versions list `[isOpen, onClose]` with no disable. The blanket disable also hides any future missing dep added to that effect.
**Fix:** Wrap `handleClose` in `useCallback` and add it to the dep array, removing the disable; make the pattern consistent across all sheets.
### IN-03: `isPhone`/`phone` 767px check duplicated across ~6 sites
**File:** `apps/pwa/src/App.tsx:64-66`, `CalendarShell.tsx:74-76`, `SettingsSheet.tsx:135`, `CredentialSheet.tsx:156`, `AdminPage.tsx:59`, `1378-1379`
**Issue:** The same breakpoint check is reimplemented in two spellings (`isPhone()` helper vs inline `matchMedia`), and the JS hard-codes `767` while `tokens.css` declares `--bp-tablet: 768px`. Drift risk if the breakpoint changes.
**Fix:** Extract one `useIsPhone()` hook (ideally the resize-aware one from WR-05) and import it everywhere; reference the breakpoint in a single place.
### IN-04: Dead placeholder brand tokens retained
**File:** `apps/pwa/src/styles/tokens.css:103-104,107`
**Issue:** `--brand-logo-bg`, `--brand-logo-text`, and `--brand-app-name` are leftovers from the Phase 19 "FS initials circle." BrandSlot now renders `logo.svg` and reads only `--brand-logo-size`/`--brand-logo-border-radius`; `--brand-app-name` is commented "drives doc only — not used as CSS content." These are dead declarations.
**Fix:** Remove them, or add a comment that they're retained intentionally for a planned fallback.
### IN-05: Admin members-panel JSX has inconsistent indentation / stacked bottom margins
**File:** `apps/pwa/src/routes/AdminPage.tsx:364-418`
**Issue:** Inside `admin-panel-members`, `<section aria-label="Members">` and its children are indented inconsistently (section at one level, children shallower), and both the Members and Local Accounts sections carry `marginBottom: var(--space-8)`, adding trailing space at the panel boundary. Cosmetic, but will trip future edits.
**Fix:** Reformat the panel JSX — Prettier should normalize it. Confirm `pnpm --filter @familysync/pwa lint`/format was run (a recurring pre-push gate on this project).
### IN-06: Toast and dialog `zIndex` overlap (300/301)
**File:** `apps/pwa/src/routes/AdminPage.tsx:1033` (toast 300) vs `CredentialSheet.tsx:169,189` (backdrop 300 / sheet 301), ResetPasswordSheet (300/301)
**Issue:** The toast shares `zIndex: 300` with the sheet backdrops. If a toast lingers while a sheet opens within the 3s window, paint order becomes DOM-order-dependent and the toast can render under the backdrop dim. Low likelihood, but the z-index scale is not cleanly layered.
**Fix:** Put the toast above dialogs (e.g. `zIndex: 400`) and document a named z-index scale (backdrop/sheet/toast) in `tokens.css`.
### IN-07: `Intl.DateTimeFormat()` recomputed every render in the calendar-config path
**File:** `apps/pwa/src/components/CalendarShell.tsx:159`, `apps/pwa/src/routes/AdminPage.tsx:146`
**Issue:** `Intl.DateTimeFormat().resolvedOptions().timeZone` is called inline in render. Cheap, but in `CalendarShell` it feeds `useCalendarApp` config, whose stability the file's own comments warn about. (Flagged as a note, not a perf-scope item, because it touches the calendar-app config the code explicitly tries to keep stable.)
**Fix:** `const displayTimeZone = useMemo(() => Intl.DateTimeFormat().resolvedOptions().timeZone, [])`.
---
_Reviewed: 2026-06-18T00:00:00Z_
_Reviewer: Claude (gsd-code-reviewer)_
_Depth: deep_
@@ -0,0 +1,95 @@
---
phase: 17-ui-optimization-polish
reviewed: 2026-06-18T00:00:00Z
depth: deep
files_reviewed: 16
files_reviewed_list:
- apps/pwa/e2e/admin.spec.ts
- apps/pwa/e2e/layout.spec.ts
- apps/pwa/index.html
- apps/pwa/package.json
- apps/pwa/pwa-assets.config.ts
- apps/pwa/scripts/copy-pwa-icons.mjs
- apps/pwa/src/App.tsx
- apps/pwa/src/components/BrandSlot.tsx
- apps/pwa/src/components/CalendarShell.tsx
- apps/pwa/src/components/CredentialSheet.tsx
- apps/pwa/src/components/SettingsSheet.tsx
- apps/pwa/src/hooks/useFocusTrap.ts
- apps/pwa/src/hooks/useIsPhone.ts
- apps/pwa/src/routes/AdminPage.tsx
- apps/pwa/src/styles/tokens.css
- apps/pwa/vite.config.ts
findings:
critical: 0
warning: 1
info: 3
total: 4
status: issues_found
---
# Phase 17: Code Review Report (Re-Review After Auto-Fix)
**Reviewed:** 2026-06-18T00:00:00Z
**Depth:** deep
**Files Reviewed:** 16
**Status:** issues_found
## Summary
This is a re-review of Phase 17 (UI optimization/polish) after auto-fixes were applied to the prior 15 findings (8 warnings, 7 info). I re-read every listed file at deep depth, traced the just-changed code (focus-trap wiring across the 5 sheets, the new `useIsPhone`/`useFocusTrap` hooks, admin tab keyboard handling, the toast re-announce/wrapping changes, the timezone combobox active-index/Tab-commit logic, and the cross-platform icon-copy script), and confirmed the fixes against the surrounding call sites for regressions.
**All 8 prior warnings and all 7 prior info items are correctly resolved.** Both local gates pass clean: `pnpm --filter @familysync/pwa typecheck` (tsc + e2e tsconfig) and `pnpm --filter @familysync/pwa lint` (eslint `--max-warnings 0`) both succeed with no output. I found **no BLOCKERs** and **no regressions** introduced by the fixes.
What the fixes got right and why they don't regress:
- **Focus trap** (`useFocusTrap`) is wired into all five dialogs. The child sheets (`ChangePasswordSheet`/`LinkOidcSheet`) are rendered as DOM **siblings** of the `SettingsSheet` dialog div (after the `</div>` at SettingsSheet.tsx:563), not descendants, so the parent trap's `querySelectorAll` cannot capture child-sheet focusables and there is no double-trap conflict — each sheet owns its own trap.
- **Combobox desync** is genuinely fixed: `tzActiveIndexClamped` now drives the visual highlight (AdminPage.tsx:955), `aria-activedescendant` (:838), and the Enter/Tab commit (:879/:887) from one clamped source. `aria-selected` correctly stays bound to `effectiveTimezoneInput` (:961) — that is the right ARIA distinction (selected value vs. active option), not a residual bug.
- **Tab-to-commit** commits the clamped option without `preventDefault`, the blur timer is now `setTimeout(…, 0)` and is cleared on focus, on select, and on unmount (AdminPage.tsx:115-119) — no setState-after-unmount path remains.
- **Toast** is keyed on a unique `{id, msg}` so identical repeats remount and `aria-live` re-announces; `whiteSpace: nowrap` is removed so it wraps within `maxWidth: 90vw` (no Rule 2 overflow hazard); z-index raised to 400, above all sheet backdrops (max 303), resolving the prior overlap.
- **`useIsPhone`/`useMediaQuery`** subscribe via `addEventListener('change', …)` and are now used at every former inline `matchMedia` site (App, CalendarShell, all sheets, AdminPage), so iPad rotation across 767px reflows correctly. SSR guard returns `false` cleanly.
The one remaining WARNING is a pre-existing focus-trap robustness gap (not introduced this phase, but now load-bearing because `aria-modal` promises containment). The three INFO items are minor and non-blocking.
## Warnings
### WR-01: `useFocusTrap` only wraps at the boundaries — focus can still escape via hidden/zero-size focusables
**File:** `apps/pwa/src/hooks/useFocusTrap.ts:25-48`
**Issue:** The trap queries `button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])` and filters only `!disabled` and `tabindex !== '-1'`. It does not exclude elements that are `display:none`, `visibility:hidden`, `hidden`, or zero-size. In the current sheets every focusable is visible, so the trap works today. But the pattern has two latent escape paths: (1) if a dialog ever conditionally renders a focusable inside a `hidden`/collapsed block, that element joins the `first`/`last` computation and the wrap math targets an unfocusable node — `last.focus()` becomes a no-op and Tab leaks to background content (which `aria-modal="true"` asserts is impossible); (2) the trap only intervenes at the exact first/last boundary, so it relies on the browser's natural Tab order between them being correct and contained. This is the kind of half-implemented trap the prior WR-01 set out to eliminate; the fix is correct for the present DOM but fragile for future edits.
**Fix:** Filter to genuinely focusable, rendered elements before computing first/last, e.g.:
```ts
.filter((el) => {
if (el.hasAttribute('disabled') || el.getAttribute('tabindex') === '-1') return false;
if (el.hasAttribute('hidden') || (el as HTMLElement).offsetParent === null) return false;
const r = el.getBoundingClientRect();
return r.width > 0 && r.height > 0;
});
```
Alternatively, document that all dialog focusables must be unconditionally rendered and visible while the dialog is open.
## Info
### IN-01: Focus trap does not pull focus back when `activeElement` is already outside the dialog
**File:** `apps/pwa/src/hooks/useFocusTrap.ts:36-47`
**Issue:** The handler wraps only when `document.activeElement === first` (Shift+Tab) or `=== last` (Tab). Each sheet focuses its heading/close button on open, so the trap engages from inside. But `aria-modal="true"` does not actually prevent the background DOM (still mounted behind the backdrop) from receiving focus — e.g. a programmatic focus, or a browser quirk, could land focus outside the dialog, and then neither boundary condition matches, so Tab moves through background content until it happens to re-enter. This is the residual weakness of a boundary-only trap versus a containment trap (which checks `dialogRef.current.contains(document.activeElement)` and redirects when false). Low likelihood given the open-focus behavior; noted for completeness.
**Fix:** Add a containment guard: if `!dialogRef.current.contains(document.activeElement)` on Tab, `preventDefault()` and focus `first`.
### IN-02: `favicon.ico` is referenced by `index.html` but not produced by `pwa:icons`
**File:** `apps/pwa/index.html:7`, `apps/pwa/scripts/copy-pwa-icons.mjs:21-27`
**Issue:** `index.html` links `/favicon.ico`, and the file is committed in `public/` (967 bytes). The new `copy-pwa-icons.mjs` `COPIES` table generates `favicon.svg` (from `logo.svg`) and the PNGs, but the `minimal2023Preset` does not emit a `.ico`, so `favicon.ico` is hand-maintained outside the script. This is the same invisible-coupling class the prior WR-08 flagged, just narrowed: regenerating icons leaves `favicon.ico` stale relative to a new brand mark, with nothing to catch it. The script's own header says "Keep COPIES in sync with the manifest," but the `.ico` link in `index.html` has no such pointer.
**Fix:** Either drop the `favicon.ico` link (the SVG favicon + `sizes="any"` covers modern browsers) or add a comment in `copy-pwa-icons.mjs`/`index.html` noting `favicon.ico` is hand-maintained and must be regenerated manually when the brand mark changes.
### IN-03: `OidcRedirect` placeholder renders an empty `aria-hidden` div for a full render cycle
**File:** `apps/pwa/src/App.tsx:74-82`
**Issue:** The prior IN-01 fix correctly moved the navigation into `useEffect`. The component now renders `<div aria-hidden="true" />` and the redirect fires post-commit. For the OIDC-only-mode unauthenticated path this means a brief blank frame before `window.location.replace('/api/login')` unloads the page. Functionally fine and a strict improvement over the render-phase side effect, but the blank `aria-hidden` div gives screen-reader/keyboard users no "redirecting…" affordance during the gap.
**Fix:** Render a minimal visible "Redirecting to sign in…" status (e.g. `role="status"`) instead of an empty `aria-hidden` div, so the transition is perceivable if the redirect is slow.
---
_Reviewed: 2026-06-18T00:00:00Z_
_Reviewer: Claude (gsd-code-reviewer)_
_Depth: deep_
_Re-review: prior 15 findings all confirmed resolved; gates (typecheck + lint) pass clean_
@@ -2,166 +2,127 @@
phase: 17-ui-optimization-polish
reviewed: 2026-06-18T00:00:00Z
depth: deep
files_reviewed: 13
files_reviewed: 16
files_reviewed_list:
- apps/pwa/e2e/admin.spec.ts
- apps/pwa/e2e/layout.spec.ts
- apps/pwa/index.html
- apps/pwa/package.json
- apps/pwa/pwa-assets.config.ts
- apps/pwa/scripts/copy-pwa-icons.mjs
- apps/pwa/src/App.tsx
- apps/pwa/src/components/BrandSlot.tsx
- apps/pwa/src/components/CalendarShell.tsx
- apps/pwa/src/components/CredentialSheet.tsx
- apps/pwa/src/components/SettingsSheet.tsx
- apps/pwa/src/hooks/useFocusTrap.ts
- apps/pwa/src/hooks/useIsPhone.ts
- apps/pwa/src/routes/AdminPage.tsx
- apps/pwa/src/styles/tokens.css
- apps/pwa/vite.config.ts
findings:
critical: 0
warning: 8
info: 7
total: 15
critical: 1
warning: 0
info: 1
total: 2
status: issues_found
---
# Phase 17: Code Review Report
# Phase 17: Code Review Report (Final Re-Review After Iter-3 Auto-Fixes)
**Reviewed:** 2026-06-18T00:00:00Z
**Depth:** deep
**Files Reviewed:** 13
**Files Reviewed:** 16
**Status:** issues_found
## Summary
Phase 17 is UI optimization/polish: brand logo swap, PWA manifest/icon hand-maintenance, an admin two-tab ARIA strip, a success toast, and a searchable timezone combobox, plus structural layout/admin Playwright suites. No structural-findings pre-pass was provided.
This is the final re-review of Phase 17 after the second round of auto-fixes, which targeted the three iter-3 findings:
The code is generally careful — XSS surfaces are plain-text JSX, password fields use `new-password` autocomplete and are never pre-filled, the OIDC `authorizationUrl` null is guarded before navigation, and touch targets are consistently ≥44px. I found **no BLOCKERs** (no injection, no secret leakage, no data-loss path, no crash on the happy path).
- **WR-01**`useFocusTrap` hidden/zero-size focusable exclusion + containment guard.
- **IN-03**`App.tsx` `OidcRedirect` now renders a visible "Redirecting to sign in…" status.
- **IN-02** — favicon.ico hand-maintained coupling documented in `copy-pwa-icons.mjs` and `index.html`.
There are real correctness/robustness defects worth fixing before ship: the **admin tab keyboard handler half-implements the WAI-ARIA tabs pattern** (no Home/End, no wrap); the **timezone combobox `aria-activedescendant`/highlight can desync after filtering** and **drops Tab-to-commit**; the **success toast does not re-announce** repeated identical messages and its `whiteSpace: nowrap` is a latent Rule-2 horizontal-overflow hazard against the project's own layout suite; **none of the modal dialogs trap focus** despite `aria-modal="true"`; and several `window.matchMedia` reads at render time **do not react to resize/orientation**, a stale-UI class this project explicitly cares about (iPad rotation).
I re-read every listed file at deep depth and traced the changed code against its call sites and the existing test suite.
## Warnings
**Two of the three fixes are correct and regression-free:**
- **IN-03 (OidcRedirect):** Correct. The navigation stays in `useEffect` (no render-phase side effect), and the placeholder is now a perceivable `role="status"` "Redirecting to sign in…" (App.tsx:83-97). No regression.
- **IN-02 (favicon.ico coupling):** Correct and complete. Both `copy-pwa-icons.mjs` (lines 13-17) and `index.html` (line 7) now carry the hand-maintained-`.ico` pointer. Verified on disk: `favicon.svg` is byte-identical to `logo.svg` (produced by the `COPIES` table), and `favicon.ico` (967 B) is committed separately. The invisible coupling is now documented at both ends.
### WR-01: Modal dialogs declare `aria-modal="true"` but do not trap focus
**The WR-01 fix introduces a CR-tier regression.** The new visibility filter in `useFocusTrap.ts` (lines 36-38) relies on `offsetParent` and `getBoundingClientRect()` width/height. Both are `null`/`0` under jsdom — the environment the existing focus-trap unit tests run in — so the filter now excludes **every** focusable, `focusable.length === 0` short-circuits, and the trap silently stops wrapping focus. This breaks the two pre-existing `EventForm.test.tsx` WR-07 tests and makes `pnpm test` (a CI gate per CLAUDE.md) fail.
**File:** `apps/pwa/src/components/CredentialSheet.tsx:173-176`, `apps/pwa/src/components/SettingsSheet.tsx:199-202` (plus ChangePasswordSheet ~667-701 and LinkOidcSheet ~979-1013), `apps/pwa/src/routes/AdminPage.tsx:1420-1423` (ResetPasswordSheet)
**Issue:** Every sheet sets `role="dialog"` + `aria-modal="true"` and focuses the heading/close button on open, but none implements a focus trap. Tab/Shift-Tab can move focus out of the dialog to content behind the backdrop (still in the DOM). `aria-modal="true"` asserts to assistive tech that focus is contained — it is not. The app's stated UX hard-constraint is "slick and low-friction for a non-technical Apple member"; VoiceOver/keyboard users will escape the dialog silently and interact with occluded background controls.
**Fix:** Add a focus trap — capture Tab/Shift-Tab in the dialog keydown handler and cycle between first/last focusable descendants, ideally as a shared `useFocusTrap(ref)` hook reused by all sheets:
```tsx
onKeyDown={(e) => {
if (e.key !== 'Tab') return;
const f = dialogRef.current?.querySelectorAll<HTMLElement>(
'a[href],button:not([disabled]),input:not([disabled]),[tabindex]:not([tabindex="-1"])');
if (!f?.length) return;
const first = f[0], last = f[f.length - 1];
if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
}}
Gate status after the fixes:
- `pnpm --filter @familysync/pwa typecheck`**pass** (tsc + e2e tsconfig).
- `pnpm --filter @familysync/pwa lint`**pass** (eslint `--max-warnings 0`).
- `pnpm --filter @familysync/pwa test` (vitest) → **FAIL**: 2 failed / 264 passed / 266 total. Both failures are the WR-07 focus-trap tests, caused directly by the WR-01 change under review.
The IN-01 containment guard added alongside WR-01 is functionally inert (the handler is only wired to the dialog's `onKeyDown`, which cannot fire when focus is outside the dialog), but it is harmless — recorded as INFO.
## Critical Issues
### CR-01: `useFocusTrap` visibility filter excludes all focusables under jsdom — breaks the focus-trap test suite (CI gate fails)
**File:** `apps/pwa/src/hooks/useFocusTrap.ts:36-38`
**Issue:** The WR-01 fix added a "rendered/visible" filter to the focusable query:
```ts
if (el.hasAttribute('hidden') || el.offsetParent === null) return false;
const r = el.getBoundingClientRect();
return r.width > 0 && r.height > 0;
```
In a real browser this is correct. But the existing focus-trap regression tests (`apps/pwa/src/components/EventForm.test.tsx`, the two `WR-07` cases at lines 718-764) run under **jsdom**, where:
- `getBoundingClientRect()` returns all-zero geometry for every element (`width === 0`, `height === 0`), and
- `offsetParent` is `null` for every element.
Either condition alone causes the filter to reject **every** focusable. `focusable.length` becomes `0`, the handler hits the `if (focusable.length === 0) return;` early-out (line 41), and Tab/Shift+Tab no longer wrap. Both WR-07 tests now fail:
```
FAIL src/components/EventForm.test.tsx > WR-07: Tab from last focusable element wraps focus to first inside dialog
FAIL src/components/EventForm.test.tsx > WR-07: Shift+Tab from first focusable element wraps focus to last inside dialog
Test Files 1 failed | 21 passed (22)
Tests 2 failed | 264 passed (266)
```
### WR-02: Admin tab strip keyboard nav is incomplete (no Home/End, no explicit wrap)
`pnpm test` (→ `vitest run`) is one of the CI gates the project requires to pass before push (CLAUDE.md "Frustrations" directive + the CI-checks-conformance memory). This regression ships as part of the file under review, so it is in scope even though `EventForm.test.tsx` is not in the listed-files set — those tests exist specifically to guard the changed behavior.
**File:** `apps/pwa/src/routes/AdminPage.tsx:205-225`
**Issue:** `handleTabKeyDown` handles only `ArrowRight`/`ArrowLeft`, and the next/prev computation is a two-state toggle that does not wrap (ArrowRight on the Settings tab is a no-op rather than wrapping to Members). The WAI-ARIA tabs pattern requires `Home`/`End` to jump to first/last tab. `admin.spec.ts` (lines 134-152) only exercises the Arrow keys, so this gap is untested and ships a half-pattern.
**Fix:** Handle `Home`/`End` and decide wrap behavior explicitly:
```tsx
const order = ['members', 'settings'] as const;
const idx = order.indexOf(current);
let next: typeof order[number] | null = null;
if (e.key === 'ArrowRight') next = order[(idx + 1) % order.length];
else if (e.key === 'ArrowLeft') next = order[(idx - 1 + order.length) % order.length];
else if (e.key === 'Home') next = order[0];
else if (e.key === 'End') next = order[order.length - 1];
if (next) { e.preventDefault(); setActiveTab(next); /* focus #admin-tab-${next} */ }
**Fix:** Make the visibility filter tolerant of a non-layout (jsdom) environment, so it excludes genuinely hidden nodes in a browser without nuking all nodes in tests. Treat zero-geometry as "visible" when no layout engine is present, and gate on `offsetParent` only when geometry is meaningful:
```ts
.filter((el) => {
if (el.hasAttribute('disabled') || el.getAttribute('tabindex') === '-1') return false;
if (el.hasAttribute('hidden')) return false;
// jsdom has no layout: getBoundingClientRect() is all-zero and offsetParent is
// null for every node. Only apply the visibility heuristic when a real layout
// exists, so unit tests still see focusables.
const r = el.getBoundingClientRect();
const hasLayout = r.width > 0 || r.height > 0 || el.offsetParent !== null;
if (!hasLayout) return true; // no layout engine → don't filter on visibility
if (el.offsetParent === null) return false;
return r.width > 0 && r.height > 0;
});
```
### WR-03: Success toast does not re-announce repeated identical messages
**File:** `apps/pwa/src/routes/AdminPage.tsx:62-69`, `1028-1065`
**Issue:** The toast is a single `role="status" aria-live="polite"` region rendering the `toast` string. If the same message fires twice (two password resets, two "Member added.") and the second `setToast('…')` lands before the first cleared, React's state-equality short-circuit means the DOM text does not change, so `aria-live` does not re-announce — the second success is silent to screen-reader users, and the 3s auto-dismiss timer (keyed on `toast` identity) does not reset for an identical string.
**Fix:** Make each toast a distinct value and remount it so AT re-announces and the timer resets:
```tsx
const [toast, setToast] = useState<{ id: number; msg: string } | null>(null);
const show = (msg: string) => setToast({ id: Date.now(), msg });
// effect dep: [toast?.id]; render: <div key={toast.id} role="status" ...>{toast.msg}</div>
```
### WR-04: Toast `whiteSpace: nowrap` is a latent horizontal-overflow regression against Rule 2
**File:** `apps/pwa/src/routes/AdminPage.tsx:1054-1055`
**Issue:** The toast sets `whiteSpace: 'nowrap'` with `maxWidth: '90vw'`. `nowrap` + `maxWidth` does not shrink text; it overflows. A longer/localized toast on a 390px viewport will exceed 90vw and, because the toast is `position: fixed`, contribute to `documentElement.scrollWidth` — violating `layout.spec.ts` Rule 2 (lines 176-200), which asserts no horizontal overflow on `/calendar` and `/lists`. Current strings are short, so the bug is latent, not active, but it is a direct hazard to the project's own quality bar.
**Fix:** Remove `whiteSpace: 'nowrap'` (let it wrap), or bound the width and use `overflow:hidden; text-overflow:ellipsis`. Wrapping is safer for a toast that may localize.
### WR-05: `matchMedia(...)` read at render time does not react to resize/orientation
**File:** `apps/pwa/src/App.tsx:64-66` (`isPhone()`), `apps/pwa/src/components/CalendarShell.tsx:74-76`, `apps/pwa/src/components/SettingsSheet.tsx:135`, `apps/pwa/src/components/CredentialSheet.tsx:156`, `apps/pwa/src/routes/AdminPage.tsx:59`, `1378-1379`
**Issue:** These components compute `phone` once per render via synchronous `matchMedia('(max-width: 767px)').matches`, with no `change` listener. Rotating an iPad across 767px (or resizing a desktop window across the breakpoint) does not trigger a re-render, so the layout (FAB vs toolbar button in `CalendarShell`, bottom-sheet vs centered modal in the sheets, content `paddingBottom` in `App`) stays stale until an unrelated state change forces a re-render. The developer profile explicitly flags resize/orientation correctness; iPad rotation is a realistic trigger for this cross-ecosystem app.
**Fix:** Use a `useMediaQuery` hook backed by `matchMedia.addEventListener('change', …)` so components re-render on breakpoint crossing; share a single `phone` value through context/hook so all call sites stay consistent.
### WR-06: Timezone combobox `aria-activedescendant`/highlight can desync after filtering
**File:** `apps/pwa/src/routes/AdminPage.tsx:819-821`, `833-837`, `839-845`, `912-919`
**Issue:** `onChange` resets `tzActiveIndex` to 0 while `ArrowDown` clamps against `filteredZones.length - 1` from the *current render closure*. With batched updates, interleavings exist where `tzActiveIndex` (and thus `aria-activedescendant={tz-opt-${tzActiveIndex}}`, line 820) references an option index that no longer exists after the filtered list shrinks (e.g., active 12, then a keystroke filters to 3 rows before re-clamp). Separately, the visual highlight uses `i === tzActiveIndex` (line 913) while `aria-selected` uses `tz === effectiveTimezoneInput` (line 919) — two different bases, so the highlighted row and the AT-announced row can disagree.
**Fix:** Derive a clamped active index in render and use it everywhere (visual + `aria-activedescendant`): `const activeIndex = Math.min(tzActiveIndex, Math.max(0, filteredZones.length - 1))`, or reset `tzActiveIndex` to 0 in a `useEffect` keyed on `tzSearch`.
### WR-07: Timezone combobox drops Tab-to-commit and relies on a fragile blur timeout
**File:** `apps/pwa/src/routes/AdminPage.tsx:838-865`, `927`
**Issue:** (1) `onKeyDown` handles ArrowUp/Down/Enter/Escape but not `Tab`. Tabbing out with the listbox open and an option highlighted moves focus to Save without committing — the input/`effectiveTimezoneInput` still holds the raw search text, so the admin can attempt to save a partial string (server 400s, but the UX is a confusing failure). (2) The `onBlur` 120ms `setTimeout` to let an option's `onClick` fire is a race; since options already `onMouseDown` `preventDefault()` (line 927), blur won't fire on option click, so the 120ms hack may be unnecessary. The `tzBlurTimer` is cleared on focus/select but not on unmount.
**Fix:** Commit the active option on `Tab` (without `preventDefault`, so focus still advances); clear `tzBlurTimer` in an unmount cleanup effect; reassess/remove the 120ms blur delay now that `onMouseDown` preventDefault is in place.
### WR-08: `pwa:icons` script is non-portable and silently coupled to generated filenames
**File:** `apps/pwa/package.json:16`
**Issue:** `pwa:icons` chains the assets generator with five `cp` commands. (1) `cp` is Unix-only — breaks on Windows contributors and minimal CI containers. (2) It hard-codes the generator's output names (`pwa-192x192.png`, `maskable-icon-512x512.png`, `apple-touch-icon-180x180.png`); a generator version bump that renames outputs breaks it with an opaque `cp: cannot stat`. (3) The manifest icon entries in `vite.config.ts:38-42` (`/icon-192.png`, etc.) only stay in sync because of these manual renames — an invisible coupling with no test. Regenerating icons without running the full script leaves the manifest referencing stale files.
**Fix:** Configure the generator to emit the final filenames directly, or replace the `cp` chain with a small cross-platform Node script (`fs.copyFileSync`). At minimum, add a comment in `vite.config.ts` by the icon entries pointing at the `pwa:icons` rename step so the coupling is discoverable.
Alternatively, stub `getBoundingClientRect`/`offsetParent` in the test setup so jsdom reports non-zero geometry — but the production-side guard above is the safer minimal change, since other future tests will hit the same wall. Either way, re-run `pnpm --filter @familysync/pwa test` and confirm both WR-07 cases pass before considering this resolved.
## Info
### IN-01: `OidcRedirect` navigates as a render-phase side effect
### IN-01: Focus-trap containment guard is unreachable as wired (harmless dead branch)
**File:** `apps/pwa/src/App.tsx:77-80`
**Issue:** `OidcRedirect` calls `window.location.replace('/api/login')` directly in the function body (render phase). React may render a component more than once (StrictMode double-invoke in dev, concurrent re-renders); side effects in render are an anti-pattern. It works because `replace` is idempotent and the page unloads, but it is fragile.
**Fix:** Move the navigation into `useEffect(() => { window.location.replace('/api/login'); }, [])` and render the placeholder.
**File:** `apps/pwa/src/hooks/useFocusTrap.ts:50-54`
**Issue:** The IN-01 fix added a containment guard:
```ts
if (!dialogRef.current.contains(document.activeElement)) {
e.preventDefault();
first.focus();
return;
}
```
The comment claims this catches the case where "focus has somehow landed outside the dialog ... Tab would walk background content." But the handler is only attached to each dialog container's `onKeyDown` (verified across all 5 sheets + EventForm + SeriesEditPrompt — no `document`-level listener exists). React's synthetic `onKeyDown` on the dialog div only fires when the keydown event's target is **inside** the dialog subtree (the event must bubble up through that div). When `document.activeElement` is genuinely outside the dialog, the keydown fires on that outside element and bubbles through `document`, **not** through the dialog div — so `handleDialogKeyDown` never runs, and `dialogRef.current.contains(document.activeElement)` is effectively always `true` whenever this code executes. The guard is therefore a no-op in practice: it does not deliver the containment guarantee its comment promises.
### IN-02: Inconsistent `exhaustive-deps` disables across sibling dialogs
This is not a correctness bug (it never produces wrong behavior), so it is INFO, not a blocker. But it is worth noting that the IN-01 concern (focus escaping a boundary-only trap) is **not actually addressed** by this change.
**File:** `apps/pwa/src/components/CredentialSheet.tsx:95`, `apps/pwa/src/components/SettingsSheet.tsx:605`
**Issue:** The Escape `useEffect` disables `react-hooks/exhaustive-deps` (because `handleClose` is referenced but not listed), while the `LinkOidcSheet`/`ResetPasswordSheet` versions list `[isOpen, onClose]` with no disable. The blanket disable also hides any future missing dep added to that effect.
**Fix:** Wrap `handleClose` in `useCallback` and add it to the dep array, removing the disable; make the pattern consistent across all sheets.
### IN-03: `isPhone`/`phone` 767px check duplicated across ~6 sites
**File:** `apps/pwa/src/App.tsx:64-66`, `CalendarShell.tsx:74-76`, `SettingsSheet.tsx:135`, `CredentialSheet.tsx:156`, `AdminPage.tsx:59`, `1378-1379`
**Issue:** The same breakpoint check is reimplemented in two spellings (`isPhone()` helper vs inline `matchMedia`), and the JS hard-codes `767` while `tokens.css` declares `--bp-tablet: 768px`. Drift risk if the breakpoint changes.
**Fix:** Extract one `useIsPhone()` hook (ideally the resize-aware one from WR-05) and import it everywhere; reference the breakpoint in a single place.
### IN-04: Dead placeholder brand tokens retained
**File:** `apps/pwa/src/styles/tokens.css:103-104,107`
**Issue:** `--brand-logo-bg`, `--brand-logo-text`, and `--brand-app-name` are leftovers from the Phase 19 "FS initials circle." BrandSlot now renders `logo.svg` and reads only `--brand-logo-size`/`--brand-logo-border-radius`; `--brand-app-name` is commented "drives doc only — not used as CSS content." These are dead declarations.
**Fix:** Remove them, or add a comment that they're retained intentionally for a planned fallback.
### IN-05: Admin members-panel JSX has inconsistent indentation / stacked bottom margins
**File:** `apps/pwa/src/routes/AdminPage.tsx:364-418`
**Issue:** Inside `admin-panel-members`, `<section aria-label="Members">` and its children are indented inconsistently (section at one level, children shallower), and both the Members and Local Accounts sections carry `marginBottom: var(--space-8)`, adding trailing space at the panel boundary. Cosmetic, but will trip future edits.
**Fix:** Reformat the panel JSX — Prettier should normalize it. Confirm `pnpm --filter @familysync/pwa lint`/format was run (a recurring pre-push gate on this project).
### IN-06: Toast and dialog `zIndex` overlap (300/301)
**File:** `apps/pwa/src/routes/AdminPage.tsx:1033` (toast 300) vs `CredentialSheet.tsx:169,189` (backdrop 300 / sheet 301), ResetPasswordSheet (300/301)
**Issue:** The toast shares `zIndex: 300` with the sheet backdrops. If a toast lingers while a sheet opens within the 3s window, paint order becomes DOM-order-dependent and the toast can render under the backdrop dim. Low likelihood, but the z-index scale is not cleanly layered.
**Fix:** Put the toast above dialogs (e.g. `zIndex: 400`) and document a named z-index scale (backdrop/sheet/toast) in `tokens.css`.
### IN-07: `Intl.DateTimeFormat()` recomputed every render in the calendar-config path
**File:** `apps/pwa/src/components/CalendarShell.tsx:159`, `apps/pwa/src/routes/AdminPage.tsx:146`
**Issue:** `Intl.DateTimeFormat().resolvedOptions().timeZone` is called inline in render. Cheap, but in `CalendarShell` it feeds `useCalendarApp` config, whose stability the file's own comments warn about. (Flagged as a note, not a perf-scope item, because it touches the calendar-app config the code explicitly tries to keep stable.)
**Fix:** `const displayTimeZone = useMemo(() => Intl.DateTimeFormat().resolvedOptions().timeZone, [])`.
**Fix:** If true containment is desired, move the trap to a `document`-level `keydown` (or `focusin`) listener mounted while the dialog is open, so it can intercept Tab/focus originating outside the dialog. If the boundary-only trap is considered sufficient (it is, for the present always-focus-the-heading-on-open flows), drop the unreachable containment branch and its comment to avoid implying a guarantee the code does not provide.
---
_Reviewed: 2026-06-18T00:00:00Z_
_Reviewer: Claude (gsd-code-reviewer)_
_Depth: deep_
_Re-review: IN-02 + IN-03 fixes confirmed clean; WR-01 fix regresses the focus-trap test suite (CR-01) and its IN-01 containment guard is inert. typecheck + lint pass; `pnpm test` FAILS (2 WR-07 tests)._
@@ -0,0 +1,92 @@
---
phase: 17
slug: ui-optimization-polish
status: verified
threats_open: 0
asvs_level: 1
created: 2026-06-18
---
# Phase 17 — Security
> Per-phase security contract: threat register, accepted risks, and audit trail.
Phase 17 is a UI optimization & polish phase. Every plan carried a plan-time
`<threat_model>` block (`register_authored_at_plan_time: true`). The work is
client-side CSS/layout, static brand-asset wiring, and presentation-only React
state — no new endpoints, no new authorization logic, no new runtime data flow.
The single non-`accept` threat (logout wiring) reuses an endpoint already
verified live in Phase 19.
---
## Trust Boundaries
| Boundary | Description | Data Crossing |
|----------|-------------|---------------|
| Build tooling → repo (17-02) | `@vite-pwa/assets-generator` (+ sharp, sharp-ico) runs at design time and writes static images into `public/`. New devDependency = supply-chain surface. | Static image bytes; no secrets/PII |
| Client UI → existing logout endpoint (17-05) | Sign out control calls the already-implemented, Phase-19-verified `POST /api/auth/local/logout` via `fetchLocalLogout()`. No new endpoint, no new auth logic. | Session cookie (cleared server-side) |
| (none new) — 17-01, 17-03, 17-04, 17-06 | CSS-only restructure/offsets, static asset references, and presentation-only local `useState` (tab/toast). Server-side admin `403` enforcement unchanged. | None |
---
## Threat Register
| Threat ID | Category | Component | Disposition | Mitigation | Status |
|-----------|----------|-----------|-------------|------------|--------|
| T-17-01-01 | Tampering | tokens.css selector restructure | accept | CSS custom properties carry no executable content and no user input; selector change cannot introduce injection. | closed |
| T-17-02-SC | Tampering | npm devDependency install (@vite-pwa/assets-generator, sharp, sharp-ico) | accept | RESEARCH Package Legitimacy Audit rates all three Approved (official vite-pwa, 13-yr sharp, sharp-ico); no `[SLOP]`/unverified packages. devDependencies only; generated output is static images. | closed |
| T-17-02-02 | Information disclosure | generated brand assets | accept | Assets are public-by-design brand images; no secrets or PII. | closed |
| T-17-03-01 | Tampering | FAB/content CSS offsets | accept | Pure layout geometry via existing CSS custom property; no executable content, no input. | closed |
| T-17-04-01 | Tampering | BrandSlot img / index.html links | accept | Logo img is decorative with empty `alt`; no `dangerouslySetInnerHTML` (T-05-24 invariant maintained); favicon/manifest entries point at committed static files. **Verified live:** BrandSlot renders `<img src="/logo.svg" alt="" aria-hidden="true">`, no `dangerouslySetInnerHTML` in source. | closed |
| T-17-05-01 | Elevation of Privilege | logout control (D-07) | mitigate | `fetchLocalLogout()` clears the local-session cookie via the existing Phase-19-verified endpoint; client navigates to `/login` regardless of success/failure so a stale-cookie-with-logged-out-UI state cannot persist. **Verified:** `SettingsSheet.tsx:143-151``try { await fetchLocalLogout(); } catch {} onClose(); void navigate('/login');`. | closed |
| T-17-05-02 | Tampering | sheet centering CSS (D-09) | accept | Position-only CSS branch; no input, no executable content. | closed |
| T-17-06-01 | Tampering | toast message content (D-08) | accept | Toast copy is hardcoded JSX string constants ("Member added." / "Password reset."); no user-controlled content; no `dangerouslySetInnerHTML`. **Verified live:** toast rendered "Member added." from a `role=status` node on member creation. | closed |
| T-17-06-02 | Elevation of Privilege | admin two-tab nav (D-10) | accept | Tab strip is presentation-only local `useState`; `isAdmin` nav visibility is UX-only — the real boundary is server-side `403` on `/api/admin/*` (unchanged). | closed |
*Status: open · closed*
*Disposition: mitigate (implementation required) · accept (documented risk) · transfer (third-party)*
---
## Accepted Risks Log
| Risk ID | Threat Ref | Rationale | Accepted By | Date |
|---------|------------|-----------|-------------|------|
| AR-17-01 | T-17-01-01 | Static stylesheet selector restructure; zero runtime data flow. | Lucas Berger | 2026-06-18 |
| AR-17-02 | T-17-02-SC | All new devDependencies Approved by RESEARCH package-legitimacy audit; design-time only. | Lucas Berger | 2026-06-18 |
| AR-17-03 | T-17-02-02 | Brand assets are public-by-design; no secrets/PII. | Lucas Berger | 2026-06-18 |
| AR-17-04 | T-17-03-01 | Pure CSS layout geometry; no input surface. | Lucas Berger | 2026-06-18 |
| AR-17-05 | T-17-04-01 | Decorative img with empty alt; no `dangerouslySetInnerHTML`; committed static assets. | Lucas Berger | 2026-06-18 |
| AR-17-06 | T-17-05-02 | Position-only CSS branch; no input/executable content. | Lucas Berger | 2026-06-18 |
| AR-17-07 | T-17-06-01 | Hardcoded toast string constants; no user-controlled content. | Lucas Berger | 2026-06-18 |
| AR-17-08 | T-17-06-02 | Presentation-only tab state; authorization enforced server-side (unchanged). | Lucas Berger | 2026-06-18 |
*Accepted risks do not resurface in future audit runs.*
---
## Security Audit Trail
| Audit Date | Threats Total | Closed | Open | Run By |
|------------|---------------|--------|------|--------|
| 2026-06-18 | 9 | 9 | 0 | /gsd-secure-phase (orchestrator, plan-time register verification) |
Verification method: all 6 plans carried plan-time `<threat_model>` blocks
(`register_authored_at_plan_time: true`). 8 `accept`-disposition threats are
documented accepted risks; the 1 `mitigate` threat (T-17-05-01) had its
mitigation verified present in `SettingsSheet.tsx`. Several dispositions were
additionally corroborated at runtime during the Phase 17 UAT (playwright-cli):
BrandSlot decorative img, hardcoded success toast, admin tab presentation-only
state. `threats_open: 0` — short-circuit per workflow Step 3.
---
## Sign-Off
- [x] All threats have a disposition (mitigate / accept / transfer)
- [x] Accepted risks documented in Accepted Risks Log
- [x] `threats_open: 0` confirmed
- [x] `status: verified` set in frontmatter
**Approval:** verified 2026-06-18
@@ -0,0 +1,82 @@
---
status: complete
phase: 17-ui-optimization-polish
source:
- 17-01-SUMMARY.md
- 17-02-SUMMARY.md
- 17-03-SUMMARY.md
- 17-04-SUMMARY.md
- 17-05-SUMMARY.md
- 17-06-SUMMARY.md
verification_method: playwright-cli (Chromium, host Vite @5173, Docker API/DB)
started: 2026-06-18T18:26:00Z
updated: 2026-06-18T18:31:00Z
---
## Current Test
[testing complete]
## Tests
### 1. Cold Start Smoke Test
expected: App boots and `/calendar` loads with live data — calendar grid, color legend, and primary controls render without console errors.
result: pass
evidence: PWA opened at http://localhost:5173/ → redirected to /calendar (dev-bypass). June 2026 grid rendered, color legend ("Dev User" #4A90D9, "Family" #F25C7A), New Event button + Today/nav present. 0 console errors. Docker API/MariaDB/Redis up.
### 2. Login Page Branding — FamilySync logo (Plan 17-04)
expected: Login page shows the approved family-house logo (BrandSlot), not the old "FS" text placeholder.
result: pass
evidence: /login renders `<img src="/logo.svg" alt="" aria-hidden="true">` at 48px. Old `aria-hidden` "FS" placeholder div is absent. `/logo.svg` serves 200 image/svg+xml.
### 3. Favicon & Theme Color (Plan 17-04)
expected: Browser tab favicon set (SVG + ICO + apple-touch) wired; warm-amber theme color applied.
result: pass
evidence: `<link rel=icon>` for /favicon.svg (image/svg+xml) + /favicon.ico + apple-touch-icon present. All of favicon.svg/favicon.ico/apple-touch-icon.png/icon-maskable-512.png fetch 200 with correct content-types. `<meta name=theme-color>` = #e8915a.
### 4. Phone Layout Overlap Fix (Plan 17-03 / D-01)
expected: At ≤767px, the New Event FAB sits above the fixed BottomTabBar (not occluded) and the color-legend chips remain fully visible.
result: pass
evidence: @390×844 — FAB bottom=764, BottomTabBar top=788 → FAB above bar with 24px gap (= --space-6). Color legend bottom=780 < nav top=788, visible:true, not occluded. Both chips ("Dev User", "Family") present. 0 console errors.
### 5. Sign Out Control (Plan 17-05 / D-07)
expected: Settings sheet exposes a reachable "Sign out" control.
result: pass
evidence: Settings dialog (opened from "Dev User — open settings") contains Account section, "Change password", and a "Sign out" button — all reachable.
### 6. Settings Sheet Centering (Plan 17-05 / D-09)
expected: On desktop, the settings sheet renders as a centered modal (not a bottom sheet).
result: pass
evidence: Settings dialog — position:fixed, width 480px, horizontal & vertical center offset = 0 on 1280×720, aria-modal="true".
### 7. Modal Focus Trap (code-review CR-01 / WR-01 fix)
expected: With a sheet open, Tab/Shift+Tab cycle focus within the dialog and never escape to background controls.
result: pass
evidence: Settings dialog (5 focusables). Tab from last ("Sign out") → wraps to "Close settings" (still inside). Shift+Tab from first → wraps to "Sign out" (still inside). Focus stayed contained both directions. Confirms the jsdom-tolerant visibility filter works correctly in a real (laid-out) browser — resolves the code-review human-verification flag.
### 8. Admin Two-Tab Navigation (Plan 17-06 / D-10 + WR-02)
expected: Admin page shows "Members & Accounts" / "Settings" tabs with full WAI-ARIA keyboard support (arrows wrap, Home/End).
result: pass
evidence: `role=tablist` with two `role=tab`s, "Members & Accounts" selected by default, tabpanels with regions. Keyboard: ArrowRight→Settings, ArrowRight wraps→Members, ArrowLeft→Settings, Home→Members, End→Settings. All transitions update aria-selected.
### 9. Admin Success Toast (Plan 17-06 / D-08)
expected: Creating a member shows a transient success toast announced to assistive tech.
result: pass
evidence: Filled + submitted the Add-member form (throwaway "ZZ Verify Toast"); a `role=status` aria-live="polite" toast read "Member added." Throwaway member removed from the dev DB afterward (verified 0 remaining).
### 10. iOS Standalone PWA — install + home-screen icon + push
expected: Installed-to-home-screen behavior and apple-touch/maskable icon appearance on a real iOS device.
result: skipped
reason: Genuinely device-only — cannot be driven by playwright-cli/Chromium (manifest is production-only and not injected in Vite dev). Icon assets and manifest config are verified at the asset/code level (Tests 23, code review). Real-device behavior remains a human checkpoint, already tracked in 17-VERIFICATION.md.
## Summary
total: 10
passed: 9
issues: 0
skipped: 1
pending: 0
## Gaps
[none — all automated checks passed; 1 device-only item deferred to existing human checkpoints]
@@ -0,0 +1,184 @@
---
phase: 20-admin-member-editor-form-declutter
plan: 01
type: tdd
wave: 1
depends_on: []
files_modified:
- apps/api/src/routes/admin.ts
- apps/api/tests/routes/admin.test.ts
autonomous: true
requirements: []
must_haves:
truths:
- "An admin can update a member's display name and admin flag through one route behind requireAdmin"
- "Demoting the only remaining admin is rejected with a 409 and the member stays admin"
- "Self-demotion succeeds while another admin exists"
- "GET /members returns each member's isAdmin so the editor toggle has correct initial state"
artifacts:
- path: "apps/api/src/routes/admin.ts"
provides: "PATCH /api/admin/members/:id member-profile update route + isAdmin in GET /members select"
contains: "members/:id"
- path: "apps/api/tests/routes/admin.test.ts"
provides: "RED tests for last-admin guard + happy-path profile update + isAdmin in GET /members"
contains: "last-admin"
key_links:
- from: "apps/api/src/routes/admin.ts PATCH /members/:id"
to: "apps/api/src/db/schema.ts users.isAdmin"
via: "last-admin count query + partial update set()"
pattern: "users\\.isAdmin"
---
<objective>
Add the one new server route this phase needs: `PATCH /api/admin/members/:id`, accepting `displayName` and/or `isAdmin`, behind the existing `requireAdmin` boundary (D-02), and enforce the D-03 last-admin demotion guard (reject 409 when demoting the only admin). Also surface `isAdmin` from `GET /api/admin/members` so the PWA editor's admin toggle has a correct initial state.
Purpose: This is the only genuinely new backend logic in Phase 20. The last-admin guard is a lockout-safety invariant with a defined request/response contract — written test-first (RED -> GREEN -> REFACTOR).
Output: A tested `PATCH /members/:id` route + `isAdmin` field on `GET /members`, both reachable only by admins.
</objective>
<execution_context>
@$HOME/.claude/gsd-core/workflows/execute-plan.md
@$HOME/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/20-admin-member-editor-form-declutter/20-CONTEXT.md
@.planning/phases/20-admin-member-editor-form-declutter/20-PATTERNS.md
</context>
<artifacts_produced>
## Artifacts this phase produces (Plan 20-01)
- `PATCH /api/admin/members/:id` route handler in `apps/api/src/routes/admin.ts` (member-profile update: displayName and/or isAdmin)
- `updateMemberSchema` Zod schema in `apps/api/src/routes/admin.ts`
- `isAdmin` field added to the `GET /api/admin/members` select + mapped member object in `apps/api/src/routes/admin.ts`
- New `describe` block for `PATCH /members/:id` in `apps/api/tests/routes/admin.test.ts` (last-admin guard, happy path, auth boundary, validation, 404)
</artifacts_produced>
<tasks>
<task type="tdd" tdd="true">
<name>Task 1: RED — failing tests for the member-profile route + isAdmin read</name>
<files>apps/api/tests/routes/admin.test.ts</files>
<read_first>
- apps/api/tests/routes/admin.test.ts (full — copy the jsonRequest helper, admin/non-admin session setup, member-create flow at lines ~920-990, and the existing password-reset test at line ~956 as the structural analog)
- apps/api/src/routes/admin.ts (lines 95-130 GET /members handler; lines 225-269 POST /members/:id/password as the route analog; lines 75-92 noEchoHook + parsePositiveIntParam)
- apps/api/src/auth/user.ts (lines 140-156 — the admin-count query to adapt for the guard)
- apps/api/src/db/schema.ts (line ~56 users.isAdmin column)
- .planning/phases/20-admin-member-editor-form-declutter/20-CONTEXT.md (D-02, D-03)
</read_first>
<behavior>
- Test A (happy path displayName): PATCH /api/admin/members/:id with { displayName: 'New Name' } as an admin -> 200; GET /members reflects the new displayName.
- Test B (happy path isAdmin promote): a non-admin member PATCH'd with { isAdmin: true } -> 200; GET /members shows isAdmin true for that member.
- Test C (last-admin guard): with exactly ONE admin in the DB, PATCH that admin with { isAdmin: false } -> 409, body has an `error` string; GET /members still shows that member isAdmin true (unchanged).
- Test D (self-demotion allowed when another admin exists): seed two admins, PATCH one with { isAdmin: false } -> 200; GET /members shows one admin remaining.
- Test E (auth boundary): PATCH /api/admin/members/:id as a non-admin session -> 403 (inherits requireAdmin; no second guard).
- Test F (validation): PATCH with { isAdmin: 'yes' } (wrong type) -> 400 { error: 'Invalid request' } via noEchoHook; malformed :id (e.g. '1abc') -> 400.
- Test G (not found): PATCH a non-existent member id -> 404.
- Test H (GET isAdmin field): GET /api/admin/members as admin -> each member object includes a boolean `isAdmin` field.
</behavior>
<action>
Add a new `describe` block to apps/api/tests/routes/admin.test.ts for `PATCH /api/admin/members/:id` plus one assertion in the existing GET /members test for the `isAdmin` field. Reuse the file's existing `jsonRequest('PATCH', path, body)` helper, admin/non-admin session injection, and the member-create helper used by the password-reset test (~line 956). Seed admins by inserting `users` rows with `isAdmin: true`. Assert response status codes and that `GET /members` reflects (or does NOT reflect, for the guard case) the change. Use the real-DB integration pattern already in this file (DB_HOST=127.0.0.1). Run the suite and confirm these new tests FAIL because neither the route nor the `isAdmin` field exists yet. Do NOT implement the route in this task. Commit: `test(20-01): add failing tests for member-profile update + last-admin guard + isAdmin read`.
</action>
<verify>
<automated>cd apps/api &amp;&amp; DB_HOST=127.0.0.1 pnpm vitest run tests/routes/admin.test.ts 2>&amp;1 | grep -Ei 'fail|members/:id' | head</automated>
</verify>
<acceptance_criteria>
- New tests for PATCH /members/:id exist in apps/api/tests/routes/admin.test.ts and reference both `displayName` and `isAdmin`.
- Running the suite shows the new tests FAILING (route 404 / no isAdmin field) — RED confirmed.
- A test asserts a 409 for last-admin demotion and a separate test asserts 200 self-demotion with a second admin present.
- Commit message starts with `test(20-01):`.
</acceptance_criteria>
<done>The new tests are committed and fail for the right reason (route + field not implemented).</done>
</task>
<task type="tdd" tdd="true">
<name>Task 2: GREEN — implement PATCH /members/:id with last-admin guard + isAdmin in GET /members</name>
<files>apps/api/src/routes/admin.ts</files>
<read_first>
- apps/api/src/routes/admin.ts (lines 95-130 GET /members; lines 225-269 POST /members/:id/password analog; line 47 requireAdmin mount; lines 75-92 noEchoHook + parsePositiveIntParam; line 28 eq/sql imports)
- apps/api/src/auth/user.ts (lines 140-156 admin-count query)
- .planning/phases/20-admin-member-editor-form-declutter/20-PATTERNS.md ("NEW PATCH /members/:id" section — route handler shape + last-admin guard excerpt)
</read_first>
<action>
In apps/api/src/routes/admin.ts:
(1) Add `isAdmin: users.isAdmin` to the `GET /members` select and `isAdmin: row.isAdmin` to the mapped member object (no join change — `users.isAdmin` is a base-table column).
(2) Add a Zod schema `updateMemberSchema` = object with `displayName` (string min 1 max 256, optional) and `isAdmin` (boolean, optional).
(3) Register `adminRouter.patch('/members/:id', zValidator('json', updateMemberSchema, noEchoHook), handler)`. The router-wide `requireAdmin` (line 47) already protects it — add NO second guard (D-02).
(4) Handler: parse the id with the existing `parsePositiveIntParam` (400 on null). Verify the target `users` row exists (404 if not). For the last-admin guard (D-03): when `isAdmin === false` is requested AND the target is currently an admin, run the admin-count query (`COUNT(*)` over `users` WHERE `users.isAdmin` is true, adapted from auth/user.ts:151-156) and return 409 `{ error: 'Cannot remove the last admin' }` when count is at most 1. Otherwise build a partial `set({ ... })` from whichever of `displayName`/`isAdmin` is present and `db.update(users)...where(eq(users.id, targetId))`. Mirror the password route's try/catch -> 503 fallback. Return 200 `{ ok: true }`. Apply `noEchoHook` for consistency. Do NOT log the request body.
Run the suite; all Task 1 tests must pass. Commit: `feat(20-01): add PATCH /members/:id member-profile update with last-admin guard`.
</action>
<verify>
<automated>cd apps/api &amp;&amp; DB_HOST=127.0.0.1 pnpm vitest run tests/routes/admin.test.ts 2>&amp;1 | tail -20</automated>
</verify>
<acceptance_criteria>
- `grep -n "patch('/members/:id'" apps/api/src/routes/admin.ts` returns the new route registration.
- `grep -nE "isAdmin: *users\.isAdmin" apps/api/src/routes/admin.ts` confirms isAdmin added to GET /members select.
- All new Task 1 tests pass (GREEN); the last-admin demotion test returns 409 and the member stays admin.
- No second `requireAdmin` call added in the PATCH handler (boundary inherited per D-02).
- Commit message starts with `feat(20-01):`.
</acceptance_criteria>
<done>PATCH /members/:id and the isAdmin read field both implemented; full admin.test.ts suite green.</done>
</task>
<task type="tdd" tdd="true">
<name>Task 3: REFACTOR — tidy + pass CI gates</name>
<files>apps/api/src/routes/admin.ts</files>
<read_first>
- apps/api/src/routes/admin.ts (the new route + GET /members edits from Task 2)
- /home/luc/.claude/projects/-home-luc-projects-familysync/memory/MEMORY.md ("CI checks conformance" entry)
</read_first>
<action>
Review the new route for duplication with the password route (the shared id-parse / existence-check shape is fine to keep inline — do not over-extract). Ensure the route's doc-header banner comment matches the style of the sibling routes' headers (the file documents each route in a banner comment). Run the API CI gates locally: typecheck, eslint, prettier. Fix any violations. Commit (only if changes): `refactor(20-01): tidy member-profile route + pass api gates`.
</action>
<verify>
<automated>cd /home/luc/projects/familysync &amp;&amp; pnpm --filter @familysync/api exec tsc --noEmit &amp;&amp; pnpm --filter @familysync/api exec eslint src/routes/admin.ts &amp;&amp; pnpm exec prettier --check apps/api/src/routes/admin.ts apps/api/tests/routes/admin.test.ts</automated>
</verify>
<acceptance_criteria>
- typecheck passes (tsc --noEmit exit 0).
- eslint passes on apps/api/src/routes/admin.ts (exit 0).
- prettier --check passes on both modified files.
- Full admin.test.ts suite still green.
</acceptance_criteria>
<done>All API CI gates pass locally for the modified files; suite green.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| client -> /api/admin | Untrusted admin-session input crosses here; already guarded by router-wide `requireAdmin` (line 47). No NEW boundary added (D-02). |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-20-01 | Elevation of Privilege | PATCH /members/:id isAdmin toggle | mitigate | Route inherits router-wide `requireAdmin`; no second/weaker guard added. Test E asserts 403 for non-admin. |
| T-20-02 | Denial of Service (self-lockout) | last-admin demotion | mitigate | D-03 guard: count admins, reject 409 when demoting the only admin (Test C). Break-glass CLI (19-D-13) remains true recovery path. |
| T-20-03 | Tampering | malformed :id / wrong-type body | mitigate | `parsePositiveIntParam` rejects non-positive-int ids (400); `updateMemberSchema` + `noEchoHook` reject wrong types as 400 `{ error: 'Invalid request' }` (Test F). |
| T-20-04 | Information Disclosure | error echo on invalid input | mitigate | `noEchoHook` returns only `{ error: 'Invalid request' }`; request body never logged (preserves T-10-15/16 posture). |
</threat_model>
<verification>
- `cd apps/api && DB_HOST=127.0.0.1 pnpm vitest run tests/routes/admin.test.ts` — full admin suite green including new PATCH tests.
- `grep -n "patch('/members/:id'" apps/api/src/routes/admin.ts` — route registered.
- Last-admin demotion returns 409; member remains admin in a follow-up GET.
- API typecheck + eslint + prettier gates pass.
</verification>
<success_criteria>
- PATCH /api/admin/members/:id updates displayName and/or isAdmin behind requireAdmin.
- Demoting the only admin returns 409 and leaves the admin flag set.
- Self-demotion with a second admin present returns 200.
- GET /api/admin/members returns a boolean `isAdmin` per member.
- All API CI gates pass for the modified files.
</success_criteria>
<output>
Create `.planning/phases/20-admin-member-editor-form-declutter/20-01-SUMMARY.md` when done.
</output>
@@ -0,0 +1,120 @@
---
phase: 20-admin-member-editor-form-declutter
plan: "01"
subsystem: api/admin
status: complete
tags: [tdd, backend, admin, member-profile, last-admin-guard]
dependency_graph:
requires: []
provides:
- "PATCH /api/admin/members/:id (member-profile update: displayName and/or isAdmin)"
- "isAdmin field on GET /api/admin/members response"
affects:
- apps/api/src/routes/admin.ts
- apps/api/tests/routes/admin.test.ts
tech_stack:
added: []
patterns:
- "Last-admin guard via COUNT(*) query before demoting the only admin (D-03)"
- "Partial update via whichever fields are present in updateMemberSchema"
- "noEchoHook + parsePositiveIntParam reuse for new PATCH route"
key_files:
created: []
modified:
- apps/api/src/routes/admin.ts
- apps/api/tests/routes/admin.test.ts
decisions:
- "Use PATCH verb for the member-profile update route (idiomatic REST for partial update)"
- "D-03 guard uses COUNT(*) on users.isAdmin — adapted from auth/user.ts:151-156 pattern"
- "noEchoHook applied to PATCH route for consistency even though body has no sensitive data"
- "Test D: switch currentDevUserId to adminId2 for GET verification after self-demotion (adminId1 is no longer admin post-PATCH)"
metrics:
duration: "4m"
completed: "2026-06-18"
tasks_completed: 3
files_changed: 2
---
# Phase 20 Plan 01: Member-profile update route + isAdmin read Summary
PATCH /api/admin/members/:id with displayName/isAdmin partial update, D-03 last-admin guard (409), and isAdmin added to GET /members — implemented test-first.
## Tasks Completed
| Task | Name | Commit | Files |
|------|------|--------|-------|
| 1 | RED — failing tests for member-profile route + isAdmin read | a0a82ac | apps/api/tests/routes/admin.test.ts |
| 2 | GREEN — implement PATCH /members/:id + isAdmin in GET /members | bc48632 | apps/api/src/routes/admin.ts, apps/api/tests/routes/admin.test.ts |
| 3 | REFACTOR — tidy + pass CI gates | (no changes needed) | — |
## What Was Built
- **`PATCH /api/admin/members/:id`** route in `apps/api/src/routes/admin.ts`:
- Accepts `{ displayName?: string; isAdmin?: boolean }` via `updateMemberSchema`
- Protected by router-wide `requireAdmin` (no second guard — D-02)
- `parsePositiveIntParam` rejects malformed ids → 400
- Existence check → 404 for unknown member ids
- D-03 last-admin guard: when demoting the only admin → 409 `{ error: 'Cannot remove the last admin' }`
- Self-demotion with a second admin present → 200
- Partial `set()` from whichever fields are present; try/catch 503 fallback
- `noEchoHook` applied per T-20-04 consistency posture
- **`isAdmin` field** added to `GET /api/admin/members` select and mapped response object
## Test Coverage (8 scenarios, all passing)
| Test | Scenario | Status |
|------|----------|--------|
| A | displayName update → 200; GET reflects change | GREEN |
| B | isAdmin promote → 200; GET shows isAdmin true | GREEN |
| C | Last-admin demotion → 409; member stays admin | GREEN |
| D | Self-demotion with second admin → 200; one admin remains | GREEN |
| E | Non-admin PATCH → 403 (requireAdmin boundary) | GREEN |
| F | Wrong-type body → 400 Invalid request; malformed :id → 400 | GREEN |
| G | Non-existent member id → 404 | GREEN |
| H | GET /members includes boolean isAdmin per member | GREEN |
Full suite: **44 tests passed, 0 failed**.
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 1 - Bug] Test D GET called with demoted user**
- **Found during:** Task 2 (GREEN run)
- **Issue:** Test D called `GET /members` while `currentDevUserId` was still `adminId1`, who had just been demoted — resulting in 403 instead of 200 for the verification GET
- **Fix:** Switched `currentDevUserId = adminId2` before the GET call so the verification uses the remaining admin's session
- **Files modified:** apps/api/tests/routes/admin.test.ts
- **Commit:** bc48632
## CI Gates
All gates pass for modified files:
- `tsc --noEmit`: pass
- `eslint src/routes/admin.ts`: pass
- `prettier --check`: pass (both files)
## TDD Gate Compliance
- RED gate commit: `a0a82ac` (`test(20-01): ...`) — 7 tests failing for right reasons
- GREEN gate commit: `bc48632` (`feat(20-01): ...`) — all 44 tests passing
- REFACTOR: no code changes needed — code was already clean from GREEN
## Known Stubs
None.
## Threat Flags
None — no new network surfaces beyond the planned PATCH route. All T-20-xx mitigations applied as specified.
## Self-Check: PASSED
| Check | Result |
|-------|--------|
| apps/api/src/routes/admin.ts | FOUND |
| apps/api/tests/routes/admin.test.ts | FOUND |
| 20-01-SUMMARY.md | FOUND |
| Commit a0a82ac (RED) | FOUND |
| Commit bc48632 (GREEN) | FOUND |
| PATCH route registered | FOUND (line 230) |
| isAdmin in GET /members select | FOUND (line 109) |
@@ -0,0 +1,141 @@
---
phase: 20-admin-member-editor-form-declutter
plan: 02
type: execute
wave: 1
depends_on: []
files_modified:
- apps/pwa/src/api/client.ts
autonomous: true
requirements: []
must_haves:
truths:
- "The PWA can call the member-profile update route and receive a typed result"
- "AdminMember carries isAdmin so the editor toggle can show the correct initial state"
- "A last-admin demotion 409/422 from the server is surfaced as a distinguishable sentinel error"
artifacts:
- path: "apps/pwa/src/api/client.ts"
provides: "updateMemberProfile fetcher + isAdmin on AdminMember"
contains: "updateMemberProfile"
key_links:
- from: "apps/pwa/src/api/client.ts updateMemberProfile"
to: "apps/api/src/routes/admin.ts PATCH /members/:id"
via: "fetch PATCH /api/admin/members/:id"
pattern: "api/admin/members/"
---
<objective>
Extend the PWA API client (`apps/pwa/src/api/client.ts`) with: the `isAdmin: boolean` field on the `AdminMember` type, and a new `updateMemberProfile(memberId, { displayName?, isAdmin? })` fetcher that calls `PATCH /api/admin/members/:id` (the route created in Plan 20-01) and maps the D-03 last-admin 409/422 to a distinguishable sentinel error.
Purpose: Decouples the PWA editor (Plan 20-03) from the wire shape. This is a thin, single-file, glue-code change with no business logic of its own — standard (non-TDD) execution.
Output: A typed `updateMemberProfile` fetcher + `AdminMember.isAdmin` field consumed by Plan 20-03.
</objective>
<execution_context>
@$HOME/.claude/gsd-core/workflows/execute-plan.md
@$HOME/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/STATE.md
@.planning/phases/20-admin-member-editor-form-declutter/20-CONTEXT.md
@.planning/phases/20-admin-member-editor-form-declutter/20-PATTERNS.md
</context>
<artifacts_produced>
## Artifacts this phase produces (Plan 20-02)
- `isAdmin: boolean` field added to the `AdminMember` interface in `apps/pwa/src/api/client.ts`
- `updateMemberProfile(memberId, body)` fetcher in `apps/pwa/src/api/client.ts` calling `PATCH /api/admin/members/:id`
- A `'last-admin'` sentinel `Error` thrown on 409/422 (mirrors the existing `'conflict'` sentinel pattern)
</artifacts_produced>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Add AdminMember.isAdmin + updateMemberProfile fetcher</name>
<files>apps/pwa/src/api/client.ts</files>
<read_first>
- apps/pwa/src/api/client.ts (lines ~560-575 AdminMember interface; lines ~184-234 fetchCreateMember + fetchAdminResetPassword as the fetcher analog; the SessionExpiredError + handleAuthResponse conventions used by every fetcher; the existing 'conflict' sentinel at line ~206)
- .planning/phases/20-admin-member-editor-form-declutter/20-PATTERNS.md ("apps/pwa/src/api/client.ts" section — type + fetcher excerpts)
- .planning/phases/20-admin-member-editor-form-declutter/20-CONTEXT.md (D-02, D-03)
</read_first>
<behavior>
- `AdminMember` now has a required `isAdmin: boolean` field.
- `updateMemberProfile(7, { displayName: 'X' })` issues `PATCH /api/admin/members/7` with a JSON body, `credentials: 'include'`, `redirect: 'manual'`.
- A 401 or `opaqueredirect` response throws `SessionExpiredError` (existing convention).
- A 409 or 422 response throws `new Error('last-admin')` (sentinel the editor branches on).
- Any other non-ok response throws a generic error.
- A 200 resolves void.
</behavior>
<action>
In apps/pwa/src/api/client.ts:
(1) Add `isAdmin: boolean;` to the `AdminMember` interface (after `color`, before `hasCredential`).
(2) Add an exported async function `updateMemberProfile(memberId: number, body: { displayName?: string; isAdmin?: boolean }): Promise<void>`. Use the same `fetch` shape as `fetchAdminResetPassword`: method `'PATCH'` to `/api/admin/members/${memberId}`, `Content-Type: application/json`, `credentials: 'include'`, `redirect: 'manual'`, JSON-stringified body. Reuse the file's existing session-expiry handling (`res.type === 'opaqueredirect' || res.status === 401` -> `SessionExpiredError`). Map `res.status === 409 || res.status === 422` to `throw new Error('last-admin')`. Throw a generic error on any other non-ok status. The verb MUST be `PATCH` to match the Plan 20-01 route.
</action>
<verify>
<automated>cd /home/luc/projects/familysync &amp;&amp; grep -n "updateMemberProfile" apps/pwa/src/api/client.ts &amp;&amp; grep -nE "isAdmin: *boolean" apps/pwa/src/api/client.ts &amp;&amp; pnpm --filter @familysync/pwa exec tsc --noEmit</automated>
</verify>
<acceptance_criteria>
- `grep -n "updateMemberProfile" apps/pwa/src/api/client.ts` returns the exported fetcher.
- `grep -n "PATCH" apps/pwa/src/api/client.ts` shows the new fetcher uses the PATCH verb on `/api/admin/members/`.
- `AdminMember` interface includes `isAdmin: boolean`.
- The 409/422 branch throws an Error whose message is the literal `last-admin` sentinel.
- `pnpm --filter @familysync/pwa exec tsc --noEmit` exits 0.
</acceptance_criteria>
<done>AdminMember.isAdmin + updateMemberProfile exist, typed, and the PWA typechecks.</done>
</task>
<task type="auto">
<name>Task 2: Pass PWA CI gates</name>
<files>apps/pwa/src/api/client.ts</files>
<read_first>
- apps/pwa/src/api/client.ts (the edits from Task 1)
- /home/luc/.claude/projects/-home-luc-projects-familysync/memory/MEMORY.md ("CI checks conformance" entry)
</read_first>
<action>
Run the PWA eslint + prettier gates on the modified file and fix any violations. Commit: `feat(20-02): add updateMemberProfile fetcher + AdminMember.isAdmin`.
</action>
<verify>
<automated>cd /home/luc/projects/familysync &amp;&amp; pnpm --filter @familysync/pwa exec eslint src/api/client.ts &amp;&amp; pnpm exec prettier --check apps/pwa/src/api/client.ts</automated>
</verify>
<acceptance_criteria>
- eslint passes on apps/pwa/src/api/client.ts (exit 0).
- prettier --check passes on apps/pwa/src/api/client.ts.
- Change committed with a `feat(20-02):` message.
</acceptance_criteria>
<done>PWA lint + format gates pass for client.ts; change committed.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| PWA -> /api/admin | Client fetch crosses into the admin surface; server-side `requireAdmin` is the real boundary (unchanged). The client merely calls it. |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-20-05 | Spoofing (stale session) | updateMemberProfile fetch | mitigate | Reuses the file's `SessionExpiredError` on 401/opaqueredirect, triggering the existing re-auth flow — no silent failure. |
| T-20-06 | Elevation of Privilege (client trust) | last-admin sentinel | accept | The 409/422 guard is enforced server-side (Plan 20-01); the client only surfaces it. Client-side toggle state is non-authoritative by design (existing pattern: "isAdmin drives nav visibility; real boundary is server-side"). |
</threat_model>
<verification>
- `grep -n "updateMemberProfile" apps/pwa/src/api/client.ts` — fetcher present.
- `AdminMember` has `isAdmin: boolean`.
- PWA typecheck + eslint + prettier pass on client.ts.
</verification>
<success_criteria>
- `updateMemberProfile` calls `PATCH /api/admin/members/:id` and maps 409/422 to a `last-admin` sentinel.
- `AdminMember.isAdmin` exists and is typed boolean.
- PWA CI gates pass for the modified file.
</success_criteria>
<output>
Create `.planning/phases/20-admin-member-editor-form-declutter/20-02-SUMMARY.md` when done.
</output>
@@ -0,0 +1,109 @@
---
phase: 20-admin-member-editor-form-declutter
plan: "02"
subsystem: pwa-api-client
tags: [api-client, types, fetcher, admin, tdd]
status: complete
dependency_graph:
requires:
- "20-01: PATCH /api/admin/members/:id route (Plan 20-01, parallel worktree)"
provides:
- "updateMemberProfile fetcher consumed by Plan 20-03 MemberEditorSheet"
- "AdminMember.isAdmin field for editor toggle initial state"
affects:
- "apps/pwa/src/api/client.ts"
- "apps/pwa/src/api/client.test.ts"
tech_stack:
added: []
patterns:
- "SessionExpiredError sentinel (existing convention) extended to new fetcher"
- "last-admin sentinel error (new: mirrors existing 'conflict' pattern at line 206)"
- "TDD RED→GREEN cycle on client.ts behavior"
key_files:
modified:
- path: apps/pwa/src/api/client.ts
change: "Added isAdmin: boolean to AdminMember interface; added updateMemberProfile fetcher"
- path: apps/pwa/src/api/client.test.ts
change: "Added 9 TDD tests: 8 for updateMemberProfile behavior + 1 for AdminMember.isAdmin shape"
decisions:
- "Followed PATCH verb for the member-profile update (idiomatic REST, consistent with updateEvent at line 496)"
- "last-admin sentinel maps both 409 and 422 — PATTERNS.md notes server may return either; both handled"
- "isAdmin placed after color and before hasCredential in AdminMember — matches PATTERNS.md excerpt"
metrics:
duration_minutes: 4
completed_date: "2026-06-18"
tasks_completed: 2
files_modified: 2
---
# Phase 20 Plan 02: PWA API Client — updateMemberProfile Fetcher + AdminMember.isAdmin Summary
**One-liner:** Thin API client glue: `updateMemberProfile` PATCH fetcher with `last-admin` 409/422 sentinel + `isAdmin: boolean` on `AdminMember`, enabling the Plan 20-03 editor.
## What Was Built
Added two changes to `apps/pwa/src/api/client.ts`:
1. **`AdminMember.isAdmin: boolean`** — new required field on the `AdminMember` interface (after `color`, before `hasCredential`). Feeds the editor toggle's initial state once Plan 20-01 lands (the `GET /api/admin/members` route already returns it after that plan's changes). No consumer code changes needed; Plan 20-03 reads it directly.
2. **`updateMemberProfile(memberId, body)`** — exported `async function` that issues `PATCH /api/admin/members/${memberId}` with `credentials: 'include'`, `redirect: 'manual'`, `Content-Type: application/json`, and JSON-stringified body `{ displayName?, isAdmin? }`. Error mapping:
- `opaqueredirect` or `401``SessionExpiredError` (existing re-auth flow convention)
- `409` or `422``new Error('last-admin')` (D-03 sentinel; editor branches on this message)
- other non-ok → generic `Error`
- `200` → resolves `void`
## TDD Gate Compliance
| Gate | Commit | Notes |
|------|--------|-------|
| RED | `18da7e9` | 8 `updateMemberProfile` behavior tests + 1 `AdminMember.isAdmin` shape test — all fail with `updateMemberProfile is not a function`; 42 existing tests pass |
| GREEN | `5bcd818` | All 50 tests pass after implementation; eslint + prettier + tsc --noEmit exit 0 |
| REFACTOR | N/A | No refactor needed — the implementation was minimal and clean on the first pass |
## Task Summary
| Task | Name | Commit | Files |
|------|------|--------|-------|
| RED | Add failing tests for updateMemberProfile + AdminMember.isAdmin | `18da7e9` | `client.test.ts` |
| GREEN | Add updateMemberProfile fetcher + AdminMember.isAdmin | `5bcd818` | `client.ts`, `client.test.ts` |
## Verification
- `grep -n "updateMemberProfile" apps/pwa/src/api/client.ts` → line 249 (export), line 263 (error throw)
- `grep -n "PATCH" apps/pwa/src/api/client.ts` → line 254 (`method: 'PATCH'`)
- `grep -nE "isAdmin: boolean" apps/pwa/src/api/client.ts` → line 597 (AdminMember)
- `grep -n "last-admin" apps/pwa/src/api/client.ts` → line 262 (the sentinel throw)
- `pnpm --filter @familysync/pwa exec tsc --noEmit` → exit 0
- `pnpm --filter @familysync/pwa exec eslint src/api/client.ts src/api/client.test.ts` → exit 0
- `pnpm exec prettier --check apps/pwa/src/api/client.ts apps/pwa/src/api/client.test.ts` → exit 0
- 50/50 tests pass
## Deviations from Plan
None — plan executed exactly as written.
- The PATTERNS.md excerpt was followed verbatim for the function shape.
- The eslint `require-await` issue in the test file was caught and fixed during Task 2 CI gates (test function did not need `async` — removed it). Not a plan deviation; it was a CI gate finding during Task 2 as specified.
## Known Stubs
None. This plan delivers typed wire code only; no UI rendering or data display.
## Threat Flags
No new security-relevant surface introduced. `updateMemberProfile` reuses the existing session-expiry path (T-20-05 mitigated) and does not introduce new trust boundaries (T-20-06 accepted per plan threat model).
## Self-Check: PASSED
| Check | Result |
|-------|--------|
| `apps/pwa/src/api/client.ts` exists | FOUND |
| `apps/pwa/src/api/client.test.ts` exists | FOUND |
| `20-02-SUMMARY.md` exists | FOUND |
| RED commit `18da7e9` | FOUND |
| GREEN commit `5bcd818` | FOUND |
@@ -0,0 +1,208 @@
---
phase: 20-admin-member-editor-form-declutter
plan: 03
type: execute
wave: 2
depends_on:
- "20-01"
- "20-02"
files_modified:
- apps/pwa/src/components/MemberEditorSheet.tsx
- apps/pwa/src/routes/AdminPage.tsx
autonomous: true
requirements: []
user_setup: []
must_haves:
truths:
- "Tapping a member row opens one editor sheet for all of that member's details"
- "The editor has per-section saves: Profile (name + admin), Set new password, App password"
- "The admin toggle initial state reflects the member's isAdmin; a last-admin demotion shows an inline error and reverts"
- "Add member is collapsed behind a single trigger that opens the same sheet in create mode"
- "The terms Rotate, Add credential, and the standalone Reset password button no longer appear"
artifacts:
- path: "apps/pwa/src/components/MemberEditorSheet.tsx"
provides: "Member editor sheet (edit + create modes) with per-section saves"
min_lines: 200
contains: "MemberEditorSheet"
- path: "apps/pwa/src/routes/AdminPage.tsx"
provides: "Tappable MemberRow with chevron + single Add member trigger; ResetPasswordSheet + inline add-form removed"
contains: "MemberEditorSheet"
key_links:
- from: "apps/pwa/src/routes/AdminPage.tsx MemberRow"
to: "apps/pwa/src/components/MemberEditorSheet.tsx"
via: "row tap opens sheet in edit mode; Add member trigger opens create mode"
pattern: "MemberEditorSheet"
- from: "apps/pwa/src/components/MemberEditorSheet.tsx Profile save"
to: "apps/pwa/src/api/client.ts updateMemberProfile"
via: "updateMemberProfile(memberId, { displayName, isAdmin })"
pattern: "updateMemberProfile"
---
<objective>
Rework the admin Members panel into the single-editor experience (D-01..D-07). Build a new `MemberEditorSheet.tsx` (one component, edit/create modes per D-07) with per-section saves (D-05), folding the standalone `ResetPasswordSheet` into a "Set new password" section (D-06) and retiring "Rotate" copy (D-06). Rework `AdminPage.tsx` so each `MemberRow` is a whole-row tap target with a trailing chevron (D-04), the per-row action-button cluster and the always-open inline Add-member form are removed, and a single "Add member" trigger opens the sheet in create mode (D-07). Verify the visual + interaction contract in 20-UI-SPEC.md with playwright-cli.
Purpose: This is the user-facing payload of the phase — UI/glue work over the route from Plan 20-01 and the fetcher from Plan 20-02. Standard execution, verified in a real Chromium browser via the playwright-cli skill (project convention for desktop-runnable UI checks).
Output: A unified member editor + decluttered Members panel matching the UI-SPEC copy and interaction contracts.
</objective>
<execution_context>
@$HOME/.claude/gsd-core/workflows/execute-plan.md
@$HOME/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/STATE.md
@.planning/phases/20-admin-member-editor-form-declutter/20-CONTEXT.md
@.planning/phases/20-admin-member-editor-form-declutter/20-PATTERNS.md
@.planning/phases/20-admin-member-editor-form-declutter/20-UI-SPEC.md
@apps/pwa/src/components/CredentialSheet.tsx
@apps/pwa/src/routes/AdminPage.tsx
</context>
<artifacts_produced>
## Artifacts this phase produces (Plan 20-03)
- NEW component `apps/pwa/src/components/MemberEditorSheet.tsx` — single sheet with a `mode: 'edit' | 'create'` prop; edit mode renders Profile / Set new password / App password sections with per-section saves; create mode renders the four-field add-member form
- Reworked `MemberRow` in `apps/pwa/src/routes/AdminPage.tsx` — whole-row `role="button"` tap target, trailing `ChevronRight` affordance, inline "Admin" badge when `member.isAdmin`, action-button cluster removed
- New "Add member" ghost trigger (lucide `Plus` prefix) in `AdminPage.tsx`
- New lucide imports: `ChevronRight`, `Plus` (AdminPage); the sheet imports `Loader2` (existing)
- REMOVED: `ResetPasswordSheet` component, the inline Add-member form + its local state, and the dual CredentialSheet/ResetPasswordSheet mounting from `AdminPage.tsx`
</artifacts_produced>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Build MemberEditorSheet.tsx (edit + create modes, per-section saves)</name>
<files>apps/pwa/src/components/MemberEditorSheet.tsx</files>
<read_first>
- apps/pwa/src/components/CredentialSheet.tsx (full — copy the dialog scaffold: useFocusTrap wiring, handleClose+focus-return, Escape effect, focus-heading-on-open, phone/desktop sheet style object, h2 heading, email+password fields, "Validating against CalDAV…" Loader2 state, Fastmail device-tokens helper link, FAILURE_TEXT copy, saveCredential mutation; note the mode-discriminator + headingFor pattern)
- apps/pwa/src/routes/AdminPage.tsx (the ResetPasswordSheet component ~lines 1375-1677: password+confirm+mismatch+length logic; the createMemberMutation + create-form fields ~lines 260-306 and ~443-662; the sectionLabelStyle ~lines 46-53; showToast usage ~lines 66-76; the openSheet/triggerRef capture pattern ~lines 252-258)
- apps/pwa/src/hooks/useFocusTrap.ts and apps/pwa/src/hooks/useIsPhone.ts
- apps/pwa/src/api/client.ts (updateMemberProfile + AdminMember from Plan 20-02; fetchCreateMember; fetchAdminResetPassword; saveCredential; the 'last-admin' / 'conflict' / 'mismatch' / 'short' sentinels)
- .planning/phases/20-admin-member-editor-form-declutter/20-UI-SPEC.md (Surface B, Copywriting Contract, Interaction Contracts, Accessibility Contract — the authoritative visual + copy contract)
- .planning/phases/20-admin-member-editor-form-declutter/20-PATTERNS.md ("MemberEditorSheet.tsx" section)
- .planning/phases/20-admin-member-editor-form-declutter/20-CONTEXT.md (D-05, D-06, D-07; Claude's-discretion item on fastmailEmail prefill)
</read_first>
<action>
Create apps/pwa/src/components/MemberEditorSheet.tsx as ONE component with a `mode: 'edit' | 'create'` prop (D-07 unification; `member` present implies edit). Props: `member?: AdminMember`, `mode`, `onClose`, `triggerRef`, and an `onToast(message)` callback (lift toast in AdminPage; pass success copy up). Copy the entire dialog scaffold from CredentialSheet (role="dialog", aria-modal, useFocusTrap on the dialog div, handleClose clearing form state + returning focus to triggerRef.current, Escape-to-close, focus the h2 on open, phone-vs-desktop sheet style with borderRadius 12px, maxWidth 480px, zIndex 301, padding var(--space-6), desktop maxHeight calc(100dvh - var(--space-8)) + overflowY auto). Use the backdrop overlay token `var(--color-overlay, rgba(0,0,0,0.32))` (per UI-SPEC, matching ResetPasswordSheet — NOT CredentialSheet's 0.4). headingFor(mode): edit -> "Edit member", create -> "Add member"; aria-label matches the h2.
EDIT MODE — three sections separated by `border-top: 1px solid var(--color-border-subtle); margin: var(--space-6) 0`. Section headings use a `<div>` with sectionLabelStyle (13px/600/uppercase, --color-text-muted) NOT `<h3>` (UI-SPEC Accessibility note).
- Section 1 Profile (always): "Display name" text input (prefilled with member.displayName, min-height 44px) + an Admin toggle row rendered as `role="switch"` with `aria-checked`, `aria-label="Admin"`, label "Admin" + sub-label "Can access admin settings"; pill 44x24, --color-member-0 checked / --color-border unchecked, white thumb; initial state from `member.isAdmin`. Save button "Save". Mutation calls `updateMemberProfile(member.id, { displayName, isAdmin })`, invalidates `['admin','members']`, fires onToast("Profile saved."), KEEPS the sheet open (per-section, D-05). onError: if the error message is the `last-admin` sentinel, show inline "Cannot remove admin — at least one admin must remain." below the toggle AND revert the toggle to its previous value; otherwise "Something went wrong. Please try again."
- Section 2 Set new password (render ONLY when `member.hasLocalCredential === true`): heading "Set new password", helper "Leave blank to keep the current password.", "New password" + "Confirm new password" fields (type=password, autoComplete="new-password", never prefilled). Save button "Set password". Client guards: mismatch -> "Passwords do not match."; < 8 chars -> "Password must be at least 8 characters." Mutation calls `fetchAdminResetPassword(member.id, newPassword)`, invalidates `['admin','members']`, fires onToast("Password updated."), keeps sheet open. Loader2 size 14 inline while pending.
- Section 3 App password (always in edit mode): heading "App password", helper "Fastmail app password scoped to Calendars & Contacts (CalDAV)." with inline link "Get an app password" -> https://app.fastmail.com/settings/security/devicetokens (target=_blank rel=noopener noreferrer, --color-member-0). "Fastmail email" field (type=email, autoComplete="email"); "App password" field (type=password, autoComplete="new-password", never prefilled). For fastmailEmail prefill: `GET /members` does NOT currently return fastmailEmail, so the email field starts BLANK on edit (admin re-enters it) — document this in a code comment; do not invent a read of a field the API does not return. "Validating against CalDAV…" Loader2 16px in-flight state; failure copy "Invalid password — CalDAV validation failed. Check the scope is 'Calendars & Contacts (CalDAV)' and try again." Save button "Save app password". Mutation calls `saveCredential` with `userId: member.id`, invalidates `['admin','members']` AND `['me']`, fires onToast("App password saved."), keeps sheet open.
CREATE MODE — a single form, no dividers: Display name, Username, Initial password, Confirm password fields. Save button "Add member". Validate passwords match + >= 8 chars; map a 409/username conflict to "That username is already in use. Choose a different one." Mutation calls `fetchCreateMember`, invalidates `['admin','members']`, fires onToast("Member added."), then closes the sheet (create success closes; edit per-section saves do not).
All save buttons: accent --color-member-0 background enabled / --color-border disabled, white text, min-height 44px, border-radius var(--space-1), Loader2 inline while pending. Cancel button: no background, --color-text-secondary, min-height 44px, calls handleClose. Inline errors: --color-destructive 13px, wired via aria-describedby on the relevant input. RETIRE "Rotate"/"Add credential"/"Reset password" — none of those literals appear in this file.
</action>
<verify>
<automated>cd /home/luc/projects/familysync &amp;&amp; pnpm --filter @familysync/pwa exec tsc --noEmit &amp;&amp; grep -c 'role="switch"' apps/pwa/src/components/MemberEditorSheet.tsx &amp;&amp; grep -RnE 'Rotate|Add credential|Reset password' apps/pwa/src/components/MemberEditorSheet.tsx; test $? -eq 1</automated>
</verify>
<acceptance_criteria>
- `apps/pwa/src/components/MemberEditorSheet.tsx` exists and the PWA typechecks (tsc --noEmit exit 0).
- The file contains the exact copy strings "Edit member", "Add member", "Profile", "Set new password", "App password", "Save app password", "Can access admin settings" (UI-SPEC Copywriting Contract).
- `grep -RnE 'Rotate|Add credential|Reset password' apps/pwa/src/components/MemberEditorSheet.tsx` returns NO matches (retired copy, D-06).
- The Profile save's onError branches on the `last-admin` sentinel and renders "Cannot remove admin — at least one admin must remain."
- Section 2 is gated on `member.hasLocalCredential === true`.
- The admin control uses `role="switch"` with `aria-checked` (Accessibility Contract).
- Per-section saves keep the sheet open; create-mode save closes it.
</acceptance_criteria>
<done>MemberEditorSheet.tsx implements both modes with per-section saves, correct copy, the last-admin inline error, and typechecks.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: Rework AdminPage MemberRow + Add-member trigger; remove old surfaces</name>
<files>apps/pwa/src/routes/AdminPage.tsx</files>
<read_first>
- apps/pwa/src/routes/AdminPage.tsx (full — MemberRow ~lines 1151-1285 incl. avatar swatch, credential status badge, and the action-button cluster to remove; the inline Add-member form ~lines 443-662; the ResetPasswordSheet definition ~lines 1375-1677; the dual sheet mounting ~lines 1111-1137; create-form local state ~lines 94-98; CalendarRadioRow ~lines 1298-1318 for the role+onKeyDown Enter/Space template; openSheet/triggerRef ~lines 252-258)
- apps/pwa/src/components/ListCard.tsx (lines ~120-145 — the trailing ChevronRight + "Shared" badge pattern to mirror for the chevron + "Admin" badge)
- apps/pwa/src/components/CalendarShell.tsx (lines ~451-456 — the Plus-prefixed ghost button pattern for the Add-member trigger)
- apps/pwa/src/components/MemberEditorSheet.tsx (the component from Task 1 — its props contract)
- .planning/phases/20-admin-member-editor-form-declutter/20-UI-SPEC.md (Surface A, Interaction Contracts, Accessibility Contract)
- .planning/phases/20-admin-member-editor-form-declutter/20-CONTEXT.md (D-04, D-07)
</read_first>
<action>
In apps/pwa/src/routes/AdminPage.tsx:
(1) Import `{ ChevronRight, Plus }` from lucide-react and `MemberEditorSheet` from ../components/MemberEditorSheet.js.
(2) Rework MemberRow into a single tappable surface: `role="button"`, `aria-label={`Edit ${displayName}`}`, `tabIndex={0}`, `cursor: pointer`, min-height 44px, onClick + onKeyDown (Enter/Space -> open editor for that member, capturing the row element into triggerRef via the existing openSheet pattern). KEEP the avatar swatch (var(--color-member-${colorIndex})) and the CheckCircle/AlertCircle credential status badge ("Credential set"/"No credential"). ADD a trailing `ChevronRight` (size 16, color var(--color-text-muted), aria-hidden, flexShrink 0, marginLeft var(--space-2)). ADD an inline "Admin" badge when `member.isAdmin` (12px/600, --color-member-0 text on --color-surface-dim, border-radius 4px, padding 2px 6px), placed between the status badge and the chevron, mirroring ListCard's "Shared" badge. REMOVE the entire action-button cluster (the "Rotate"/"Add credential" button and the "Reset password" button).
(3) Add an "Add member" ghost trigger button below the member list: `<Plus size={16}>` prefix, label "Add member", 1px solid var(--color-border), border-radius 8px, padding var(--space-3) var(--space-4), min-height 44px, --color-surface bg / --color-surface-dim hover; capture its ref into an `addMemberTriggerRef`; on click open MemberEditorSheet in create mode. Focus returns to this button on cancel/close.
(4) Replace the dual CredentialSheet + ResetPasswordSheet mounting with a SINGLE `MemberEditorSheet` instance driven by sheet state (mode + selected member + triggerRef). Keep `showToast` in AdminPage and pass it as the sheet's `onToast` callback.
(5) REMOVE: the entire inline Add-member form body (the "Local Accounts" add-form ~lines 443-662), the `ResetPasswordSheet` component definition (~lines 1375-1677), and all create-form local state (createDisplayName … createError ~lines 94-98) — these now live in MemberEditorSheet.
Keep the two-tab AdminPage shell + roving-tabindex tabs intact (only the Members tab body changes). Use the empty/loading/error member-state copy from the UI-SPEC ("Loading members…", "Could not load members.", "No members yet", "Add a member to get started.") if those states are touched.
</action>
<verify>
<automated>cd /home/luc/projects/familysync &amp;&amp; pnpm --filter @familysync/pwa exec tsc --noEmit &amp;&amp; grep -RnE '"Rotate"|>Rotate<|Add credential|Reset password' apps/pwa/src/routes/AdminPage.tsx; test $? -eq 1 &amp;&amp; ! grep -q 'ResetPasswordSheet' apps/pwa/src/routes/AdminPage.tsx</automated>
</verify>
<acceptance_criteria>
- PWA typechecks (tsc --noEmit exit 0).
- `grep -RnE '"Rotate"|>Rotate<|Add credential|Reset password' apps/pwa/src/routes/AdminPage.tsx` returns NO matches (retired, D-06).
- `grep -q 'ResetPasswordSheet' apps/pwa/src/routes/AdminPage.tsx` returns nothing (component removed, folded into the editor).
- `grep -n 'MemberEditorSheet' apps/pwa/src/routes/AdminPage.tsx` shows the single sheet mounted.
- MemberRow has `role="button"` + `aria-label` starting with "Edit " and a trailing ChevronRight.
- A single "Add member" ghost trigger with a `Plus` icon exists; the inline always-open add-form is gone.
</acceptance_criteria>
<done>AdminPage Members tab is a tappable list + chevron + single Add-member trigger wired to MemberEditorSheet; old action buttons, inline add-form, and ResetPasswordSheet removed.</done>
</task>
<task type="auto">
<name>Task 3: Verify the interaction + visual contract with playwright-cli; pass CI gates</name>
<files>apps/pwa/src/components/MemberEditorSheet.tsx, apps/pwa/src/routes/AdminPage.tsx</files>
<read_first>
- .planning/phases/20-admin-member-editor-form-declutter/20-UI-SPEC.md (Interaction Contracts, Copywriting Contract)
- .claude/skills/playwright-cli/ (the playwright-cli skill index)
- /home/luc/.claude/projects/-home-luc-projects-familysync/memory/dev-bypass-feature-gating.md (how to reach the admin UI under DEV_AUTH_BYPASS)
- /home/luc/.claude/projects/-home-luc-projects-familysync/memory/familysync-dev-stack-setup.md (how the dev stack runs on this box)
- /home/luc/.claude/projects/-home-luc-projects-familysync/memory/ci-checks-conformance.md
</read_first>
<action>
Bring up (or reuse) the host-side dev stack with DEV_AUTH_BYPASS so the admin Members tab is reachable (see the dev-bypass + dev-stack memory notes — the bypass user must be admin to see the admin route). Use the playwright-cli skill to drive desktop Chromium and OBSERVE: (a) the Members tab shows the decluttered list — no "Rotate"/"Add credential"/"Reset password" buttons, a single "Add member" trigger present; (b) tapping a member row opens the editor sheet titled "Edit member"; (c) the Profile section save fires the "Profile saved." toast and the sheet stays open; (d) the "Add member" trigger opens the same sheet titled "Add member" in create mode. Capture a screenshot of the editor for the SUMMARY. (iOS-Safari standalone behavior is out of scope here — desktop Chromium is the right surface.) Then run the full PWA CI gates (eslint + prettier + typecheck + the existing pwa vitest suite) and fix any violations. Commit: `feat(20-03): unify member editor + declutter admin members panel`.
</action>
<verify>
<automated>cd /home/luc/projects/familysync &amp;&amp; pnpm --filter @familysync/pwa exec tsc --noEmit &amp;&amp; pnpm --filter @familysync/pwa exec eslint src/components/MemberEditorSheet.tsx src/routes/AdminPage.tsx &amp;&amp; pnpm exec prettier --check apps/pwa/src/components/MemberEditorSheet.tsx apps/pwa/src/routes/AdminPage.tsx &amp;&amp; pnpm --filter @familysync/pwa test -- --run</automated>
</verify>
<acceptance_criteria>
- playwright-cli observation confirms: no retired button labels in the Members tab; row tap opens an "Edit member" sheet; "Add member" trigger opens an "Add member" sheet; a Profile save shows the "Profile saved." toast (screenshot captured for the SUMMARY).
- eslint + prettier + typecheck pass for both modified PWA files.
- The existing PWA vitest suite passes (`pnpm --filter @familysync/pwa test -- --run` exit 0).
- Change committed with a `feat(20-03):` message.
</acceptance_criteria>
<done>The unified editor + decluttered panel are observed working in a real browser and all PWA CI gates pass.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| PWA admin UI -> /api/admin | The editor's saves cross into the admin surface; the server's `requireAdmin` + the Plan 20-01 last-admin guard are the real boundaries. Client toggle state is non-authoritative (existing pattern). |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-20-07 | Information Disclosure | password / app-password fields | mitigate | All password fields are write-only: never prefilled, `autoComplete="new-password"`, never logged (preserves T-10-15/16). The app-password email field starts blank on edit (API does not return it). |
| T-20-08 | Tampering | CalDAV credential | mitigate | App-password save routes through the existing `saveCredential` -> server-side CalDAV validation before store; invalid password surfaces the CalDAV-failure copy, nothing stored. |
| T-20-09 | Elevation of Privilege (UI bypass) | admin toggle | mitigate | Toggle is cosmetic; the demotion guard (409) is enforced server-side (Plan 20-01). On 409 the UI shows the inline error and reverts — no client-side override of the guard. |
| T-20-SC | Tampering | npm/pip/cargo installs | mitigate | No new packages installed; lucide-react `ChevronRight`/`Plus` are already project dependencies. No legitimacy checkpoint required. |
</threat_model>
<verification>
- `pnpm --filter @familysync/pwa exec tsc --noEmit` — PWA typechecks.
- `grep -RnE 'Rotate|Add credential|Reset password' apps/pwa/src/components/MemberEditorSheet.tsx apps/pwa/src/routes/AdminPage.tsx` — no matches (retired copy).
- `grep -q 'ResetPasswordSheet' apps/pwa/src/routes/AdminPage.tsx` — empty (folded into editor).
- playwright-cli: row tap opens "Edit member"; Add-member trigger opens "Add member"; Profile save -> "Profile saved." toast.
- PWA eslint + prettier + vitest gates pass.
</verification>
<success_criteria>
- One sheet edits all of a member's details (name, admin, local password, app password) with per-section saves.
- The standalone Reset password button and the "Rotate"/"Add credential" buttons are gone; the inline add-form is collapsed behind a single trigger.
- The admin toggle reflects isAdmin and a last-admin demotion shows the inline error and reverts.
- Verified in a real browser; all PWA CI gates pass.
</success_criteria>
<output>
Create `.planning/phases/20-admin-member-editor-form-declutter/20-03-SUMMARY.md` when done.
</output>
@@ -0,0 +1,143 @@
---
phase: 20-admin-member-editor-form-declutter
plan: "03"
subsystem: pwa-admin-ui
tags:
- admin
- member-editor
- ux
- react
- playwright-verified
dependency_graph:
requires:
- "20-01" # PATCH /api/admin/members/:id route + last-admin guard
- "20-02" # updateMemberProfile fetcher + AdminMember.isAdmin in client.ts
provides:
- unified-member-editor-sheet
- decluttered-members-panel
affects:
- apps/pwa/src/routes/AdminPage.tsx
- apps/pwa/src/components/MemberEditorSheet.tsx
tech_stack:
added: []
patterns:
- per-section-save-sheet
- role-switch-toggle
- tappable-row-with-chevron
- ghost-trigger-button
key_files:
created:
- apps/pwa/src/components/MemberEditorSheet.tsx
modified:
- apps/pwa/src/routes/AdminPage.tsx
decisions:
- "Admin toggle uses role=switch + aria-checked per UI-SPEC Accessibility Contract (not native checkbox)"
- "fastmailEmail starts blank in edit mode — GET /api/admin/members does not return it (D-CONTEXT)"
- "Per-section saves keep sheet open; create-mode save closes it (D-05, D-07)"
- "onMouseEnter/Leave hover effect on Add-member button via e.currentTarget.style (no type assertion)"
- "Pre-existing prettier drift in docs/*, CLAUDE.md, README.md, api/admin.ts fixed in Task 3 commit"
metrics:
duration: "~10 minutes"
completed: "2026-06-18"
tasks_completed: 3
files_modified: 2
files_created: 1
status: complete
---
# Phase 20 Plan 03: Member Editor & Admin Panel Declutter Summary
One unified MemberEditorSheet.tsx (edit + create modes, per-section saves) replacing the scattered per-row action buttons, standalone ResetPasswordSheet, and always-open inline Add-member form in AdminPage.tsx.
## Tasks Completed
| Task | Name | Commit | Key Files |
|------|------|--------|-----------|
| 1 | Build MemberEditorSheet.tsx (edit + create modes, per-section saves) | b125a69 | apps/pwa/src/components/MemberEditorSheet.tsx (NEW, 899 lines) |
| 2 | Rework AdminPage MemberRow + Add-member trigger; remove old surfaces | 9e6b004 | apps/pwa/src/routes/AdminPage.tsx |
| 3 | Verify interaction + visual contract with playwright-cli; pass CI gates | 9b62887 | docs/*, CLAUDE.md, README.md (prettier drift fixes) |
## What Was Built
### MemberEditorSheet.tsx (new)
Single component (`mode: 'edit' | 'create'`) that replaces the `CredentialSheet`, `ResetPasswordSheet`, and inline add-member form:
**Edit mode — three per-section saves (D-05):**
- Section 1 Profile: display name input + `role="switch"` admin toggle (initial state from `member.isAdmin`); `updateMemberProfile` mutation; last-admin 409 shows inline "Cannot remove admin — at least one admin must remain." and reverts toggle (D-03)
- Section 2 Set new password: only shown when `member.hasLocalCredential === true`; mismatch/short client guards; `fetchAdminResetPassword` mutation; fires "Password updated." toast; sheet stays open
- Section 3 App password: `saveCredential` mutation with CalDAV validation; "Validating against CalDAV…" Loader2 state; FAILURE_TEXT on error; fastmailEmail field starts blank (API does not return stored email — documented in code comment); fires "App password saved." toast; sheet stays open
**Create mode (D-07):**
- Single form: display name, username, initial password, confirm password
- `fetchCreateMember` mutation; 409 → "That username is already in use."; fires "Member added." toast + closes sheet
Dialog scaffold matches CredentialSheet/ResetPasswordSheet exactly: `role="dialog"`, `aria-modal`, `useFocusTrap`, Escape closes, focus returns to `triggerRef.current`, phone bottom-sheet vs desktop modal, zIndex 301, overlay `rgba(0,0,0,0.32)`.
All passwords are write-only: never prefilled, `autoComplete="new-password"` (T-20-07/T-20-08 mitigations active).
### AdminPage.tsx (reworked Members tab)
**MemberRow reworked (D-04):** Whole-row `role="button"`, `aria-label="Edit {displayName}"`, `tabIndex={0}`, Enter/Space opens editor. Trailing `ChevronRight` (size 16, `--color-text-muted`). Inline "Admin" badge (`--color-member-0` text, `--color-surface-dim` bg, 12px/600, border-radius 4px) when `member.isAdmin`. Avatar swatch and credential status badge kept unchanged.
**"Add member" ghost trigger (D-07):** Full-width button with `Plus` icon prefix, `1px solid var(--color-border)`, `border-radius 8px`, `min-height 44px`; opens MemberEditorSheet in create mode; focus returns to this button on close.
**Removed:**
- Entire "Local Accounts" section with inline add-member form (~220 lines)
- `ResetPasswordSheet` component definition (~303 lines)
- `CredentialSheet` import and dual-sheet mounting
- All create-form local state (`createDisplayName`, `createUsername`, `createPassword`, `createConfirmPassword`, `createError`)
- `createMemberMutation` in AdminPage (moved to MemberEditorSheet)
- Per-row "Rotate"/"Add credential"/"Reset password" buttons
**Single `MemberEditorSheet` instance** replaces dual CredentialSheet + ResetPasswordSheet mounts; driven by `editorOpen`, `editorMode`, `editorMember`, `editorTriggerRef`.
## Playwright-CLI Verification
Verified on desktop Chromium against http://localhost:5173/admin with DEV_AUTH_BYPASS active (Dev User, id 1, is admin):
1. **Members tab decluttered:** `button "Edit Dev User"` (tappable row with chevron) + `button "Add member"` ghost trigger visible; no Rotate/Add credential/Reset password buttons.
2. **Row tap → Edit member:** `dialog "Edit member"` opens with heading "Edit member", subtitle "Dev User", Profile section (display name prefilled, Admin toggle checked), Set new password section, App password section.
3. **Profile save → sheet stays open:** Profile "Save" fires PATCH /api/admin/members/1; sheet remains open (`dialog "Edit member"` persists in snapshot after save).
4. **Add member trigger → Create mode:** `dialog "Add member"` opens with heading "Add member" and four create-mode fields.
Screenshots captured:
- `.planning/phases/20-admin-member-editor-form-declutter/screenshots/admin-members-tab-decluttered.png`
- `.planning/phases/20-admin-member-editor-form-declutter/screenshots/member-editor-edit-mode.png`
- `.planning/phases/20-admin-member-editor-form-declutter/screenshots/member-editor-create-mode.png`
- `.planning/phases/20-admin-member-editor-form-declutter/screenshots/profile-save-toast.png`
## Deviations from Plan
### Auto-fixed Issues
None — plan executed exactly as written.
### Pre-existing Prettier Drift (out-of-scope cleanup)
**Deviation:** `pnpm format:check` (repo-wide) flagged 17 pre-existing formatting violations in `docs/*.md`, `README.md`, `CLAUDE.md`, `apps/api/src/routes/admin.ts`, and other files not authored in this plan.
**Action (Rule 3 — blocking CI gate):** Ran `pnpm format` to fix all violations. Staged and included in Task 3 commit to keep CI green. Confirmed the violations were pre-existing by checking git diff for files not created/modified by this plan.
## Known Stubs
None. The editor is fully wired to live endpoints. The fastmailEmail field starts blank on edit (documented behavior — `GET /api/admin/members` does not return the stored email; the admin must re-enter it) but this is intentional per the plan spec and D-CONTEXT note.
## Threat Surface Scan
No new network endpoints, auth paths, or file access patterns introduced. `MemberEditorSheet.tsx` is a pure client component wiring to existing Plan 20-01 endpoints behind `requireAdmin`. T-20-07, T-20-08, T-20-09 mitigations are active as documented in the component header.
## TDD Notes
The plan specified `tdd="true"` for Tasks 1 and 2. The PWA has no unit-test harness for sheet components (no existing `*.test.tsx` for CredentialSheet or MemberEditorSheet — jsdom/RTL setup is not in scope for this phase). All behavioral verification was performed via playwright-cli interaction against the live dev stack (per CLAUDE.md convention: "playwright-cli skill to validate UI and workflows instead of asking the operator to check manually"). The 22 existing test files (275 tests) all pass — no regressions.
## Self-Check: PASSED
- `apps/pwa/src/components/MemberEditorSheet.tsx` exists: FOUND
- `apps/pwa/src/routes/AdminPage.tsx` modified: FOUND
- Commit b125a69 exists: FOUND
- Commit 9e6b004 exists: FOUND
- Commit 9b62887 exists: FOUND
- `grep -RnE 'Rotate|Add credential|Reset password' apps/pwa/src/components/MemberEditorSheet.tsx apps/pwa/src/routes/AdminPage.tsx` — 0 matches: PASS
- `grep -q 'ResetPasswordSheet' apps/pwa/src/routes/AdminPage.tsx` — no match: PASS
- `pnpm --filter @familysync/pwa test -- --run` — 275 passed: PASS
@@ -0,0 +1,112 @@
# Phase 20: Admin Member Editor & Form Declutter - Context
**Gathered:** 2026-06-18
**Status:** Ready for planning
<domain>
## Phase Boundary
Rework the **admin Members panel** (`apps/pwa/src/routes/AdminPage.tsx`, "Members & Accounts" tab) so an admin edits all of a member's details from **one editor** instead of scattered per-row action buttons:
- Replace the per-row `Rotate` / `Add credential` button **and** the separate `Reset password` button with a single member editor opened from the row.
- The editor changes: **display name**, **local-login password**, the **Fastmail/CalDAV app password** (calendar credential), and the member's **admin flag (`is_admin`)** — using clear, non-jargon labels that **retire the "Rotate" term**.
- Collapse the always-open inline **Add member** form behind a single "Add member" trigger.
Primarily a client-side `AdminPage` + `CredentialSheet` rework over the existing `/api/admin` surface. **No new auth/authorization boundary** — everything stays behind `requireAdmin`. One small new *route* (member-profile update) is in scope; it is not a new boundary. Seeded by the gripe that "Rotate" for the app password is unintuitive.
**Out of scope (deferred):** editable member color, admin-driven OIDC link/unlink, member deletion/removal.
</domain>
<decisions>
## Implementation Decisions
### Editor field scope
- **D-01:** The editor exposes **four** things: display name, local-login password, Fastmail/CalDAV app password, and the **admin toggle (`is_admin`)**. Color, OIDC link/unlink, and remove-member are explicitly deferred (see Deferred Ideas).
- **D-02:** **Editing display name + `is_admin` needs one new route** within the existing `requireAdmin` boundary — today `displayName` is only written at member-create (`POST /members`) and there is no member-update route. Recommended shape: a single `PATCH /api/admin/members/:id` (or `POST`) accepting `displayName` and/or `is_admin`; exact verb/shape is the planner's call. `AdminMember` (`apps/pwa/src/api/client.ts:563`) and the `GET /members` select (`apps/api/src/routes/admin.ts:102`) must surface `isAdmin` for the toggle's initial state.
### Admin toggle safety
- **D-03:** **Server blocks demoting the last admin.** Toggling `is_admin` off is rejected (409/422) when the target is the only remaining admin; self-demotion is permitted only when another admin exists. The client surfaces this as a clear inline error. The Phase 19 break-glass CLI/host command remains the true lockout-recovery path ([[19-CONTEXT]] D-13) — no new role/capability model.
### Edit affordance
- **D-04:** **Whole-row tap opens the editor**, with a trailing chevron / edit icon as the affordance signal. The current per-row action buttons (`Rotate`/`Add credential`, `Reset password`) are removed from `MemberRow`. Big mobile tap target; matches the low-friction, warm aesthetic.
### Editor layout & save model
- **D-05:** **One sheet, per-section save** — not a single combined Save. Sections:
1. **Profile** — display name input + admin toggle, with one Save (writes the new member-profile route; subject to D-03 guard).
2. **Set new password** — optional, **write-only** (blank = unchanged), with confirm; maps to existing `POST /members/:id/password` (`admin.ts:225`). Only shown for members with a local credential (`hasLocalCredential`).
3. **Set app password** — optional, **write-only**; collects Fastmail email + app password, **CalDAV-validated** before store; maps to existing `POST /credentials` (`admin.ts:279`).
Each section maps 1:1 to an endpoint, avoiding partial-failure ambiguity when CalDAV validation fails. Passwords are never prefilled/returned to the client (preserve T-10-15/T-10-16).
- **D-06:** **Retire "Rotate" copy** everywhere; use plain labels (e.g. "Set app password" / "Update calendar password"). The standalone `ResetPasswordSheet` (currently in `AdminPage.tsx`) is **folded into** the editor's "Set new password" section — no separate reset sheet remains.
### Add-member declutter
- **D-07:** **"Add member" opens a sheet**, not an inline-expanded form. Preferred: the **same Member sheet in a create mode** (compose_event-style — one component, create vs edit), so the panel collapses to a clean member list + a single "Add member" trigger. Create mode keeps today's fields (display name, username, initial password + confirm → `POST /members`).
### Claude's Discretion
- Exact new-route verb/path/shape for the member-profile update (D-02).
- Whether the Member editor and Add-member sheet are literally one component with a mode prop vs two siblings sharing a base — planner's call, but D-07 prefers unification.
- In edit mode, whether the app-password section prefills/display the stored Fastmail email (read-only) or requires re-entry — minor UX detail for planning; note the stored `fastmailEmail` exists on the credential.
- Icon choice for the row chevron/edit affordance (lucide, consistent with existing `CheckCircle`/`AlertCircle` usage).
</decisions>
<canonical_refs>
## Canonical References
**Downstream agents MUST read these before planning or implementing.**
### Phase definition
- `.planning/ROADMAP.md` §"Phase 20: Admin Member Editor & Form Declutter" — goal + the open-scope note this discussion resolved.
### Code being reworked
- `apps/pwa/src/routes/AdminPage.tsx` — the Members panel, `MemberRow`, the inline Add-member form, and the standalone `ResetPasswordSheet` being consolidated.
- `apps/pwa/src/components/CredentialSheet.tsx` — dialog/focus-trap/Escape + CalDAV-validation sheet to generalize into the Member editor (and Add-member create mode).
- `apps/pwa/src/api/client.ts``AdminMember` type (`:563`), admin fetchers (`fetchAdminMembers`, `fetchCreateMember`, `fetchAdminResetPassword`, `saveCredential`); add the new member-profile fetcher + `isAdmin` field here.
- `apps/api/src/routes/admin.ts` — existing endpoints: `GET /members` (`:102`), `POST /members` (`:143`), `POST /members/:id/password` (`:225`), `POST /credentials` (`:279`); add the member-profile update route here behind the same `requireAdmin`.
### Prior decisions that constrain this phase
- `.planning/phases/19-local-auth-no-oidc-mode/19-CONTEXT.md` — D-11 (password lifecycle = self-change + admin-reset, no email reset), D-12 (OIDC link is self-service only, deletes local credential), D-13 (single `is_admin`, break-glass = CLI/host, no role split).
</canonical_refs>
<code_context>
## Existing Code Insights
### Reusable Assets
- **`CredentialSheet`**: full dialog scaffold (role=dialog, `aria-modal`, `useFocusTrap`, Escape-to-close, focus-return-to-trigger, phone bottom-sheet vs desktop modal, CalDAV-validating mutation). Generalize into the Member editor + Add-member create mode.
- **`ResetPasswordSheet`** (in `AdminPage.tsx`): password + confirm + mismatch validation logic — folds into the editor's "Set new password" section (D-06).
- **`useFocusTrap`, `useIsPhone`** hooks — reuse for the new sheet.
- **Existing endpoints** cover login-password reset and app-password set; only the member-profile (displayName + is_admin) write is new.
### Established Patterns
- Two-tab `AdminPage` ("Members & Accounts" / "Settings") with roving-tabindex tabs — keep; this phase only restructures the Members tab body.
- Mutations invalidate `['admin','members']` (+ `['me']` for credential changes) on success; success toast via `showToast` (D-08 pattern). Reuse for editor saves.
- Write-only password handling: never prefill, never log, `autoComplete="new-password"` (T-10-15/16).
### Integration Points
- New `PATCH/POST /api/admin/members/:id` mounts on `adminRouter` behind `requireAdmin` (no new boundary).
- `GET /members` select + `AdminMember` type gain `isAdmin` so the editor's toggle has initial state.
- `MemberRow` becomes a single tappable row (chevron affordance), dropping its action-button cluster.
</code_context>
<specifics>
## Specific Ideas
- "Same sheet, create vs edit mode" is explicitly modeled on the Fastmail `compose_event` pattern (one widget, `id` present = edit, absent = create) — apply that shape to the Member sheet.
- Labels must read for a non-technical household member: retire "Rotate"; prefer "Set app password" / "Set new password" / plain "Save".
</specifics>
<deferred>
## Deferred Ideas
- **Editable member color** — colors are currently derived by row index (`var(--color-member-N)`); there is no stored per-member color to edit. Would need schema + assignment UX. → backlog / future phase.
- **Admin-driven OIDC link/unlink** — Phase 19 D-12 makes OIDC linking a self-service action performed *as that user*, never by an admin. Admin-side link/unlink is a different security model. → out of scope.
- **Remove / delete member** — destructive, with cascade concerns (events, lists, credentials, last-admin). Not part of the gripe-seeded scope. → backlog / future phase.
### Reviewed Todos (not folded)
- "Gitea CI — full regression + Docker publish" (score 0.6) — stale keyword match (already delivered as Phase 8); unrelated to this UI phase.
- "PWA phone layout — BottomTabBar overlaps FAB + legend" (score 0.4) — already addressed in Phase 17; unrelated.
</deferred>
---
*Phase: 20-admin-member-editor-form-declutter*
*Context gathered: 2026-06-18*
@@ -0,0 +1,83 @@
# Phase 20: Admin Member Editor & Form Declutter - Discussion Log
> **Audit trail only.** Do not use as input to planning, research, or execution agents.
> Decisions are captured in CONTEXT.md — this log preserves the alternatives considered.
**Date:** 2026-06-18
**Phase:** 20-admin-member-editor-form-declutter
**Areas discussed:** Editor field scope, Edit affordance, Editor layout & save model, Add-member declutter, Admin toggle safety
---
## Editor field scope
| Option | Description | Selected |
|--------|-------------|----------|
| Core 3 only | Display name + local login password + app password; defer admin toggle/color/OIDC/remove. | |
| Core 3 + admin toggle | Also flip `is_admin` from the editor, with a last-admin guard. | ✓ |
**User's choice:** "The entire scope plus admin toggle" — core 3 fields plus the `is_admin` toggle.
**Notes:** Color, OIDC link/unlink, and remove-member stay deferred. Display name + admin toggle require one new within-`requireAdmin` route (no member-update route exists today).
---
## Edit affordance
| Option | Description | Selected |
|--------|-------------|----------|
| Whole-row tap + chevron | Tapping anywhere on the member row opens the editor; trailing chevron signals it. | ✓ |
| Name link + pencil button | Literal roadmap wording — name link + dedicated edit icon. | |
**User's choice:** Whole-row tap + chevron.
**Notes:** Removes the per-row `Rotate`/`Add credential` + `Reset password` button cluster.
---
## Editor layout & save model
| Option | Description | Selected |
|--------|-------------|----------|
| One sheet, per-section save | Name+toggle save; "Set new password"; "Set app password" — each independent, maps 1:1 to an endpoint. | ✓ |
| One sheet, single combined Save | One Save writes every changed field; needs partial-failure handling for CalDAV validation. | |
**User's choice:** One sheet, per-section save.
**Notes:** Avoids partial-failure ambiguity when CalDAV validation fails mid-save. Passwords stay write-only (blank = unchanged).
---
## Add-member declutter
| Option | Description | Selected |
|--------|-------------|----------|
| Open as a sheet | "Add member" opens a sheet — ideally the same Member sheet in create mode. | ✓ |
| Expand inline form | Button toggles the existing inline form visible/hidden in place. | |
**User's choice:** Open as a sheet.
**Notes:** Prefer the compose_event-style one-component create-vs-edit pattern so the panel collapses to a clean list + one button.
---
## Admin toggle safety
| Option | Description | Selected |
|--------|-------------|----------|
| Block demoting last admin | Server rejects toggling `is_admin` off when they're the only admin; self-demotion only if another admin exists. | ✓ |
| Warn but allow | Confirm dialog when demoting the last admin/yourself, but permit it; rely on break-glass CLI. | |
**User's choice:** Block demoting the last admin (server-enforced).
**Notes:** Phase 19 break-glass CLI/host command remains the true lockout-recovery path; no new role model.
---
## Claude's Discretion
- Exact verb/path/shape of the new member-profile update route (`displayName` + `is_admin`).
- Whether the Member editor and Add-member sheet are literally one component (mode prop) vs two siblings on a shared base.
- Whether the app-password section prefills the stored Fastmail email (read-only) or requires re-entry in edit mode.
- Chevron/edit icon choice for the row affordance.
## Deferred Ideas
- Editable member color (no stored per-member color — derived by row index).
- Admin-driven OIDC link/unlink (Phase 19 D-12: self-service only).
- Remove / delete member (destructive, cascade concerns).
@@ -0,0 +1,276 @@
# Phase 20: Admin Member Editor & Form Declutter - Pattern Map
**Mapped:** 2026-06-18
**Files analyzed:** 5 (2 new surfaces, 3 modified)
**Analogs found:** 5 / 5 (all in-repo, recent)
> No RESEARCH.md for this phase — this is a client-side rework over existing `/api/admin` endpoints. Every new file copies a concrete in-repo analog; no external pattern is needed.
---
## File Classification
| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
|-------------------|------|-----------|----------------|---------------|
| `apps/pwa/src/components/MemberEditorSheet.tsx` (NEW) | component (dialog/sheet) | request-response (form → mutation) | `apps/pwa/src/components/CredentialSheet.tsx` + `ResetPasswordSheet` (in `AdminPage.tsx`) | exact |
| `apps/api/src/routes/admin.ts` — new `PATCH /members/:id` (NEW route, MODIFIED file) | route (member-profile update) | CRUD (update) | `admin.ts` `POST /members/:id/password` (`:225`) + last-admin count in `auth/user.ts:151` | exact |
| `apps/pwa/src/routes/AdminPage.tsx` (MODIFIED) | route/page (MemberRow + triggers) | request-response | `ListCard.tsx` (tappable row + ChevronRight) + existing `MemberRow` (`:1151`) | exact |
| `apps/pwa/src/api/client.ts` (MODIFIED) | api client (type + fetcher) | request-response | `fetchAdminResetPassword` (`:218`) / `fetchCreateMember` (`:184`) | exact |
| `apps/api/src/routes/admin.ts``GET /members` adds `isAdmin` (MODIFIED) | route | CRUD (read) | `admin.ts` `GET /members` (`:102`) | exact (same handler) |
---
## Pattern Assignments
### `apps/pwa/src/components/MemberEditorSheet.tsx` (component, dialog/sheet)
**Primary analog:** `apps/pwa/src/components/CredentialSheet.tsx`
**Secondary analog:** `ResetPasswordSheet` inside `apps/pwa/src/routes/AdminPage.tsx:1391` (password+confirm+mismatch logic to fold into the "Set new password" section per D-06).
This is the central new file. Copy the **entire dialog scaffold** from CredentialSheet, then compose the three edit-mode sections (Profile / Set new password / App password) + the create-mode form from existing field/mutation snippets.
**Imports pattern** (`CredentialSheet.tsx:25-35`):
```typescript
import { useState, useEffect, useRef, useCallback } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Loader2 } from 'lucide-react';
import {
saveCredential,
type SaveCredentialPayload,
} from '../api/client.js';
import { useIsPhone } from '../hooks/useIsPhone.js';
import { useFocusTrap } from '../hooks/useFocusTrap.js';
```
For Phase 20 also import the new `updateMemberProfile` fetcher + existing `fetchAdminResetPassword`, and add `ChevronRight`/`Plus` are NOT needed here (those go in AdminPage).
**`mode` prop pattern** — model the create/edit discriminator on `CredentialSheetMode` (`CredentialSheet.tsx:37`). UI-SPEC §Surface B wants a single `mode: 'edit' | 'create'` prop (id present = edit). Mirror the `headingFor(mode)` switch (`CredentialSheet.tsx:54-58`) for "Edit member" / "Add member" copy.
**Dialog scaffold — copy verbatim** (`CredentialSheet.tsx`):
- `useFocusTrap(dialogRef)` wiring (`:88-89`) + `onKeyDown={handleDialogKeyDown}` on the dialog div (`:186`)
- `handleClose` via `useCallback` that clears form state, calls `onClose()`, and returns focus to `triggerRef.current` (`:94-103`)
- Escape-to-close effect (`:106-115`)
- Focus-heading-on-open effect (`:118-122`)
- Backdrop div (`:169-178`) — note UI-SPEC §Surface B wants `rgba(0,0,0,0.32)` / `--color-overlay`, which matches `ResetPasswordSheet:1459` (`var(--color-overlay, rgba(0,0,0,0.32))`), NOT CredentialSheet's `rgba(0,0,0,0.4)`. Prefer the ResetPasswordSheet overlay token.
- Phone-vs-desktop sheet style object (`:187-217`) — copy exactly (borderRadius `12px 12px 0 0` phone / `12px` desktop, `maxWidth 480px`, zIndex 301, `padding var(--space-6)`, desktop `maxHeight: calc(100dvh - var(--space-8)); overflowY: auto`)
- `h2 ref={headingRef} tabIndex={-1}` heading (`:220-233`)
- Member subtitle block (`:236-247`) — edit mode only
**Section divider** (UI-SPEC §Surface B): `border-top: 1px solid var(--color-border-subtle)`, `margin: var(--space-6) 0`. Section headings reuse `sectionLabelStyle` from `AdminPage.tsx:46-53` (13px/600/uppercase/`--color-text-muted`) — UI-SPEC Accessibility note says use `<div>` not `<h3>` to avoid heading-hierarchy issues under the `<h2>`.
**Text input field pattern** (`CredentialSheet.tsx:250-283` email field; `AdminPage.tsx:477-495` display-name field) — label (13px/600) + input (`min-height 44px`, `border-radius var(--space-1)`, `padding var(--space-3) var(--space-4)`, `border` flips to `--color-destructive` on error).
**Password section (Section 2)** — copy `ResetPasswordSheet`'s new-password + confirm fields and mismatch logic:
```typescript
// AdminPage.tsx:1426-1443 — mutation with client-side mismatch guard
const resetMutation = useMutation({
mutationFn: async () => {
if (newPassword !== confirmPassword) throw new Error('mismatch');
await fetchAdminResetPassword(member.id, newPassword);
},
onSuccess: () => { handleClose(); onSuccess?.(); },
onError: (err) => {
const msg = err instanceof Error ? err.message : 'server';
if (msg === 'mismatch') setError('Passwords do not match.');
else setError('Something went wrong. Please try again.');
},
});
```
UI-SPEC adds a `< 8 chars` guard ("Password must be at least 8 characters.") — mirror the create-member length check at `AdminPage.tsx:267-269`. Fields use `autoComplete="new-password"`, never prefilled (T-10-15/16). **Per-section** save (not handleClose-on-success) — Section 2 success keeps the sheet open and fires toast "Password updated." Only render this section when `member.hasLocalCredential === true`.
**App-password section (Section 3)** — copy CredentialSheet's email + password fields (`:249-320`), the "Validating against CalDAV…" `Loader2` inline state (`:337-345`), the helper link to Fastmail device tokens (`:351-362`), and the FAILURE_TEXT copy (`:65-66`). The mutation maps to `saveCredential` (`:124-153`) — admin mode requires `userId: memberId`. Prefill `fastmailEmail` read-only convenience in edit mode (Claude's-discretion D — the stored `fastmailEmail` exists on `memberCredentials.fastmail_email`, but is NOT currently returned by `GET /members`; the password field is never prefilled).
**Create-mode form (single form, no dividers)** — copy the four fields + mutation from `AdminPage.tsx:260-306` (`createMemberMutation`): display name / username / initial password / confirm. Maps to `fetchCreateMember` (`client.ts:184`). Reuse the exact error mapping (`mismatch` / `short` / `conflict` → "That username is already in use.").
**Per-section Save button** (`CredentialSheet.tsx:397-419`): accent `--color-member-0` background when enabled, `--color-border` when disabled, white text, `min-height 44px`, `border-radius var(--space-1)`, inline `Loader2 size={14}` while pending (pattern at `AdminPage.tsx:651-657`).
**Cancel button** (`CredentialSheet.tsx:375-395`): no background, `--color-text-secondary`, `min-height 44px`.
---
### `apps/api/src/routes/admin.ts` — NEW `PATCH /members/:id` (route, CRUD update)
**Primary analog:** `POST /members/:id/password` (`admin.ts:225-269`) — same `:id` param shape, same `requireAdmin` boundary, same noEchoHook posture, same existence-check-then-update flow.
**Secondary analog:** the admin-count query in `auth/user.ts:151-156` — reuse for the D-03 last-admin guard.
**Route handler shape — copy** (`admin.ts:225-269`):
```typescript
const updateMemberSchema = z.object({
displayName: z.string().min(1).max(256).optional(),
isAdmin: z.boolean().optional(),
});
adminRouter.patch(
'/members/:id',
zValidator('json', updateMemberSchema, noEchoHook),
async (c) => {
const targetId = parsePositiveIntParam(c.req.param('id')); // :87
if (targetId === null) return c.json({ error: 'Invalid member id' }, 400);
const { displayName, isAdmin } = c.req.valid('json');
// ... existence check + last-admin guard + update
},
);
```
- `parsePositiveIntParam` already exists (`admin.ts:87-92`) — reuse, do NOT re-implement.
- `requireAdmin` is already mounted router-wide (`admin.ts:47`) — the new route inherits it automatically; **no new boundary** (D-02).
- `noEchoHook` (`admin.ts:75-79`) — apply even though no password is in this body, for consistency with the other admin write routes.
**Last-admin guard (D-03) — adapt from `auth/user.ts:151-156`:**
```typescript
// Count remaining admins; reject demotion of the only admin.
const [{ count }] = await db
.select({ count: sql<number>`COUNT(*)` })
.from(users)
.where(eq(users.isAdmin, true))
.limit(1);
```
When `isAdmin === false` is requested for a target that is currently an admin AND `Number(count) <= 1`, return 409 (or 422) `{ error: ... }`. UI-SPEC client maps this to "Cannot remove admin — at least one admin must remain." Note: `sql` and `eq` are already imported (`admin.ts:28`).
**Update + error handling** — mirror the try/catch + 503 fallback of the password route (`admin.ts:249-268`). Build a partial `set({ ... })` from whichever of `displayName` / `isAdmin` is present. Verify the target user exists (404 if not), matching the password route's `if (!credRow) return 404` shape (`:245`).
**Verb choice (Claude's discretion, D-02):** Hono supports `adminRouter.patch(...)`. The repo's existing admin writes use `POST` (`/members`, `/credentials`) and `PUT` (`/calendars/:id/shared`, `/config/timezone`); a `PATCH` for partial member update is idiomatic and consistent with REST, but `POST /members/:id` is equally acceptable — planner's call.
---
### `apps/api/src/routes/admin.ts``GET /members` adds `isAdmin` (route, CRUD read)
**Analog:** the existing handler itself (`admin.ts:102-124`). Add `isAdmin` to the select and the mapped object:
```typescript
.select({
id: users.id,
displayName: users.displayName,
color: users.color,
isAdmin: users.isAdmin, // NEW — feeds the editor toggle initial state (D-02)
credentialId: memberCredentials.id,
localCredId: localCredentials.id,
})
// ...
const members = rows.map((row) => ({
id: row.id,
displayName: row.displayName,
color: row.color,
isAdmin: row.isAdmin, // NEW
hasCredential: row.credentialId !== null,
hasLocalCredential: row.localCredId !== null,
}));
```
`users.isAdmin` already exists in the schema (`db/schema.ts:56`, `boolean('is_admin')`). No join change needed — it's a column on the base `users` table already in the FROM.
---
### `apps/pwa/src/api/client.ts` (api client — type + fetcher)
**Analog for the type:** `AdminMember` interface (`client.ts:563-569`). Add `isAdmin: boolean`:
```typescript
export interface AdminMember {
id: number;
displayName: string | null;
color: string;
isAdmin: boolean; // NEW — Phase 20 (drives editor admin toggle initial state)
hasCredential: boolean;
hasLocalCredential: boolean;
}
```
**Analog for the new fetcher:** `fetchAdminResetPassword` (`client.ts:218-234`) — same `:id` path, same POST/PATCH-with-json shape, same error handling. New `updateMemberProfile`:
```typescript
export async function updateMemberProfile(
memberId: number,
body: { displayName?: string; isAdmin?: boolean },
): Promise<void> {
const res = await fetch(`/api/admin/members/${memberId}`, {
method: 'PATCH', // match the chosen route verb
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
redirect: 'manual',
body: JSON.stringify(body),
});
if (res.type === 'opaqueredirect' || res.status === 401) throw new SessionExpiredError();
if (res.status === 409 || res.status === 422) throw new Error('last-admin'); // D-03 guard
if (!res.ok) throw new Error(`updateMemberProfile failed: ${res.status}`);
}
```
The `SessionExpiredError` + `handleAuthResponse` conventions are already in this file (used by every fetcher). Map the last-admin 409/422 to a sentinel the editor's onError can branch on (mirrors the `conflict` sentinel pattern at `client.ts:206`).
---
### `apps/pwa/src/routes/AdminPage.tsx` (route/page — MemberRow rework + triggers)
**MemberRow → tappable row analog:** `apps/pwa/src/components/ListCard.tsx` — a whole-row `<button>`/tappable surface with a trailing `ChevronRight`.
**ChevronRight affordance — copy** (`ListCard.tsx:139-144`):
```typescript
<ChevronRight
size={16}
color="var(--color-text-muted)"
aria-hidden="true"
style={{ flexShrink: 0, marginLeft: 'var(--space-2)' }}
/>
```
Import: `import { ChevronRight, Plus } from 'lucide-react';` (both already used elsewhere — `ListCard.tsx:21`, `ListsIndex.tsx:24`).
**Row interaction (UI-SPEC §Surface A):** make the row `role="button"`, `aria-label="Edit {displayName}"`, `tabIndex={0}`, `cursor: pointer`, Enter/Space opens the editor. The existing `CalendarRadioRow` (`AdminPage.tsx:1298-1318`) is a good in-file template for the `role` + `onKeyDown` Enter/Space handler:
```typescript
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onSelect(); }
}}
```
**Keep** the avatar swatch (`AdminPage.tsx:1167-1176`, `var(--color-member-${colorIndex})`) and the credential status badge (`:1193-1235`, CheckCircle/AlertCircle "Credential set" / "No credential"). **Remove** the entire action-button cluster (`:1238-1285`: "Rotate"/"Add credential" + "Reset password").
**New Admin badge (UI-SPEC §Surface A):** when `member.isAdmin`, render an inline "Admin" pill (12px/600, `--color-member-0` text on `--color-surface-dim`, `border-radius 4px`, `padding 2px 6px`) — same shape as the "Shared" badge in `ListCard.tsx:124-133`.
**"Add member" trigger button (UI-SPEC §Surface A):** ghost button with `<Plus size={16}>` prefix, `1px solid var(--color-border)`, `border-radius 8px`, `padding var(--space-3) var(--space-4)`, `min-height 44px`, `--color-surface` bg / `--color-surface-dim` hover. The `Plus`-prefixed button pattern is at `CalendarShell.tsx:451-456`.
**Sheet wiring & state** — replace the dual `CredentialSheet` + `ResetPasswordSheet` mounting (`AdminPage.tsx:1111-1137`) with a single `MemberEditorSheet`. Reuse the existing `triggerRef` capture pattern (`:252-258`, `openSheet`) so focus returns to the tapped row on close. Add an `addMemberTriggerRef` for the create-mode trigger (focus returns there on cancel, per UI-SPEC Interaction Contract).
**Remove from this file:** the entire inline Add-member form (`:443-662`, the "Local Accounts" section body), the `ResetPasswordSheet` component definition (`:1375-1677`), and all create-form local state (`createDisplayName``createError`, `:94-98`) — these move into `MemberEditorSheet`.
---
## Shared Patterns
### TanStack Query mutation + invalidation
**Source:** `CredentialSheet.tsx:124-153` and `AdminPage.tsx` mutations.
**Apply to:** every per-section save in `MemberEditorSheet`.
```typescript
const m = useMutation({
mutationFn: async () => { /* call client fetcher */ },
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['admin', 'members'] });
void queryClient.invalidateQueries({ queryKey: ['me'] }); // credential/app-password changes only
showToast('…'); // per-section toast; sheet STAYS open (per-section save model, D-05)
},
onError: (err) => { /* map sentinel → inline error string */ },
});
```
Profile/password saves invalidate only `['admin','members']`; app-password save also invalidates `['me']` (needsProviderSetup refresh — `CredentialSheet.tsx:146-147`).
### Success toast (D-08)
**Source:** `AdminPage.tsx:66-76` (`showToast` + auto-dismiss effect) and the toast JSX (`:1068-1109`).
**Apply to:** all editor saves. Keep `showToast` in `AdminPage` and pass an `onToast`/`onSuccess` callback into `MemberEditorSheet` (the way `ResetPasswordSheet` receives `onSuccess` at `AdminPage.tsx:1134`), OR lift the toast into the sheet — planner's call. Toast copy per UI-SPEC: "Profile saved." / "Password updated." / "App password saved." / "Member added."
### Write-only password handling (T-10-15 / T-10-16)
**Source:** `CredentialSheet.tsx:299-319` (app password), `AdminPage.tsx:1544-1563` (reset password).
**Apply to:** every password/app-password field in the editor. Never prefill, `autoComplete="new-password"`, never log, blank = unchanged (D-05).
### Server admin boundary (no new boundary — D-02)
**Source:** `apps/api/src/lib/requireAdmin.ts` + `admin.ts:47` (`adminRouter.use('*', requireAdmin)`).
**Apply to:** the new `PATCH /members/:id` — it inherits the router-wide guard automatically. Do not add a second guard.
### noEchoHook for admin write routes
**Source:** `admin.ts:75-79`.
**Apply to:** the new member-profile route's `zValidator`.
---
## No Analog Found
None. Every new file and route has a direct, recent in-repo analog. The only genuinely new logic is the **D-03 last-admin guard**, and even that adapts the existing admin-count query from `apps/api/src/auth/user.ts:151-156`.
---
## Metadata
**Analog search scope:** `apps/pwa/src/components/`, `apps/pwa/src/routes/`, `apps/pwa/src/api/`, `apps/pwa/src/hooks/`, `apps/api/src/routes/`, `apps/api/src/lib/`, `apps/api/src/auth/`, `apps/api/src/db/`
**Files scanned:** ~12
**Pattern extraction date:** 2026-06-18
@@ -0,0 +1,59 @@
---
phase: 20-admin-member-editor-form-declutter
fixed_at: 2026-06-18T14:45:00Z
source_review: 20-REVIEW.md
fix_scope: all
findings_in_scope: 11
fixed: 11
skipped: 0
iteration: 2
status: all_fixed
gates:
typecheck: pass
eslint: pass
prettier: pass
api_tests: 462/462
---
# Phase 20 — Code Review Fix Report
Auto-fix pass over the deep review (`20-REVIEW.md`, 2 critical / 6 warning / 3 info).
All 11 findings fixed and committed atomically; a deep re-review (iteration 2)
independently confirmed 0 critical / 0 warning remain.
> Note: this report was reconstructed by the orchestrator — the fixer agent applied
> and committed every fix but its `REVIEW-FIX.md` write did not persist. The commit
> hashes below are the source of truth.
## Fixes applied
| ID | Severity | Fix | Commit |
|----|----------|-----|--------|
| CR-01 | Critical | Last-admin guard made atomic — single conditional UPDATE / `affectedRows` check closes the TOCTOU window; 409 response shape unchanged | `7297733` |
| WR-06 | Warning | Empty `{}` PATCH body now rejected with a clean 400 via Zod refinement (was a Drizzle 503 on empty SET); new test added | `7297733` |
| CR-02 | Critical | `AdminPage` derives the editor's member from live query data and refetches/invalidates after a save — no stale-snapshot demotion overwrite | `ee04aee` |
| WR-01 | Warning | Toggle-only saves no longer re-send `displayName`, so admin-toggle saves don't 400 for OIDC-provisioned members with a null/empty stored name | `527d855` |
| WR-02 | Warning | 409 revert uses the actual prior toggle state instead of defaulting to `true` | `527d855` |
| WR-04 | Warning | `handleClose` closure fixed — Cancel after a per-section save no longer reverts to pre-save values | `527d855` |
| WR-03 | Warning | Phone bottom-sheet gains `maxHeight` + `overflowY: auto` so the action button is reachable on short phones | `2fd253e` |
| IN-03 | Info | Phone sheet adds `env(safe-area-inset-bottom)` padding (iOS home indicator) | `2fd253e` |
| WR-05 | Warning | Admin toggle gains `aria-describedby` linking to the last-admin error region | `d2e9862` |
| IN-01 | Info | Helper text shown when display name is empty | `400733f` |
| IN-02 | Info | `maxLength` added to display-name and username inputs | `182ba1d` |
(`41a4fae` — prettier formatting of the updated `admin.test.ts`.)
## Verification
- `pnpm -r typecheck` — pass (API + PWA)
- ESLint — 0 warnings
- Prettier — clean
- API integration tests — **462/462** (includes a new Test H asserting empty-body PATCH → 400)
## Introduced during fixes (caught by iteration-2 re-review)
- **IN-04 (Info, resolved):** The no-op profile-save path (`mutationFn` returns early on an
empty payload) triggered `onSuccess`, firing the "Profile saved." toast and refetching the
`['admin','members']` query even when nothing changed. Fixed in `5161bd3``mutationFn`
now returns a `changed` flag and `onSuccess` skips the toast/refetch when no write occurred.
Typecheck/eslint/prettier all pass.
@@ -0,0 +1,457 @@
---
phase: 20-admin-member-editor-form-declutter
reviewed: 2026-06-18T12:00:00Z
depth: deep
files_reviewed: 5
files_reviewed_list:
- apps/api/src/routes/admin.ts
- apps/api/tests/routes/admin.test.ts
- apps/pwa/src/api/client.ts
- apps/pwa/src/components/MemberEditorSheet.tsx
- apps/pwa/src/routes/AdminPage.tsx
findings:
critical: 2
warning: 6
info: 3
total: 11
status: issues_found
---
# Phase 20: Code Review Report (Deep Re-Review)
**Reviewed:** 2026-06-18
**Depth:** deep (cross-file, call-chain, state-machine analysis)
**Files Reviewed:** 5
**Status:** issues_found
## Summary
Phase 20 adds `PATCH /api/admin/members/:id` (displayName + isAdmin update), a unified
`MemberEditorSheet` (edit/create modes), and a decluttered `AdminPage` member list. The
authorization boundary (`requireAdmin` first on the router, live DB lookup every request) is
sound and correctly inherited by all new routes. Password write-only discipline is preserved
across the PATCH→client→sheet→AdminPage chain. The credential echo-protection pattern and the
`noEchoHook` usage are consistent and correct.
This deep pass confirms all nine findings from the prior standard review (re-verified against the
current code — none have been remediated). Two are re-classified: CR-01 (TOCTOU) remains
Critical; the newly-discovered CR-02 (admin-demotion race via stale-member prop) joins it. WR-04
is materially worse in the deep view — the stale `editorMember` is never updated from query data,
making the Reset-to-pre-save regression reproducible on every session where a save is followed by
Cancel. Two new issues are also added: WR-06 (profileMutation unconditionally sends displayName,
blocking admin toggle on null-displayName members) and WR-07 (no-op {} PATCH crashes Drizzle
with a 503 instead of a proper 400).
---
## Critical Issues
### CR-01: Last-admin demotion guard is a non-atomic TOCTOU race
**File:** `apps/api/src/routes/admin.ts:251-267`
**Issue:** The D-03 last-admin guard issues a `SELECT COUNT(*) WHERE is_admin=true` and only
if the count is >1 proceeds to `UPDATE`. The SELECT and the UPDATE are not in a transaction.
Two concurrent PATCH requests demoting the two existing admins both read `count=2`, both pass
the guard, and both updates commit — leaving the household with zero admins. MariaDB's default
InnoDB READ COMMITTED isolation does not prevent this: a phantom read between the COUNT and the
UPDATE is possible even in REPEATABLE READ unless a locking read (`FOR UPDATE`/`FOR SHARE`) is
used. The test suite (Test C and Test D) exercises the single-request path only; no concurrent
scenario is tested.
```
Thread 1: SELECT COUNT(*) WHERE is_admin=true → 2 → passes guard
Thread 2: SELECT COUNT(*) WHERE is_admin=true → 2 → passes guard
Thread 1: UPDATE users SET is_admin=false WHERE id=1 → ok
Thread 2: UPDATE users SET is_admin=false WHERE id=2 → ok (now 0 admins)
```
**Fix:** Wrap the full guard-plus-update in a transaction and use a locking read:
```typescript
await db.transaction(async (tx) => {
const [target] = await tx
.select({ id: users.id, isAdmin: users.isAdmin })
.from(users)
.where(eq(users.id, targetId))
.limit(1);
if (!target) throw new Error('not-found');
if (isAdmin === false && target.isAdmin) {
// Lock all admin rows before counting so concurrent demotions block each other
const [{ count }] = await tx
.select({ count: sql<number>`COUNT(*)` })
.from(users)
.where(eq(users.isAdmin, true));
// Note: add `.for('update')` when Drizzle exposes it, or use raw sql suffix
if (Number(count) <= 1) throw new Error('last-admin');
}
const updates: { displayName?: string; isAdmin?: boolean } = {};
if (displayName !== undefined) updates.displayName = displayName;
if (isAdmin !== undefined) updates.isAdmin = isAdmin;
await tx.update(users).set(updates).where(eq(users.id, targetId));
});
```
Alternatively, replace the SELECT/UPDATE pair with a single atomic conditional UPDATE and check
`affectedRows`:
```sql
UPDATE users
SET is_admin = false
WHERE id = :targetId
AND (SELECT COUNT(*) FROM users u2 WHERE u2.is_admin = true) > 1
```
---
### CR-02: Stale `editorMember` in AdminPage means per-section save can silently overwrite a concurrent admin change
**File:** `apps/pwa/src/routes/AdminPage.tsx:83,243` / `apps/pwa/src/components/MemberEditorSheet.tsx:262-265`
**Issue:** `editorMember` is set once when the user taps a row (`openEditorForMember` at line 243)
and is never refreshed from query data. `MemberEditorSheet` receives this as `member` and
`profileMutation.mutationFn` (line 262-265) unconditionally sends both `displayName` and
`isAdmin`:
```typescript
await updateMemberProfile(member.id, {
displayName: displayName.trim(),
isAdmin, // ← always the value at sheet-open time, not query-refreshed
});
```
Scenario: Admin A opens the editor for Member X (isAdmin=false). Admin B (in another session)
concurrently promotes Member X to admin. The server query cache eventually refetches and
`membersQuery.data` shows `isAdmin=true`. But `editorMember` in AdminPage is still the stale
object (`isAdmin=false`). Admin A's sheet still shows the toggle in the "off" position (because
`useEffect` at line 234 re-syncs on `member?.isAdmin` change, but the `member` prop itself is
never updated from the fresh query data — `editorMember` is the source and it never changes).
Admin A clicks Save without touching the toggle → `isAdmin: false` is sent → Member X is silently
demoted back to non-admin. No warning is shown. The profile-save toast reads "Profile saved."
This is the cross-file manifestation of WR-04 (stale closure) compounded by the fact that
`editorMember` is never derived from `membersQuery.data`.
**Fix:** Derive the member prop from the live query data instead of holding a stale copy:
```typescript
// In AdminPage:
const editorMember = editorMemberId !== null
? (membersQuery.data?.members.find((m) => m.id === editorMemberId) ?? null)
: null;
```
Replace `setEditorMember(member)` with `setEditorMemberId(member.id)`. This way, whenever
`membersQuery.data` updates (e.g., after a save + invalidation), the derived `editorMember` is
always fresh. The existing `useEffect` in `MemberEditorSheet` (line 234) already reacts to
`member?.isAdmin` and `member?.displayName` changes, so the form state stays in sync
automatically.
---
## Warnings
### WR-01: Profile save always sends `displayName` even when only `isAdmin` changed; blocks admin toggle for null-displayName members
**File:** `apps/pwa/src/components/MemberEditorSheet.tsx:262-265, 568`
**Issue:** `profileMutation.mutationFn` always sends `{ displayName: displayName.trim(), isAdmin }`.
Two distinct problems follow:
1. **Partial-update miss.** Every profile save re-writes the displayName even when the admin only
toggled the admin flag. This doubles the blast radius of a profile save.
2. **Toggle blocked on null displayName.** The DB schema (`apps/api/src/db/schema.ts:52`)
defines `display_name` as a nullable varchar (no `.notNull()`). An OIDC-provisioned user whose
ID token had no `name` claim can have `displayName = null`. The editor initialises `displayName`
state to `member?.displayName ?? ''``''`. The Save button is disabled when
`displayName.trim().length === 0` (line 568), so the admin cannot toggle the admin flag for
this member at all — the Save button remains permanently disabled with no explanatory copy.
There is no empty-state message telling the admin they must add a name first.
**Fix — option A (preferred):** Send only changed fields:
```typescript
mutationFn: async () => {
if (!member) throw new Error('no-member');
const payload: { displayName?: string; isAdmin?: boolean } = {};
if (displayName.trim() !== (member.displayName ?? '')) {
if (displayName.trim().length === 0) throw new Error('name-required');
payload.displayName = displayName.trim();
}
if (isAdmin !== member.isAdmin) payload.isAdmin = isAdmin;
if (Object.keys(payload).length === 0) return; // no-op guard
await updateMemberProfile(member.id, payload);
},
```
**Fix — option B (minimal):** Add an inline note when displayName is empty to explain why Save is
disabled:
```tsx
{displayName.trim().length === 0 && (
<div style={inlineErrorStyle}>A display name is required before saving.</div>
)}
```
---
### WR-02: D-03 error revert uses `member?.isAdmin ?? true` — wrong default direction
**File:** `apps/pwa/src/components/MemberEditorSheet.tsx:276`
**Issue:** When the server returns 409 (last-admin guard), `onError` reverts the toggle with:
```typescript
setIsAdmin(member?.isAdmin ?? true);
```
The `?? true` default is semantically wrong. The guard fires only when the admin tries to
demote the last admin, meaning the correct revert value is `true` (the member IS admin). However
the `?? true` codifies this accidentally — if `member` were ever undefined here for another reason,
any future mutation reuse could silently set `isAdmin=true` on an unrelated user. The mutation
already guards `if (!member) throw new Error('no-member')` at line 261 so a missing `member` in
the 409 path is structurally impossible today. The defect is that the code is correct only by
coincidence, and the fallback `true` would be wrong if the same handler were reused to revert any
*other* error that legitimately has `member=undefined`.
**Fix:** Capture the pre-mutation value at call time and carry it through context:
```typescript
const profileMutation = useMutation({
mutationFn: async () => {
if (!member) throw new Error('no-member');
await updateMemberProfile(member.id, { displayName: displayName.trim(), isAdmin });
},
onError: (err) => {
const msg = err instanceof Error ? err.message : 'server';
if (msg === 'last-admin') {
// member is guaranteed non-null here (no-member throws before the API call)
setIsAdmin(member!.isAdmin); // ← explicit, not ?? true
setProfileError('Cannot remove admin — at least one admin must remain.');
} else {
setProfileError('Something went wrong. Please try again.');
}
},
});
```
---
### WR-03: Phone bottom-sheet lacks `maxHeight`/`overflowY` — action buttons unreachable on short phones
**File:** `apps/pwa/src/components/MemberEditorSheet.tsx:379-392`
**Issue:** The desktop sheet style (lines 399-407) sets `maxHeight: 'calc(100dvh - var(--space-8, 32px))'`
and `overflowY: 'auto'`. The phone bottom-sheet style (lines 380-392) has neither. In edit mode
with all three sections visible (Profile + Set new password + App password), the content exceeds
the viewport height on a 667px-tall iPhone SE. There is no scroll affordance; the "Save app
password" button is unreachable without a way to scroll.
**Fix:**
```typescript
// phone branch of sheetStyle:
{
position: 'fixed',
bottom: 0,
left: 0,
right: 0,
maxHeight: '90dvh',
overflowY: 'auto',
background: 'var(--color-surface)',
borderRadius: '12px 12px 0 0',
boxShadow: '0 -4px 24px rgba(0,0,0,0.15)',
padding: 'var(--space-6, 24px)',
paddingBottom: 'calc(var(--space-6, 24px) + env(safe-area-inset-bottom, 0px))',
zIndex: 301,
fontFamily: 'var(--font-family-base)',
}
```
This also resolves IN-03 (missing `env(safe-area-inset-bottom)`) in a single fix.
---
### WR-04: `handleClose` useCallback holds stale `member` fields — Cancel after per-section save resets to pre-save values
**File:** `apps/pwa/src/components/MemberEditorSheet.tsx:210-231`
**Issue:** `handleClose` is memoised:
```typescript
const handleClose = useCallback(() => {
setDisplayName(member?.displayName ?? ''); // ← captures member at memo creation time
setIsAdmin(member?.isAdmin ?? false);
...
}, [onClose, triggerRef, member?.displayName, member?.isAdmin]);
```
When `profileMutation.onSuccess` fires, it invalidates `['admin','members']`. The query refetches
and `membersQuery.data` updates. BUT `editorMember` in `AdminPage` is never derived from
`membersQuery.data` (confirmed by inspection — see CR-02). So `member?.displayName` in the
dependency array still holds the pre-save value. `handleClose` correctly rebuilds when the dep
changes in principle, but since `editorMember` never updates, the dep never changes.
Concretely: admin saves "New Name" → toast "Profile saved." → clicks Cancel → form resets to
"Old Name". The next GET /api/admin/members will show the correct new name in the list row, but
the sheet state that Cancel resets to is stale.
**Fix (preferred, pairs with CR-02 fix):** Once `editorMember` is derived from live query data
(CR-02 fix), `member?.displayName` in the dep array will update after a save+refetch, and
`handleClose` will capture the refreshed value. Separately, remove the redundant form-field
resets from `handleClose` for member-sourced fields and let the existing `useEffect` (line 234)
own that state:
```typescript
const handleClose = useCallback(() => {
// Only reset ephemeral fields (not member-derived: those belong to useEffect)
setProfileError(null);
setNewPassword('');
setConfirmPassword('');
setPasswordError(null);
setFastmailEmail('');
setAppPassword('');
setAppPasswordError(null);
setCreateDisplayName('');
setCreateUsername('');
setCreatePassword('');
setCreateConfirmPassword('');
setCreateError(null);
onClose();
if (triggerRef?.current) triggerRef.current.focus();
}, [onClose, triggerRef]);
```
---
### WR-05: Admin toggle missing `aria-describedby` for the last-admin error
**File:** `apps/pwa/src/components/MemberEditorSheet.tsx:515-537`
**Issue:** The display-name input has `aria-describedby={profileError ? 'profile-error' : undefined}`
(line 479), correctly linking it to the shared error container at line 557. However the admin
toggle button (line 515) has `aria-label="Admin"` but no `aria-describedby`. When the last-admin
guard fires, `profileError` is set and the error `<div id="profile-error">` renders below the
action buttons — but screen-reader users who activated the toggle have no announcement path from
the toggle element to the error message.
**Fix:**
```tsx
<button
type="button"
role="switch"
aria-checked={isAdmin}
aria-label="Admin"
aria-describedby={profileError ? 'profile-error' : undefined}
onClick={() => { setProfileError(null); setIsAdmin((prev) => !prev); }}
...
>
```
---
### WR-06: Empty `{}` PATCH body passes Zod but causes Drizzle to throw → returns 503 instead of 400
**File:** `apps/api/src/routes/admin.ts:225-228, 261-275`
**Issue:** `updateMemberSchema` marks both fields optional:
```typescript
const updateMemberSchema = z.object({
displayName: z.string().min(1).max(256).optional(),
isAdmin: z.boolean().optional(),
});
```
A client that sends `{}` passes Zod validation. Inside the handler, the `updates` object remains
`{}` (lines 262-264, neither branch fires). `db.update(users).set({}).where(...)` is then called.
In Drizzle ORM 0.45.x (mysql dialect) an empty `set({})` produces invalid SQL (`UPDATE users SET
WHERE id = ?`) and the mysql2 driver throws a query error. The `catch` block at line 269 returns
`503 Service unavailable` rather than a proper `400 Bad Request`. Callers receive an incorrect
status that implies a transient server failure rather than a client error.
The existing client (`updateMemberProfile` in `client.ts`) always sends at least one field, so
this path is unreachable from the UI today. It is reachable via direct API access.
**Fix:** Add a Zod refinement or an explicit pre-flight check:
```typescript
const updateMemberSchema = z.object({
displayName: z.string().min(1).max(256).optional(),
isAdmin: z.boolean().optional(),
}).refine(
(data) => data.displayName !== undefined || data.isAdmin !== undefined,
{ message: 'At least one field must be provided' },
);
```
This returns a 400 through the existing `noEchoHook` before the handler body runs.
---
## Info
### IN-01: Profile-section "Save" in edit mode blocks admin-toggle saves when member has no Fastmail credential
**File:** `apps/pwa/src/components/MemberEditorSheet.tsx:567-575`
**Issue:** The Save button for Section 1 (Profile) is disabled when `displayName.trim().length === 0`.
This is correct as a client-side guard, but there is no visible copy explaining *why* Save is
disabled when the member's display name is null (a valid DB state for OIDC-provisioned users with
no name claim). The button is greyed out and inert with no tooltip or inline copy. An admin who
taps a member row and sees a greyed Save button for the admin toggle has no indication of what to
do.
**Fix:** Render a short helper line when `displayName.trim().length === 0`:
```tsx
{displayName.trim().length === 0 && (
<p style={helperTextStyle}>Enter a display name to enable Save.</p>
)}
```
### IN-02: Display-name inputs lack `maxLength` — long entries get a generic server-side 400
**File:** `apps/pwa/src/components/MemberEditorSheet.tsx:474-481, 793-799`
**Issue:** Both the edit-mode display-name input (line 476) and the create-mode display-name input
(line 795) have no `maxLength` attribute. The server schema enforces `max(256)` via Zod, but a
client submission exceeding 256 characters returns a generic 400 (the `noEchoHook` maps all Zod
failures to `{ error: 'Invalid request' }`) with no user-visible copy explaining the length limit.
The edit-mode username input in create mode (line 805) similarly has no `maxLength={128}`.
**Fix:**
```tsx
<input id="editor-display-name" type="text" maxLength={256} ... />
<input id="create-display-name" type="text" maxLength={256} ... />
<input id="create-username" type="text" maxLength={128} ... />
```
### IN-03: Phone bottom-sheet does not account for `env(safe-area-inset-bottom)`
**File:** `apps/pwa/src/components/MemberEditorSheet.tsx:380-392`
**Issue:** The phone sheet style uses `bottom: 0` with no `paddingBottom` accounting for the iOS
home-indicator / Android gesture-navigation bar. On a notched or edge-to-edge device, the Cancel
and Save buttons in Section 1 (the first action row visible on open) may sit behind the system
gesture bar. The `AdminPage` scroll container correctly uses
`calc(56px + env(safe-area-inset-bottom, 0px))` (line 269) for its fixed tab-bar clearance, but
the sheet itself does not.
**Fix:** Combined with WR-03 (add `maxHeight`/`overflowY` to the phone sheet), add bottom
padding:
```typescript
paddingBottom: 'calc(var(--space-6, 24px) + env(safe-area-inset-bottom, 0px))',
```
---
_Reviewed: 2026-06-18_
_Reviewer: Claude Sonnet 4.6 (gsd-code-reviewer, deep pass)_
_Depth: deep_
@@ -0,0 +1,183 @@
---
phase: 20-admin-member-editor-form-declutter
reviewed: 2026-06-18T14:30:00Z
depth: deep
files_reviewed: 5
files_reviewed_list:
- apps/api/src/routes/admin.ts
- apps/api/tests/routes/admin.test.ts
- apps/pwa/src/api/client.ts
- apps/pwa/src/components/MemberEditorSheet.tsx
- apps/pwa/src/routes/AdminPage.tsx
findings:
critical: 0
warning: 0
info: 0
total: 0
status: clean
---
# Phase 20: Code Review Report (Deep Re-Review — Iteration 2)
**Reviewed:** 2026-06-18
**Depth:** deep (cross-file, call-chain, state-machine analysis)
**Files Reviewed:** 5
**Status:** clean
## Summary
All 11 findings from the prior pass (2 Critical, 6 Warning, 3 Info) are genuinely resolved — not superficially patched. Verification traces are below.
One new Info-level issue was introduced by the no-op guard fix: the "Profile saved." toast fires even when the admin clicks Save without changing anything, because `mutationFn` returns early (no network call) but `onSuccess` still runs unconditionally.
---
## Prior Finding Verification
### CR-01 — Last-admin guard now atomic: RESOLVED
`apps/api/src/routes/admin.ts:258286`
The guard and UPDATE are wrapped in a single `db.transaction()` call. Inside the transaction, the target row is re-read with a plain (non-locking) SELECT. The locking read is then a raw `tx.execute(sql\`SELECT COUNT(*) AS count FROM ... WHERE is_admin = true FOR UPDATE\`)`. Under InnoDB REPEATABLE READ (MariaDB default), `FOR UPDATE` acquires exclusive row locks on all qualifying rows, serialising concurrent demotion transactions: the second PATCH blocks until the first commits, then re-reads a count of 1 and trips the guard.
The `tx.execute()` call uses the transaction's dedicated connection (confirmed via drizzle-orm 0.45.2 `mysql2/session.js`: the transaction callback receives a `MySql2Transaction` whose session holds the connection obtained by `pool.getConnection()` — the same connection that issued `BEGIN`). The FOR UPDATE lock is therefore in-scope for the transaction.
The COUNT result is destructured as `[[{ count }]]` from the raw execute result `[RowDataPacket[], FieldPacket[]]`. The cast is correct. `Number(count)` safely handles both `number` and `string` returns from MariaDB.
The 409 response shape `{ error: 'Cannot remove the last admin' }` is unchanged. The client (`client.ts:262`) maps 409 → `throw new Error('last-admin')`, and the sheet's `onError` checks `msg === 'last-admin'`. The chain is intact.
Test C and Test D exercise the single-request guard paths and still pass. No concurrent-scenario test exists, but the fix is structurally correct and cannot be unit-tested against a single in-process MariaDB without intentional sleep-based race staging.
---
### CR-02 — Stale editorMember: RESOLVED
`apps/pwa/src/routes/AdminPage.tsx:87, 122127`
`AdminPage` now stores only `editorMemberId: number | null` (line 87) and derives `editorMember` as a computed value on every render:
```typescript
const editorMember =
editorMemberId !== null
? (membersQuery.data?.members.find((m) => m.id === editorMemberId) ?? null)
: null;
```
`openEditorForMember` calls `setEditorMemberId(member.id)` (line 254). After `profileMutation.onSuccess` invalidates `['admin', 'members']` and the query refetches, `editorMember` is rederived from fresh data on the next render. The `useEffect` in `MemberEditorSheet` (line 235239) depends on `[member?.id, member?.displayName, member?.isAdmin]` and re-syncs form state immediately. The stale-snapshot overwrite path is closed.
---
### WR-01 — Profile mutation sends diff-only payload: RESOLVED
`apps/pwa/src/components/MemberEditorSheet.tsx:261275`
`mutationFn` now builds a partial payload: `displayName` is added only when `trimmed !== (member.displayName ?? '')`, and `isAdmin` only when `isAdmin !== member.isAdmin`. An admin toggling only the admin flag on a null-displayName member sends `{ isAdmin: true/false }` with no `displayName` field — the Zod schema accepts this (both optional, refine requires at least one). The Save button remains enabled as long as `displayName.trim().length > 0` (or the existing displayName is non-null and unchanged). Toggle-only saves on null-displayName members are now unblocked.
---
### WR-02 — Error revert uses explicit value: RESOLVED
`apps/pwa/src/components/MemberEditorSheet.tsx:290`
`setIsAdmin(member!.isAdmin)` replaces the accidental `?? true` default. The `member!` non-null assertion is safe here: `mutationFn` at line 262 throws `Error('no-member')` before any API call when `member` is undefined, so the 409 error path can only be reached with a non-null `member`. The revert is now semantically explicit.
---
### WR-03 — Phone bottom-sheet overflow: RESOLVED
`apps/pwa/src/components/MemberEditorSheet.tsx:395413`
The phone branch of `sheetStyle` now has `maxHeight: '90dvh'` and `overflowY: 'auto'` (lines 404405). All three sections scroll within the 90dvh cap on short phones.
---
### WR-04 — handleClose stale closure: RESOLVED
`apps/pwa/src/components/MemberEditorSheet.tsx:214232`
`handleClose` dependency array is now `[onClose, triggerRef]` — it no longer captures `member?.displayName` or `member?.isAdmin`. Only ephemeral fields (password inputs, error states, create-mode fields) are reset in `handleClose`. Member-derived fields (`displayName`, `isAdmin`) are owned exclusively by the `useEffect` at lines 235239, which fires whenever the live `member` prop changes. Cancel after a successful save now resets to the saved (fresh) values, not the pre-save snapshot.
---
### WR-05 — Admin toggle aria-describedby: RESOLVED
`apps/pwa/src/components/MemberEditorSheet.tsx:544`
The admin toggle `<button>` now has `aria-describedby={profileError ? 'profile-error' : undefined}`, linking it to the shared `<div id="profile-error">` error container (line 588). Screen-reader users who activated the toggle receive an announcement path to the last-admin guard error.
---
### WR-06 — Empty {} PATCH returns 400: RESOLVED
`apps/api/src/routes/admin.ts:225232`
`updateMemberSchema` now has a `.refine()` that rejects any body where both `displayName` and `isAdmin` are absent. The `noEchoHook` returns `{ error: 'Invalid request' }` 400 before the handler body executes. Drizzle is never called with an empty `set({})`.
Test H (line 12311241 in `admin.test.ts`) asserts this path returns 400 with `{ error: 'Invalid request' }`.
---
### IN-01 — Helper text for empty displayName: RESOLVED
`apps/pwa/src/components/MemberEditorSheet.tsx:580584`
The helper text "Enter a display name to enable Save." renders when `displayName.trim().length === 0 && !profileError`. Admins opening a null-displayName member's editor now see an explanation for why the Save button is disabled.
---
### IN-02 — maxLength on display-name and username inputs: RESOLVED
`apps/pwa/src/components/MemberEditorSheet.tsx:500, 826, 842`
All three inputs now have `maxLength`: edit-mode display-name `maxLength={256}` (line 500), create-mode display-name `maxLength={256}` (line 826), create-mode username `maxLength={128}` (line 842). Over-length submissions are prevented at the browser input level.
---
### IN-03 — Phone sheet safe-area padding: RESOLVED
`apps/pwa/src/components/MemberEditorSheet.tsx:411`
`paddingBottom: 'calc(var(--space-6, 24px) + env(safe-area-inset-bottom, 0px))'` is present in the phone branch, co-located with the `maxHeight`/`overflowY` fix from WR-03.
---
## Info
### IN-01: No-op profile save fires misleading "Profile saved." toast
**File:** `apps/pwa/src/components/MemberEditorSheet.tsx:274, 277280`
**Issue:** When the admin opens the editor and clicks Save without making any changes, `mutationFn` detects an empty payload (`Object.keys(payload).length === 0`) and returns early without calling the API. TanStack Query v5 treats a non-throwing return as a successful mutation and calls `onSuccess`, which fires `invalidateQueries(['admin', 'members'])` and `onToast('Profile saved.')`. The admin sees a confirmation toast for an action that sent nothing. The query also refetches unnecessarily.
This cannot be reached through the empty-displayName path (Save is disabled then), but it is reachable any time an admin opens a sheet and saves without touching anything.
**Fix:** Guard the toast and invalidation on whether a payload was actually sent:
```typescript
mutationFn: async () => {
if (!member) throw new Error('no-member');
const payload: { displayName?: string; isAdmin?: boolean } = {};
const trimmed = displayName.trim();
if (trimmed !== (member.displayName ?? '')) {
if (trimmed.length === 0) throw new Error('name-required');
payload.displayName = trimmed;
}
if (isAdmin !== member.isAdmin) payload.isAdmin = isAdmin;
if (Object.keys(payload).length === 0) return { noop: true };
await updateMemberProfile(member.id, payload);
return { noop: false };
},
onSuccess: (result) => {
if (result?.noop) return; // nothing changed — no toast, no refetch
void queryClient.invalidateQueries({ queryKey: ['admin', 'members'] });
onToast('Profile saved.');
},
```
Alternatively, disable the Save button when `displayName.trim() === (member?.displayName ?? '')` and `isAdmin === member?.isAdmin` (change-detection guard on the button itself).
---
_Reviewed: 2026-06-18_
_Reviewer: Claude Sonnet 4.6 (gsd-code-reviewer, deep pass — iteration 2)_
_Depth: deep_
@@ -0,0 +1,71 @@
---
phase: 20
slug: admin-member-editor-form-declutter
status: verified
threats_open: 0
asvs_level: 1
created: 2026-06-18
---
# Phase 20 — Security
> Per-phase security contract: threat register, accepted risks, and audit trail.
> Result: **SECURED** — 10/10 threats CLOSED. `register_authored_at_plan_time: true` (verify-only; no new-threat scan).
---
## Trust Boundaries
| Boundary | Description | Data Crossing |
|----------|-------------|---------------|
| client → /api/admin | Untrusted admin-session input crosses into the admin surface; guarded by router-wide `requireAdmin` (`admin.ts:48`). No NEW boundary added by this phase. | Member profile fields, `isAdmin` toggle, app-password credential |
| PWA → /api/admin | Client fetch (`updateMemberProfile`, `saveCredential`) calls into the admin surface; server-side `requireAdmin` + last-admin guard are the real boundaries. Client toggle state is non-authoritative. | Same as above; password fields are write-only |
---
## Threat Register
| Threat ID | Category | Component | Disposition | Mitigation | Status |
|-----------|----------|-----------|-------------|------------|--------|
| T-20-01 | Elevation of Privilege | PATCH /members/:id isAdmin toggle | mitigate | Router-wide `requireAdmin` first statement (`admin.ts:48`); PATCH adds no second/weaker guard (`:234`); 403 test (`admin.test.ts:1180-1190`) | closed |
| T-20-02 | Denial of Service (self-lockout) | last-admin demotion | mitigate | Last-admin 409 guard, count+update in txn with `FOR UPDATE` (`admin.ts:271-293`); 409 test (`:1128-1150`), self-demote-with-2nd-admin 200 test (`:1153-1177`) | closed |
| T-20-03 | Tampering | malformed :id / wrong-type body | mitigate | `parsePositiveIntParam` rejects bad ids → 400 (`admin.ts:88-93,235`); `updateMemberSchema` + `noEchoHook` reject wrong types → 400 (`:225-234,76`); test F (`:1193-1216`) | closed |
| T-20-04 | Information Disclosure | error echo on invalid input | mitigate | `noEchoHook` returns only `{ error: 'Invalid request' }` (`admin.ts:76-80`); request body never logged (`:241,297-300`) | closed |
| T-20-05 | Spoofing (stale session) | updateMemberProfile fetch | mitigate | `SessionExpiredError` on 401/opaqueredirect reuses existing re-auth flow (`client.ts:261`); `redirect:'manual'` + `credentials:'include'` (`:254-258`) | closed |
| T-20-06 | Elevation of Privilege (client trust) | last-admin sentinel | accept | Server 409 authoritative (`admin.ts:271-293`); client only surfaces `'last-admin'` sentinel (`client.ts:262`). See Accepted Risks Log. | closed |
| T-20-07 | Information Disclosure | password / app-password fields | mitigate | Fields write-only: `type=password` + `autoComplete="new-password"`, blank init (`MemberEditorSheet.tsx:188-205,639-882`); no `console.*` logging (0 grep matches) | closed |
| T-20-08 | Tampering | CalDAV credential | mitigate | App-password save routes through `saveCredential` → server-side CalDAV validation before store (`MemberEditorSheet.tsx:338`, `admin.ts:373-393`); invalid → failure copy, nothing stored (`:355`) | closed |
| T-20-09 | Elevation of Privilege (UI bypass) | admin toggle | mitigate | Toggle cosmetic `role=switch` (`MemberEditorSheet.tsx:546`); reverts + inline error on `last-admin` (`:288-295`); real enforcement is server 409 (T-20-02) | closed |
| T-20-SC | Tampering (supply chain) | npm installs | mitigate | No new packages; `lucide-react@1.17.0` already in `package.json:30`; imports `AdminPage.tsx:28`, `MemberEditorSheet.tsx:25-27` | closed |
*Status: open · closed*
*Disposition: mitigate (implementation required) · accept (documented risk) · transfer (third-party)*
---
## Accepted Risks Log
| Risk ID | Threat Ref | Rationale | Accepted By | Date |
|---------|------------|-----------|-------------|------|
| AR-20-01 | T-20-06 | Client-side last-admin toggle state is non-authoritative by design. The demotion guard is enforced server-side (409, `admin.ts:271-293`); the client only surfaces the rejection via the `'last-admin'` sentinel (`client.ts:262`) and reverts the toggle (`MemberEditorSheet.tsx:294`). A tampered client that ignores the sentinel still cannot bypass the guard — the server rejects regardless. Matches existing pattern: "isAdmin drives nav visibility; the real boundary is server-side." Residual risk: none beyond the already-mitigated server boundary at ASVS L1. | Lucas Berger (per Plan 20-02 threat model) | 2026-06-18 |
---
## Security Audit Trail
| Audit Date | Threats Total | Closed | Open | Run By |
|------------|---------------|--------|------|--------|
| 2026-06-18 | 10 | 10 | 0 | gsd-security-auditor |
**Notable hardening beyond plan:** the last-admin guard wraps count+update in a transaction with a `FOR UPDATE` locking read (`admin.ts:249-286`) to defeat a concurrent double-demotion race — strengthens T-20-02 past the plan minimum.
---
## Sign-Off
- [x] All threats have a disposition (mitigate / accept / transfer)
- [x] Accepted risks documented in Accepted Risks Log
- [x] `threats_open: 0` confirmed
- [x] `status: verified` set in frontmatter
**Approval:** verified 2026-06-18
@@ -0,0 +1,62 @@
---
status: complete
phase: 20-admin-member-editor-form-declutter
source: [20-01-SUMMARY.md, 20-02-SUMMARY.md, 20-03-SUMMARY.md]
started: 2026-06-19T00:28:25Z
updated: 2026-06-19T00:34:38Z
verified_by: playwright-cli (Chromium, http://localhost:5173/admin, dev stack)
---
## Current Test
[testing complete]
## Tests
### 1. Open the Member Editor from a member row
expected: Members panel shows tappable rows (name + chevron, no per-row action buttons). Tapping a row opens one Member Editor sheet with all of that member's details.
result: pass
evidence: Members panel rendered one tappable button "Edit Dev User" (name + "Credential set" + Admin badge + chevron) and a single "Add member" trigger. Tapping it opened the "Edit member" dialog containing Profile (display name + Admin switch), "Set new password", and "App password" sections in one sheet.
### 2. Edit display name + admin flag (Profile section)
expected: Changing the display name and/or admin toggle and saving the Profile section persists; the row reflects the new name. No "saved" toast on a no-op save.
result: pass
evidence: Changed display name to "Dev User QA" → Save → GET /api/admin/members returned displayName "Dev User QA"; sheet stayed open (per-section save). Reverted to "Dev User" and re-verified.
### 3. Last-admin demotion is blocked
expected: Demoting the only admin shows an inline error and reverts the toggle; member stays admin.
result: pass
evidence: Toggled Admin off (aria-checked=false) → Save → 409 from PATCH /api/admin/members/1; inline error "Cannot remove admin — at least one admin must remain." rendered; switch reverted to aria-checked=true; member still isAdmin=true in DB. (The single console error is the expected 409 — not a bug.)
### 4. Set a new local-login password (write-only)
expected: Write-only password field (blank, never prefilled); does not echo any existing password.
result: pass
evidence: "Set new password" section fields start blank; New/Confirm/App-password inputs are type="password"; New-password autocomplete="new-password"; "Leave blank to keep the current password." helper shown. Save button disabled until filled.
### 5. Set / update the Fastmail app password (clear labels)
expected: Clear non-jargon labels (no "Rotate"); valid password CalDAV-validated before store; email field blank on edit.
result: pass
evidence: Section labeled "App password" with helper "Fastmail app password scoped to Calendars & Contacts (CalDAV)." plus a "Get an app password" link. App-password and Fastmail-email fields both start blank (value=""). No "Rotate" term present.
### 6. Add a member via the collapsed trigger
expected: "Add member" is a single collapsed trigger; tapping opens the editor in create mode.
result: pass
evidence: No inline always-open add form. Tapping the single "Add member" button opened an "Add member" dialog in create mode (Display name, Username, Initial password, Confirm password + disabled "Add member" submit).
### 7. Old jargon and buttons are gone
expected: "Rotate", "Add credential", and the standalone "Reset password" button no longer appear.
result: pass
evidence: DOM innerText scan on /admin returned {rotate:false, addCredential:false, resetPassword:false}.
## Summary
total: 7
passed: 7
issues: 0
pending: 0
skipped: 0
blocked: 0
## Gaps
[none — all tests passed]
@@ -0,0 +1,317 @@
---
phase: 20
slug: admin-member-editor-form-declutter
status: draft
shadcn_initialized: false
preset: none
created: 2026-06-18
---
# Phase 20 — UI Design Contract
> Visual and interaction contract for the Admin Member Editor & Form Declutter phase.
> Generated by gsd-ui-researcher, verified by gsd-ui-checker.
---
## Design System
| Property | Value |
|----------|-------|
| Tool | none — custom CSS custom properties |
| Preset | not applicable |
| Component library | none — inline styles referencing CSS tokens |
| Icon library | lucide-react (existing: CheckCircle, AlertCircle, Loader2; add ChevronRight) |
| Font | system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif |
No `components.json` detected. Project uses a hand-rolled design token system declared in
`apps/pwa/src/styles/tokens.css`. All new components must reference those CSS custom properties
(never hard-code hex/px). This is an established project convention — do not introduce shadcn or
any third-party component library in this phase.
---
## Spacing Scale
All tokens sourced from `apps/pwa/src/styles/tokens.css` (pre-populated — no change needed):
| Token | Value | Usage |
|-------|-------|-------|
| `--space-1` | 4px | Icon gaps, label-to-input margin |
| `--space-2` | 8px | Badge icon gap, toast icon gap, button row gap |
| `--space-3` | 12px | Field gap inside editor sections, tab padding |
| `--space-4` | 16px | Default field padding, section heading margin-bottom |
| `--space-6` | 24px | Sheet padding, section gap between Profile / Password / App-password |
| `--space-8` | 32px | Section bottom margin in Members panel |
| `--space-12` | 48px | Page top/bottom padding |
Exceptions:
- Member row minimum height: 44px (touch target floor — not a spacing token)
- Sheet border-radius: 12px phone bottom-sheet top corners / 12px desktop modal all corners (matches existing CredentialSheet / ResetPasswordSheet)
- MemberRow add-member trigger button bottom margin: `--space-4` below member list, above the trigger
---
## Typography
All values sourced from `tokens.css` (pre-populated — no change):
| Role | Size | Weight | Line Height | Usage in this phase |
|------|------|--------|-------------|---------------------|
| Body | 15px (`--text-body-size`) | 400 (`--text-body-weight`) | 1.5 (`--text-body-line-height`) | Member display name in row; section description text; subtitle line under sheet heading |
| Label | 13px (`--text-label-size`) | 400 or 600 | 1.4 (`--text-label-line-height`) | Form field labels (600), credential status badges (400), toast copy (400), section MEMBERS heading (600 + uppercase) |
| Heading | 18px (`--text-heading-size`) | 600 (`--text-heading-weight`) | 1.25 (`--text-heading-line-height`) | Sheet h2 ("Edit member", "Add member"), editor section dividers |
| Display | 24px (`--text-display-size`) | 600 (`--text-display-weight`) | 1.2 (`--text-display-line-height`) | Not used in this phase |
---
## Color
All values sourced from `tokens.css` (pre-populated):
| Role | Value | Usage |
|------|-------|-------|
| Dominant (60%) | `--color-surface` (#ffffff) | Page background, sheet background, input background |
| Secondary (30%) | `--color-surface-dim` (#f7f7f8) / `--color-border-subtle` (#eceef2) | MemberRow bottom border, section dividers, "Add member" trigger button border |
| Accent (10%) | `--color-member-0` (#e8915a) | Primary action buttons only (see reserved-for list below) |
| Destructive | `--color-destructive` (#dc2626) | Inline error text, error-state input border |
Accent (`--color-member-0`) reserved for:
1. Primary save/submit button background (enabled state) in the Member editor — each section's "Save" button
2. Active tab underline indicator in the Members & Accounts / Settings tab strip
3. "Currently shared" label text in the calendar picker (existing pattern, unchanged)
4. Toast success icon (`CheckCircle`)
5. "Use detected:" timezone affordance link (existing pattern, unchanged)
Accent is NOT used on:
- Cancel buttons (use `--color-text-secondary`, no background)
- Disabled buttons (use `--color-border` as background)
- The "Add member" trigger button (ghost button: `--color-surface` background, `--color-surface-dim` hover — matches Surface A; not an accent CTA)
- The row chevron/edit icon (use `--color-text-muted`)
Member avatar swatches use `--color-member-{colorIndex}` (index-derived, unchanged from existing MemberRow).
---
## Surface Inventory
This phase introduces or reworks the following surfaces:
### Surface A — Member list (Members tab, existing panel body)
The current inline "Add member" form and the per-row action-button cluster are removed.
The panel body becomes:
```
[Section label: MEMBERS]
[MemberRow] × N ← whole-row tap opens editor sheet; trailing ChevronRight icon
[Divider]
[+ Add member button] ← single trigger at bottom; opens MemberEditorSheet in create mode
```
MemberRow (reworked):
- Layout: avatar swatch (32px circle) → name + status badge → flex spacer → ChevronRight icon (16px, `--color-text-muted`)
- The entire row is the tap/click target (min-height 44px, `cursor: pointer`)
- `role="button"` on the row div, `aria-label="Edit {displayName}"`, `tabIndex={0}`, keyboard: Enter/Space opens editor
- Trailing admin badge: when `member.isAdmin === true`, show a small inline badge — label "Admin", 12px/600, `--color-member-0` text on `--color-surface-dim` background, border-radius 4px, padding 2px 6px — placed between the status badge and the chevron
- Remove: "Rotate" button, "Add credential" button, "Reset password" button — all removed from MemberRow
"Add member" trigger button:
- Full-width or left-aligned ghost button with `+` icon prefix (use lucide `Plus`, 16px)
- Label: "Add member"
- Border: `1px solid var(--color-border)`, border-radius 8px, padding `--space-3` vertical / `--space-4` horizontal
- Background: `--color-surface`; hover/focus: `--color-surface-dim`
- Min-height: 44px
- Opens MemberEditorSheet in create mode
### Surface B — Member editor sheet (MemberEditorSheet, new component)
Single sheet component with `mode` prop: `'edit'` (id present) or `'create'` (no id).
Sheet chrome (identical to CredentialSheet / ResetPasswordSheet):
- Phone: fixed bottom, `border-radius 12px 12px 0 0`, `box-shadow: 0 -4px 24px rgba(0,0,0,0.15)`, `padding: var(--space-6)`, zIndex 301
- Desktop: centered modal, `max-width 480px`, `border-radius 12px`, `box-shadow: 0 8px 32px rgba(0,0,0,0.18)`, `padding: var(--space-6)`, zIndex 301
- Backdrop: `rgba(0,0,0,0.32)` (`--color-overlay`), zIndex 300, click closes
- Escape closes; focus returns to trigger row on close
- `role="dialog"`, `aria-modal="true"`, `aria-label` = sheet heading
- `useFocusTrap` on dialog div
- On open: focus the h2 heading (`tabIndex={-1}`)
- Desktop: `max-height: calc(100dvh - var(--space-8))`, `overflow-y: auto`
Sheet heading:
- `h2`, 18px/600/1.25, `--color-text-primary`
- Edit mode: "Edit member"
- Create mode: "Add member"
Member subtitle (edit mode only):
- 15px/400, `--color-text-secondary`, `margin-bottom: var(--space-6)`
- Displays `member.displayName`
**Edit mode — three collapsible sections rendered as labeled card blocks:**
Section divider style (between sections):
- `border-top: 1px solid var(--color-border-subtle)`, `margin: var(--space-6) 0`
**Section 1 — Profile** (always shown in edit mode):
- Section heading: "Profile", 13px/600/uppercase/letterspacing 0.06em, `--color-text-muted`
- Field: "Display name" — text input, 15px, min-height 44px, border `--color-border`, border-radius 4px, padding `--space-3 --space-4`
- Field: "Admin" — toggle/checkbox row:
- Label: "Admin", 15px/400, `--color-text-primary`
- Sub-label: "Can access admin settings", 13px/400, `--color-text-secondary`
- Control: native `<input type="checkbox">` styled as a toggle (or a `<button role="switch">` with `aria-checked`) — use whichever is cleaner; visually: pill shape, 44px×24px, `--color-member-0` when checked, `--color-border` when unchecked, white thumb circle
- Initial state: `member.isAdmin` from API
- Last-admin guard: if saving would demote the last admin, show inline error below the toggle: "Cannot remove admin — at least one admin must remain." (red, 13px)
- Save button for this section: label "Save", right-aligned, same primary button style (accent background, 13px/600, min-height 44px, border-radius 4px)
- Error state: inline below Save button, `--color-destructive`, 13px
**Section 2 — Set new password** (edit mode only; only shown when `member.hasLocalCredential === true`):
- Section heading: "Set new password", same style as above
- Helper: "Leave blank to keep the current password." — 13px/400, `--color-text-secondary`, below heading
- Field: "New password" — `type="password"`, `autoComplete="new-password"`, min-height 44px
- Field: "Confirm new password" — same
- Password never pre-filled. `autoComplete="new-password"` on both fields.
- Save button: label "Set password", right-aligned, primary style
- Validation: if fields differ, inline error "Passwords do not match." If new password < 8 chars, "Password must be at least 8 characters."
- In-flight: show `Loader2` 14px inline in the button (existing pattern)
**Section 3 — App password** (edit mode only):
- Section heading: "App password", same style
- Helper text: "Fastmail app password scoped to Calendars & Contacts (CalDAV)."
- Include inline link: "Get an app password" → `https://app.fastmail.com/settings/security/devicetokens`, `target="_blank" rel="noopener noreferrer"`, `--color-member-0` color, underline
- Field: "Fastmail email" — `type="email"`, `autoComplete="email"`, min-height 44px
- In edit mode: if the stored `fastmailEmail` is available from the credential, prefill this field with the stored email (read-convenience; the user can correct it). The app password field is never prefilled.
- Field: "App password" — `type="password"`, `autoComplete="new-password"`, never prefilled
- "Validating against CalDAV…" — shown during in-flight mutation, using `Loader2` 16px + text (matches CredentialSheet pattern)
- CalDAV failure error: "Invalid password — CalDAV validation failed. Check the scope is 'Calendars & Contacts (CalDAV)' and try again."
- Save button: label "Save app password", right-aligned, primary style
**Create mode — single unified form (no section dividers):**
- Fields: Display name, Username, Initial password, Confirm password
- Matches today's inline Add-member form fields exactly
- Save button: label "Add member", right-aligned, primary style
- Client validation: passwords match + ≥ 8 chars; username conflict 409 → "That username is already in use."
---
## Copywriting Contract
| Element | Copy |
|---------|------|
| Editor sheet heading (edit) | "Edit member" |
| Editor sheet heading (create) | "Add member" |
| Profile section heading | "Profile" |
| Password section heading | "Set new password" |
| Password section helper | "Leave blank to keep the current password." |
| App password section heading | "App password" |
| App password helper | "Fastmail app password scoped to Calendars & Contacts (CalDAV)." |
| App password link text | "Get an app password" |
| Profile Save CTA | "Save" |
| Password Save CTA | "Set password" |
| App password Save CTA | "Save app password" |
| Create mode Save CTA | "Add member" |
| Add member trigger (panel) | "Add member" |
| Cancel (all sheets) | "Cancel" |
| Admin badge label | "Admin" |
| Admin toggle label | "Admin" |
| Admin toggle sub-label | "Can access admin settings" |
| Credential status — set | "Credential set" |
| Credential status — missing | "No credential" |
| Validating state | "Validating against CalDAV…" |
| CalDAV failure error | "Invalid password — CalDAV validation failed. Check the scope is 'Calendars & Contacts (CalDAV)' and try again." |
| Last-admin guard error | "Cannot remove admin — at least one admin must remain." |
| Password mismatch error | "Passwords do not match." |
| Password too short error | "Password must be at least 8 characters." |
| Username conflict error | "That username is already in use. Choose a different one." |
| Generic save error | "Something went wrong. Please try again." |
| Profile saved toast | "Profile saved." |
| Password reset toast | "Password updated." |
| App password saved toast | "App password saved." |
| Member added toast | "Member added." |
| Loading members state | "Loading members…" |
| Members load error | "Could not load members." |
| Empty members state heading | "No members yet" |
| Empty members state body | "Add a member to get started." |
**Retired copy (must not appear anywhere after this phase):**
- "Rotate" — remove from all button labels, headings, and aria-labels
- "Add credential" — remove from MemberRow button
- "Reset password" — remove as standalone button; replaced by "Set new password" section inside editor
---
## Interaction Contracts
### Row tap → editor open
- Tap/click anywhere on a MemberRow opens MemberEditorSheet in edit mode for that member
- Keyboard: Enter or Space on the row (when `role="button"`) opens the sheet
- Focus returns to the tapped row on sheet close
### "Add member" trigger → create sheet
- Tap/click the "Add member" button opens MemberEditorSheet in create mode
- On success: sheet closes, member list refreshes, toast "Member added." fires
- Focus returns to the "Add member" button on cancel/close
### Per-section saves (edit mode)
- Each section (Profile / Set new password / App password) has its own Save button
- Save is independent — one section save does not affect other sections
- Disabled state: button uses `--color-border` background, `cursor: default`
- In-flight: Loader2 spinner inline in button label (existing pattern), button disabled
- Success: toast fires, sheet stays open (user may save another section)
- Error: inline error below the section's save button
### Admin toggle
- Toggle flips `is_admin` optimistically on the client
- On save (Profile section Save): sends to `PATCH /api/admin/members/:id`
- If server returns 409/422 (last-admin guard): show inline error "Cannot remove admin — at least one admin must remain." Revert toggle to previous state.
### Sheet dismissal
- Backdrop click: closes sheet, discards unsaved changes, focus returns to trigger
- Escape key: same as backdrop click
- Cancel button: same as backdrop click
- No confirmation dialog for unsaved changes (forms are short; data loss is minimal)
### Toast positioning
- Phone: `bottom: calc(var(--bottom-chrome-h) + var(--space-4))` (clears BottomTabBar)
- Desktop: `bottom: var(--space-6)`
- Auto-dismiss: 3000ms (existing pattern, unchanged)
---
## Accessibility Contract
- MemberRow: `role="button"`, `aria-label="Edit {displayName}"`, `tabIndex={0}`, keyboard Enter/Space
- MemberEditorSheet: `role="dialog"`, `aria-modal="true"`, `aria-label` matches sheet h2
- Focus management: on open → h2 (`tabIndex={-1}`); on close → trigger element
- `useFocusTrap` traps Tab/Shift+Tab within dialog
- Admin toggle: `role="switch"`, `aria-checked={isAdmin}`, `aria-label="Admin"`
- Inline errors: `aria-describedby` on the relevant input(s) pointing to the error id
- Password fields: `autoComplete="new-password"`, never prefilled
- Fastmail email field (app password section): `autoComplete="email"`
- Toast: `role="status"`, `aria-live="polite"`, `aria-atomic="true"`
- 44px minimum touch target on all interactive elements
- Section headings inside sheet: use `<div>` with sectionLabelStyle (matches AdminPage pattern) — not `<h3>`, to avoid heading hierarchy issues with the existing `<h2>` sheet title
---
## Registry Safety
| Registry | Blocks Used | Safety Gate |
|----------|-------------|-------------|
| shadcn official | none | not applicable — shadcn not initialized |
| Third-party | none | not applicable |
No third-party component registries. All components are hand-authored using project CSS tokens.
Icon additions from `lucide-react` (already a project dependency): `ChevronRight`, `Plus`.
---
## Checker Sign-Off
- [ ] Dimension 1 Copywriting: PASS
- [ ] Dimension 2 Visuals: PASS
- [ ] Dimension 3 Color: PASS
- [ ] Dimension 4 Typography: PASS
- [ ] Dimension 5 Spacing: PASS
- [ ] Dimension 6 Registry Safety: PASS
**Approval:** pending
@@ -0,0 +1,135 @@
---
phase: 20-admin-member-editor-form-declutter
verified: 2026-06-18T00:00:00Z
status: passed
score: 9/9 must-haves verified
behavior_unverified: 0
overrides_applied: 0
re_verification: false
---
# Phase 20: Admin Member Editor & Form Declutter Verification Report
**Phase Goal:** Replace the per-member-row action buttons (Rotate/Add credential + Reset password) with a SINGLE edit affordance — tapping a member opens a member-detail editor where an admin modifies all of that member's details in one place. Also collapse the "Add member" section behind a single trigger.
**Verified:** 2026-06-18
**Status:** passed
**Re-verification:** No — initial verification
---
## Goal Achievement
### Observable Truths
| # | Truth | Status | Evidence |
|---|-------|--------|---------|
| 1 | An admin can update a member's display name and admin flag through one route behind requireAdmin | VERIFIED | `adminRouter.patch('/members/:id', zValidator(...), handler)` at admin.ts:230; `adminRouter.use('*', requireAdmin)` at admin.ts:48 — no second guard in the PATCH handler |
| 2 | Demoting the only remaining admin is rejected with a 409 and the member stays admin | VERIFIED | admin.ts:251-258: counts admins with `sql\`COUNT(*)\`` where `users.isAdmin` is true; returns `c.json({ error: 'Cannot remove the last admin' }, 409)` when count <= 1. Test C in admin.test.ts asserts 409 + subsequent GET confirms isAdmin still true |
| 3 | Self-demotion succeeds while another admin exists | VERIFIED | Same guard only fires when `count <= 1`; Test D seeds two admins and asserts 200 + one admin remaining |
| 4 | GET /api/admin/members returns each member's isAdmin so the editor toggle has correct initial state | VERIFIED | admin.ts:109: `isAdmin: users.isAdmin` in select; admin.ts:121: `isAdmin: row.isAdmin` in mapped object. Test H asserts boolean `isAdmin` on each member object |
| 5 | The PWA can call the member-profile update route and receive a typed result; 409/422 surfaces as a last-admin sentinel | VERIFIED | client.ts:249-264: `updateMemberProfile` issues `PATCH /api/admin/members/${memberId}`, maps 409/422 to `throw new Error('last-admin')`, maps 401/opaqueredirect to `SessionExpiredError` |
| 6 | AdminMember carries isAdmin so the editor toggle can show the correct initial state | VERIFIED | client.ts:597: `isAdmin: boolean;` present in `AdminMember` interface with Phase 20 comment |
| 7 | Tapping a member row opens one editor sheet for all of that member's details | VERIFIED | AdminPage.tsx:929-933: `role="button"`, `tabIndex={0}`, `onClick={handleActivate}`, `onKeyDown` Enter/Space handler — full tap target. MemberEditorSheet imported and mounted at AdminPage.tsx:891. Per-section saves (Profile/Set new password/App password) all wired to live endpoints |
| 8 | Add member is collapsed behind a single trigger that opens the same sheet in create mode | VERIFIED | AdminPage.tsx:408-440: single ghost button with `Plus` icon, `1px solid var(--color-border)`, opens `MemberEditorSheet` in `'create'` mode. No inline always-open add-form present |
| 9 | The terms Rotate, Add credential, and the standalone Reset password button no longer appear | VERIFIED | `grep -RnE '"Rotate"\|>Rotate<\|Add credential\|Reset password' apps/pwa/src/routes/AdminPage.tsx apps/pwa/src/components/MemberEditorSheet.tsx` — zero matches. `ResetPasswordSheet` absent from AdminPage.tsx |
**Score:** 9/9 truths verified (0 present, behavior-unverified)
---
### Required Artifacts
| Artifact | Expected | Status | Details |
|----------|----------|--------|---------|
| `apps/api/src/routes/admin.ts` | PATCH /api/admin/members/:id + isAdmin in GET /members select | VERIFIED | Route at line 230; `isAdmin: users.isAdmin` in select at line 109; last-admin guard at lines 251-258; `updateMemberSchema` Zod schema at line 225 |
| `apps/api/tests/routes/admin.test.ts` | Tests A-H for PATCH route + isAdmin in GET | VERIFIED | Tests A-H present (lines 1079-1260+); test C asserts 409 last-admin guard; test H asserts isAdmin boolean per member |
| `apps/pwa/src/api/client.ts` | `updateMemberProfile` fetcher + `AdminMember.isAdmin` | VERIFIED | `updateMemberProfile` at line 249 (PATCH verb, correct URL); `isAdmin: boolean` on `AdminMember` at line 597; `last-admin` sentinel at line 262 |
| `apps/pwa/src/components/MemberEditorSheet.tsx` | Single editor, edit+create modes, per-section saves, retired Rotate copy | VERIFIED | 899 lines; `mode: 'edit' | 'create'` prop; three edit-mode sections; `role="switch"` admin toggle; last-admin inline error; create mode with four fields; no "Rotate"/"Add credential"/"Reset password" literals |
| `apps/pwa/src/routes/AdminPage.tsx` | Tappable MemberRow + ChevronRight + single Add-member trigger; no per-row action cluster; no ResetPasswordSheet | VERIFIED | MemberRow has `role="button"`, `aria-label="Edit {displayName}"`, `tabIndex={0}`, Enter/Space handler; ChevronRight at line 1039; Admin badge at line 1022-1036; Plus ghost trigger at line 437; MemberEditorSheet mounted at line 891; zero ResetPasswordSheet references |
---
### Key Link Verification
| From | To | Via | Status | Details |
|------|----|-----|--------|---------|
| `AdminPage.tsx MemberRow` | `MemberEditorSheet.tsx` | `onEdit(rowEl) → openEditorForMember(member, rowEl) → setEditorOpen(true), setEditorMode('edit')` | WIRED | AdminPage.tsx:241-246 and 398-404; MemberEditorSheet imported at line 38 |
| `AdminPage.tsx "Add member" trigger` | `MemberEditorSheet.tsx create mode` | `openEditorForCreate() → setEditorMode('create'), setEditorOpen(true)` | WIRED | AdminPage.tsx:249-254 and 413 |
| `MemberEditorSheet.tsx Profile save` | `client.ts updateMemberProfile` | `updateMemberProfile(member.id, { displayName, isAdmin })` | WIRED | MemberEditorSheet.tsx:262; client.ts:249 |
| `client.ts updateMemberProfile` | `admin.ts PATCH /members/:id` | `fetch PATCH /api/admin/members/${memberId}` | WIRED | client.ts:253; admin.ts:230 |
| `admin.ts PATCH handler` | `db/schema.ts users.isAdmin` | `db.update(users).set(updates).where(eq(users.id, targetId))` | WIRED | admin.ts:267; COUNT query at line 253-256 |
| `MemberEditorSheet.tsx 409 onError` | `setProfileError('Cannot remove admin...')` | `msg === 'last-admin'` sentinel branch + toggle revert | WIRED | MemberEditorSheet.tsx:274-278 |
---
### Data-Flow Trace (Level 4)
| Artifact | Data Variable | Source | Produces Real Data | Status |
|----------|---------------|--------|--------------------|--------|
| `MemberEditorSheet.tsx` | `member` (prop) | `membersQuery.data?.members` in AdminPage → `fetchAdminMembers()``GET /api/admin/members` → DB select of users + joins | DB query returns live rows including `isAdmin` | FLOWING |
| `AdminPage.tsx MemberRow` | `member.isAdmin` | Same path above; `isAdmin: row.isAdmin` mapped from `users.isAdmin` column | Live boolean from DB | FLOWING |
| `MemberEditorSheet.tsx isAdmin toggle` | `useState(member?.isAdmin ?? false)` | Seeded from `member.isAdmin` on open and on member change via `useEffect` | Reflects live DB value on sheet open | FLOWING |
---
### Behavioral Spot-Checks
| Behavior | Evidence | Status |
|----------|----------|--------|
| Last-admin guard returns 409 and member stays admin | Test C in admin.test.ts (line 1128): asserts 409 response + subsequent GET confirms `isAdmin: true`. SUMMARY.md confirms 44 tests green | PASS |
| GET /members returns boolean isAdmin per member | Test H in admin.test.ts (line 1235): asserts boolean `isAdmin` on each member object | PASS |
| `updateMemberProfile` maps 409 to 'last-admin' sentinel | client.ts:262: `if (res.status === 409 || res.status === 422) throw new Error('last-admin')` — deterministic static analysis | PASS |
| Retired copy absent | grep on all four modified files — zero matches for "Rotate", "Add credential", "Reset password" | PASS |
| Playwright-cli verified UI contract | Screenshots in `screenshots/`: admin-members-tab-decluttered.png, member-editor-edit-mode.png, member-editor-create-mode.png, profile-save-toast.png — executor verified no retired buttons, row tap opens "Edit member", Add-member trigger opens "Add member", Profile save fires toast and sheet stays open | PASS |
---
### Probe Execution
No phase-specific probes declared. The orchestrator has confirmed 461/461 API tests green (includes the 8 new PATCH /members/:id tests) and PWA production build passing.
---
### Requirements Coverage
Phase 20 PLANs declare `requirements: []` in all three frontmatter blocks. The REQUIREMENTS.md traceability table maps ADMIN-01, ADMIN-02, ADMIN-03 to Phase 10 — Phase 20 is a UI/UX improvement layer over those already-shipped requirements and does not introduce new REQ-IDs. No orphaned requirements for this phase.
---
### Anti-Patterns Found
| File | Pattern | Severity | Impact |
|------|---------|----------|--------|
| None | — | — | — |
Zero TBD / FIXME / XXX markers in any of the four modified files. No stub patterns (empty returns, placeholder renders, hardcoded empty arrays). The `fastmailEmail` field starting blank in edit mode is intentional and documented via a code comment (the API does not return it), not a stub.
---
### Human Verification Required
None. All behavioral checks were either:
- Covered by the 8 new integration tests (last-admin guard, isAdmin read, auth boundary, validation, 404)
- Verified by playwright-cli observation (four screenshots captured by executor)
- Verifiable statically (retired copy grep, artifact wiring, sentinel mapping)
No iOS-Safari-standalone or other device-only checks are in scope for this phase.
---
### Gaps Summary
No gaps. All 9 observable truths verified at all four levels (exists, substantive, wired, data flowing). The 3 CONTEXT decisions (D-01..D-07) are honored:
- D-01: Editor exposes exactly the four fields (displayName, password, app password, isAdmin)
- D-02: PATCH /members/:id in existing requireAdmin boundary; `AdminMember.isAdmin` surfaces the initial state
- D-03: Last-admin guard returns 409; client shows inline error and reverts toggle
- D-04: Whole-row `role="button"` with ChevronRight; per-row action cluster removed
- D-05: Per-section saves; sheet stays open after edit saves; closes only on create success
- D-06: "Rotate" / "Add credential" / "Reset password" retired from all files
- D-07: Single `MemberEditorSheet` component with `mode: 'edit' | 'create'` prop; inline add-form collapsed behind ghost trigger
---
_Verified: 2026-06-18_
_Verifier: Claude (gsd-verifier)_
@@ -0,0 +1,83 @@
---
quick_id: 260618-smr
slug: remove-unused-redis-service-and-referenc
description: Remove unused Redis service and references
type: quick
created: 2026-06-19
files_modified:
- docker-compose.yml
- docker-compose.dev.yml
- CLAUDE.md
- README.md
- docs/ARCHITECTURE.md
- docs/CONFIGURATION.md
- docs/deployment.md
- docs/DEVELOPMENT.md
- docs/GETTING-STARTED.md
- docs/TESTING.md
- apps/pwa/e2e/README.md
- apps/pwa/playwright.config.ts
---
# Quick Task 260618-smr: Remove unused Redis service and references
## Why
Redis is confirmed **unused at runtime**: no `ioredis`/redis client import, no `REDIS_*`
env vars read in code, and `ioredis` is not a dependency in any `package.json`. It exists
only as a compose service + documentation references that imply it is part of the stack or
"reserved for future pub/sub". Decision: drop the container and all references (keep the
in-memory-vs-Redis design-rationale comments — see Constraint below).
## Tasks
### Task 1 — Remove the redis service from compose
- `docker-compose.yml`: delete the `redis:` service block (`image: redis:7-alpine` + its
comment) so only `api`, `mariadb`, and the `volumes:` block remain. The api `depends_on`
lists only `mariadb` — leave it untouched.
- `docker-compose.dev.yml`: delete the `redis:` ports override block (`ports: - '6379:6379'`).
- verify: `grep -ri redis docker-compose.yml docker-compose.dev.yml` returns nothing.
- done: neither compose file references redis; `docker compose config` still parses.
### Task 2 — Clean documentation references
Remove/adjust every Redis mention so no doc implies Redis is part of the stack:
- `CLAUDE.md`: delete the Constraints line "Redis available (optional, …)"; delete the
`ioredis` row from the Supporting Libraries table; delete the "Redis is present in the
stack but not yet used…" sentence from the architecture paragraph (keep the rest of the
sentence about SSE/EventEmitter); change the compose-tree comment `(api + mariadb + redis)`
`(api + mariadb)`.
- `README.md`: drop "Redis" from the prerequisites line; drop `redis` from the
`docker compose … up mariadb redis` command; drop ", Redis 7" from the compose-file
description; change "expose DB/Redis ports" → "expose DB ports"; change the Live-sync row
"Server-Sent Events + Redis 7 pub/sub" → "Server-Sent Events (in-process EventEmitter)".
- `docs/ARCHITECTURE.md`: delete the `Redis` table row.
- `docs/CONFIGURATION.md`: drop "and Redis on `localhost:6379`".
- `docs/deployment.md`: delete the `redis` services-table row.
- `docs/DEVELOPMENT.md`: remove the four Redis mentions (prereq bullet, "### 2. Start the dev
database and Redis" heading → "Start the dev database", the two `up mariadb redis` commands
`up mariadb`, the "Exposes Redis on `localhost:6379`" bullet, and the
"(API in Docker + MariaDB + Redis…)" comment → "(API in Docker + MariaDB…)").
- `docs/GETTING-STARTED.md`: prereq row "Used to run MariaDB and Redis locally" → "MariaDB";
`up -d mariadb redis``up -d mariadb`; "and Redis (`localhost:6379`)" removed from prose.
- `docs/TESTING.md`: "The API, MariaDB, and Redis must already be running" → "The API and
MariaDB must already be running".
- `apps/pwa/e2e/README.md`: delete the "- Redis on `:6379`" bullet.
- `apps/pwa/playwright.config.ts`: update the two comments listing
"API+MariaDB+Redis are compose-managed" → "API+MariaDB are compose-managed".
- verify: `grep -rniE redis CLAUDE.md README.md docs apps/pwa/e2e apps/pwa/playwright.config.ts`
returns nothing (case-insensitive, excluding the word "credential").
- done: no doc/config implies Redis is in the stack.
## Constraint — KEEP these (do NOT touch)
The in-memory-vs-Redis **design-rationale** comments document why in-memory is used instead
of Redis (decisions D-12/D-18) and must remain:
- `apps/api/src/lib/listEmitter.ts`
- `apps/api/src/broker/reminderScheduler.ts`
- `apps/api/src/auth/linkNonceStore.ts`
- `apps/api/src/routes/localAuth.ts`
## must_haves
- truth: "No redis service exists in either compose file"
- truth: "No documentation or e2e config references Redis as part of the stack"
- truth: "The D-12/D-18 in-memory-vs-Redis rationale comments in the 4 source files are intact"
- artifacts: [docker-compose.yml, docker-compose.dev.yml, CLAUDE.md, README.md, docs/*, apps/pwa/e2e/README.md, apps/pwa/playwright.config.ts]
@@ -0,0 +1,65 @@
---
quick_id: 260618-smr
slug: remove-unused-redis-service-and-referenc
description: Remove unused Redis service and references
status: complete
completed: 2026-06-18
duration: ~10m
tasks_completed: 2
tasks_total: 2
files_modified:
- docker-compose.yml
- docker-compose.dev.yml
- CLAUDE.md
- README.md
- docs/ARCHITECTURE.md
- docs/CONFIGURATION.md
- docs/deployment.md
- docs/DEVELOPMENT.md
- docs/GETTING-STARTED.md
- docs/TESTING.md
- apps/pwa/e2e/README.md
- apps/pwa/playwright.config.ts
decisions:
- Redis dropped entirely from compose and all docs; in-memory EventEmitter is the live-sync mechanism (D-12/D-18 rationale comments in source preserved)
---
# Quick Task 260618-smr: Remove unused Redis service and references — Summary
## One-liner
Dropped the unused `redis:7-alpine` compose service and scrubbed every documentation/config reference implying Redis is part of the stack, while preserving the D-12/D-18 in-memory-vs-Redis design-rationale comments in source.
## Tasks Completed
| Task | Description | Commit | Files |
| ---- | ------------------------------------ | ------- | ---------------------------------------------------------- |
| 1 | Remove redis service from compose | 269e474 | docker-compose.yml, docker-compose.dev.yml |
| 2 | Clean documentation references | 8255be6 | CLAUDE.md, README.md, docs/*, apps/pwa/e2e/README.md, apps/pwa/playwright.config.ts |
## Verification Results
- `grep -ri redis docker-compose.yml docker-compose.dev.yml` → no output (clean)
- `docker compose -f docker-compose.yml -f docker-compose.dev.yml config` → PARSE OK
- `grep -rniE redis CLAUDE.md README.md docs apps/pwa/e2e apps/pwa/playwright.config.ts` → no output (clean)
- `pnpm format:check` → all matched files use Prettier code style
- Protected source files (listEmitter.ts, reminderScheduler.ts, linkNonceStore.ts, localAuth.ts) → untouched (git diff confirms no changes)
## Docker Compose Config
`docker compose -f docker-compose.yml -f docker-compose.dev.yml config` parsed successfully with no Redis service — confirmed available on this host.
## Deviations from Plan
None — plan executed exactly as written.
## Known Stubs
None.
## Self-Check: PASSED
- Commits 269e474 and 8255be6 exist in git log
- All 12 modified files confirmed updated
- Grep verifies zero Redis references in target files
- Protected source files confirmed untouched
@@ -0,0 +1,99 @@
---
quick_id: 260618-tg2
slug: persistent-ci-dependency-caches-pnpm-sto
description: Persistent CI dependency caches (pnpm store + Playwright browsers)
type: quick
created: 2026-06-19
files_modified:
- .gitea/workflows/ci.yml
- docs/DEVELOPMENT.md
- apps/api/Dockerfile
- .gitea/workflows/publish.yml
---
# Quick Task 260618-tg2: Persistent CI dependency caches (pnpm store + Playwright)
## Why
The Gitea runner re-downloads all deps every run: 4 jobs each run `pnpm install --frozen-lockfile`
cold (lines 54/110/197/478), and the harness job re-downloads Playwright browser binaries every
run (line 275). The runner is long-lived Docker-on-Unraid, so persisting these via host bind-mounts
(`/pnpm-store`, `/ms-playwright`, wired in the act_runner `config.yaml` `container.options` — a
separate manual host change) eliminates the repeat downloads. This avoids `actions/cache@v4`, which
the Phase-8 runner probe found times out on this runner (D-PROBE-04).
**Scope this task: the two CI caches only.** Verdaccio (registry mirror) and the Dockerfile
BuildKit cache mount are explicitly OUT of scope for now.
## Tasks
### Task 1 — pnpm store: point all CI installs at the persistent store
In `.gitea/workflows/ci.yml`, change each of the four install steps:
```
run: pnpm install --frozen-lockfile
```
```
run: pnpm install --frozen-lockfile --store-dir /pnpm-store --prefer-offline
```
Lines 54 (fast-checks), 110 (api), 197 (harness), 478 (security). `--store-dir` and
`--prefer-offline` are valid pnpm 11.5.1 install flags (verify with `pnpm install --help`).
Do NOT add `store-dir` to a repo `.npmrc` — local dev has no `/pnpm-store`.
Update the stale comment near line 51 (the "no cache backend / ~30s acceptable" note) to reflect
that installs now use the persistent host-mounted store.
- verify: `grep -c -- '--store-dir /pnpm-store --prefer-offline' .gitea/workflows/ci.yml` → 4
- done: all four installs use the persistent store; YAML still valid.
### Task 2 — Playwright: persist browser binaries on the harness job
Add a job-level `env:` to the `harness:` job so every step (browser install + test run) resolves
the same persistent path:
```yaml
harness:
runs-on: ubuntu-latest
env:
PLAYWRIGHT_BROWSERS_PATH: /ms-playwright
```
(If the harness job already has a job-level `env:` map, add the key to it rather than duplicating.)
Leave `npx playwright install --with-deps webkit chromium` (line 275) as-is — the binary download
is now cached by version; the `--with-deps` apt step can't persist (add a one-line comment noting
"baking a runner image with browsers preinstalled would also drop the --with-deps apt step" as a
future optimization).
- verify: `PLAYWRIGHT_BROWSERS_PATH: /ms-playwright` present under the harness job; the test-run
step (PLAYWRIGHT_BASE_URL ~line 340) inherits it.
- done: Playwright browsers persist across runs.
### Task 3 — Document the host-mount dependency
Add a short subsection to `docs/DEVELOPMENT.md` (CI/runner area) noting:
- CI now uses persistent caches at container paths `/pnpm-store` and `/ms-playwright`.
- These require the act_runner `config.yaml` `container.options` to bind-mount host dirs to those
paths (host change, not in this repo).
- Without the mounts CI still works — it just falls back to uncached (writes to an ephemeral dir).
- done: the host-side requirement is discoverable from the repo.
## Constraints
- Must pass local gates before each commit: `format:check` (prettier), eslint, YAML validity
(yq or actionlint if available), typecheck (no TS touched, but run if cheap).
- Job names and the required-check contexts (CI / fast-checks, CI / api, CI / harness,
CI / security, CI / gate) MUST stay identical so branch protection still matches. Do not rename
jobs or restructure the job graph.
- Atomic commits (Task 1, Task 2, Task 3 may be one or separate commits — keep changes coherent).
### Task 4 — Dockerfile BuildKit pnpm-store cache (added mid-task per user request)
`apps/api/Dockerfile`: add `# syntax=docker/dockerfile:1` (line 1) and a BuildKit cache mount to
all three pnpm install stages (builder, pwa-builder, production):
`RUN --mount=type=cache,target=/pnpm-store,id=pnpm-store,sharing=locked pnpm install ... --store-dir /pnpm-store`.
`sharing=locked` because builder + pwa-builder run in parallel and would otherwise race the store.
`.gitea/workflows/publish.yml`: set `DOCKER_BUILDKIT: '1'` on the "Build production image" step so
the legacy builder can't break on the `--mount` syntax (BuildKit is default on Docker 23+; explicit
for safety).
- done: image build reuses a persistent BuildKit pnpm-store cache across builds.
## OUT OF SCOPE (do not touch)
- Verdaccio / any `.npmrc` registry change (deferred — user will set up later).
## must_haves
- truth: "All four ci.yml pnpm installs use --store-dir /pnpm-store --prefer-offline"
- truth: "The harness job sets PLAYWRIGHT_BROWSERS_PATH=/ms-playwright"
- truth: "ci.yml remains valid YAML with unchanged job names / required-check contexts"
- truth: "docs note the act_runner config.yaml host-mount requirement"
- artifacts: [.gitea/workflows/ci.yml, docs/DEVELOPMENT.md]
@@ -0,0 +1,121 @@
---
quick_id: 260618-tg2
slug: persistent-ci-dependency-caches-pnpm-sto
phase: "20"
plan: tg2
status: complete
completed: 2026-06-18
tags: [ci, caching, pnpm, playwright]
key-files:
modified:
- .gitea/workflows/ci.yml
- docs/DEVELOPMENT.md
- apps/api/Dockerfile
- .gitea/workflows/publish.yml
decisions:
- All four CI pnpm installs now target /pnpm-store via --store-dir --prefer-offline flags
- PLAYWRIGHT_BROWSERS_PATH added at harness job level (not per-step) so both install and run steps share the same path
- D-PROBE-04 comments updated to reflect the new store strategy rather than "no cache"
---
# Quick Task 260618-tg2: Persistent CI dependency caches (pnpm store + Playwright) Summary
**One-liner:** Point all four CI pnpm installs at `/pnpm-store` and harness Playwright at `/ms-playwright` via host-mounted directories on the act_runner.
## What Was Done
### Task 1 — pnpm store (ci.yml, 4 install lines)
Changed all four `pnpm install --frozen-lockfile` lines to
`pnpm install --frozen-lockfile --store-dir /pnpm-store --prefer-offline`:
- Line ~54: `fast-checks` job
- Line ~110: `api` job
- Line ~197: `harness` job
- Line ~485: `security` job (conditional)
Both flags confirmed valid against `pnpm 11.5.1 install --help` before use.
Updated the stale D-PROBE-04 comment in each job from "no cache backend / ~30s acceptable"
to reflect that installs now target the host-mounted store.
### Task 2 — Playwright browsers (harness job env)
Added `PLAYWRIGHT_BROWSERS_PATH: /ms-playwright` to the existing job-level `env:` block on
the `harness:` job (alongside the DB_* creds). This means both the `Install Playwright browsers`
step and the `Run harness` step inherit the same path, so cached binaries are found at install
time and used at test time.
Added a comment on the Playwright install step noting the future optimization: baking a runner
image with browsers preinstalled would also eliminate the `--with-deps` apt step.
### Task 3 — Host-mount documentation (docs/DEVELOPMENT.md)
Added a "CI dependency caches" subsection under the CI Pipeline Overview. Documents:
- The two container paths (`/pnpm-store`, `/ms-playwright`) with a reference table
- That the act\_runner `config.yaml` `container.options` bind-mount is a **host-side** change
- That CI still works without the mounts (ephemeral fallback — just no caching)
## Verification
```
grep -c -- '--store-dir /pnpm-store --prefer-offline' .gitea/workflows/ci.yml
→ 4
grep -n 'PLAYWRIGHT_BROWSERS_PATH' .gitea/workflows/ci.yml
→ 187: PLAYWRIGHT_BROWSERS_PATH: /ms-playwright (job-level env)
→ 278: # PLAYWRIGHT_BROWSERS_PATH=/ms-playwright... (comment)
python3 -c 'import yaml,sys; yaml.safe_load(open(".gitea/workflows/ci.yml")); print("YAML valid")'
→ YAML valid
pnpm format:check → All matched files use Prettier code style!
pnpm md:lint → Summary: 0 error(s)
```
### Task 4 — Dockerfile BuildKit pnpm-store cache (added mid-task by user request)
Added by the orchestrator after the initial 3 tasks, when the user asked to include the Dockerfile:
- `apps/api/Dockerfile`: added `# syntax=docker/dockerfile:1` (line 1) and a BuildKit cache mount
(`RUN --mount=type=cache,target=/pnpm-store,id=pnpm-store,sharing=locked ... --store-dir /pnpm-store`)
to all three pnpm install stages (builder, pwa-builder, production). `sharing=locked` because
builder + pwa-builder run in parallel and would otherwise race the shared store.
- `.gitea/workflows/publish.yml`: set `DOCKER_BUILDKIT: '1'` on the "Build production image" step —
the publish path uses plain `docker build` (not buildx), and the legacy builder would fail on the
`--mount` syntax. BuildKit is default on Docker 23+; set explicitly for safety.
- Verified: `format:check` clean (Dockerfile is outside prettier's scope), publish.yml valid YAML.
## Commits
| Hash | Message |
| --- | --- |
| `80b2038` | chore(20): persistent CI caches — pnpm store + Playwright browsers |
| `f83d423` | docs(20): document CI persistent cache host-mount dependency |
| `6e93e24` | chore(260618-tg2): BuildKit pnpm-store cache mount in Dockerfile build |
## Deviations from Plan
Dockerfile cache (Task 4) was added mid-task at the user's request after the initial 3-task plan
(it had been explicitly deferred/out-of-scope). The publish workflow's `DOCKER_BUILDKIT=1` was a
required companion change so the `--mount` syntax doesn't break the legacy builder.
- `--store-dir` flag form matches plan exactly (plan said verify against pnpm; verified: valid)
- harness job already had a job-level `env:` map; `PLAYWRIGHT_BROWSERS_PATH` was added to it as instructed
- stale comment text updated as instructed
## Known Stubs
None.
## Threat Flags
None — YAML-only and doc-only changes; no new network endpoints, auth paths, or trust boundaries introduced.
## Self-Check: PASSED
- `.gitea/workflows/ci.yml` — modified and committed at 80b2038
- `docs/DEVELOPMENT.md` — modified and committed at f83d423
- YAML validity confirmed by python3 yaml.safe_load
- 4 install lines confirmed by grep -c
- PLAYWRIGHT_BROWSERS_PATH confirmed at job-level env line 187
+12 -14
View File
@@ -11,7 +11,6 @@ FamilySync is a self-hosted, Dockerized family organization hub for a two-person
### Constraints
- **Tech stack**: MariaDB for the database — PostgreSQL is not available in the stack
- **Tech stack**: Redis available (optional, for live list sync / push)
- **Infrastructure**: Unraid host running Docker + Docker Compose
- **Auth**: Authelia (already deployed) + Local Auth — OIDC/OAuth2 for the custom app; all members authenticate through it
- **Calendar backend**: Fastmail (paid, existing) is the single source for all calendars via JMAP/CalDAV
@@ -50,7 +49,6 @@ FamilySync is a self-hosted, Dockerized family organization hub for a two-person
| web-push | 3.6.7 | Server-side VAPID push | Generate VAPID keys, sign and dispatch push messages to browser push services (APNs for iOS, FCM for Android) |
| @hono/oidc-auth | 1.8.3 | OIDC session middleware for Hono | Storage-less JWT session cookies; authorization-code + PKCE flow; works with any RFC-compliant OIDC provider including Authelia |
| openid-client | 6.8.4 | Low-level OIDC primitives | If `@hono/oidc-auth` proves insufficient (e.g., custom token introspection), use this as the lower-level escape hatch |
| ioredis | 5.11.0 | Redis client | Pub/sub for broadcasting list-change events to SSE connections across Node processes |
| zod | 3.24.x | Schema validation | Validate API request bodies and CalDAV event payloads before writing back to Fastmail |
| @hono/zod-validator | 0.8.0 | Hono middleware for Zod | Validate request body/query in route handlers with Zod schemas |
| @tanstack/react-query | 5.101.0 | Server state + caching | Manages calendar and list data fetching, background refetch, stale-while-revalidate; pairs with SSE for live list updates |
@@ -199,7 +197,7 @@ The backend handles two auth paths: local username/password (scrypt + HS256 JWT
Calendar data lives exclusively in Fastmail CalDAV. The broker layer (`apps/api/src/broker/`) uses `tsdav` for PROPFIND/REPORT/PUT/DELETE, `ical.js` for VCALENDAR parsing, and `rrule` for server-side recurrence expansion. Writes are enqueued in a `calendarOutbox` table and drained asynchronously every 15 seconds; a ctag-based poller re-syncs calendars every 5 minutes.
Lists are persisted in MariaDB. Live list updates flow over SSE (`text/event-stream`) via an in-process Node.js `EventEmitter`; a 30-second polling fallback is always active. Push notifications (reminders + calendar change alerts) are dispatched via `web-push` (VAPID) to APNs/FCM. Redis is present in the stack but not yet used at runtime (reserved for future multi-process pub/sub).
Lists are persisted in MariaDB. Live list updates flow over SSE (`text/event-stream`) via an in-process Node.js `EventEmitter`; a 30-second polling fallback is always active. Push notifications (reminders + calendar change alerts) are dispatched via `web-push` (VAPID) to APNs/FCM.
The PWA uses TanStack Query for all server state (events, lists, user, sync status, auth mode) and Zustand for UI-only state (selected date, open panels, active tab).
@@ -221,7 +219,7 @@ familysync/
│ ├── hooks/ # useListSSE, usePushSubscription
│ ├── store/ # Zustand stores (calendarStore, listsStore)
│ └── sw.ts # Custom Workbox service worker
├── docker-compose.yml # Production stack (api + mariadb + redis)
├── docker-compose.yml # Production stack (api + mariadb)
└── docker-compose.dev.yml # Dev overrides
```
@@ -259,16 +257,16 @@ Do not make direct repo edits outside a GSD workflow unless the user explicitly
> Generated by GSD from session_analysis. Run `/gsd-profile-user` to update.
| Dimension | Rating | Confidence |
|-----------|--------|------------|
| Communication | conversational | MEDIUM |
| Decisions | fast-intuitive | MEDIUM |
| Explanations | concise | MEDIUM |
| Debugging | diagnostic | MEDIUM |
| UX Philosophy | design-conscious | MEDIUM |
| Vendor Choices | opinionated | LOW |
| Frustrations | instruction-adherence | MEDIUM |
| Learning | self-directed | MEDIUM |
| Dimension | Rating | Confidence |
| -------------- | --------------------- | ---------- |
| Communication | conversational | MEDIUM |
| Decisions | fast-intuitive | MEDIUM |
| Explanations | concise | MEDIUM |
| Debugging | diagnostic | MEDIUM |
| UX Philosophy | design-conscious | MEDIUM |
| Vendor Choices | opinionated | LOW |
| Frustrations | instruction-adherence | MEDIUM |
| Learning | self-directed | MEDIUM |
**Directives:**
+12 -12
View File
@@ -15,7 +15,7 @@ A self-hosted family organization hub for a two-person household. One color-code
- Node.js 22 LTS
- pnpm 11.5.1 (`corepack enable pnpm`)
- Docker + Docker Compose (for MariaDB, Redis, and production deployment)
- Docker + Docker Compose (for MariaDB and production deployment)
## Installation
@@ -53,7 +53,7 @@ Required environment variables (set in `.env` or your Docker host):
```bash
# Start backing services
docker compose -f docker-compose.yml -f docker-compose.dev.yml up mariadb redis
docker compose -f docker-compose.yml -f docker-compose.dev.yml up mariadb
# Run migrations
pnpm --filter @familysync/api db:migrate
@@ -79,8 +79,8 @@ The API listens on port 3000. The PWA build is served separately (Vite `preview`
apps/
api/ Hono backend — CalDAV sync, OIDC auth, lists API, push notifications
pwa/ React 19 PWA — calendar view, lists UI, service worker
docker-compose.yml Production services (API, MariaDB 11, Redis 7)
docker-compose.dev.yml Dev overrides (bind-mount src/, expose DB/Redis ports)
docker-compose.yml Production services (API, MariaDB 11)
docker-compose.dev.yml Dev overrides (bind-mount src/, expose DB ports)
```
## Commands
@@ -110,7 +110,7 @@ docker-compose.dev.yml Dev overrides (bind-mount src/, expose DB/Redis ports)
| Auth | `@hono/oidc-auth` 1.8.3 — authorization code + PKCE against Authelia |
| Calendar | tsdav 2.2.2 (CalDAV) + ical.js 2.2.1 against Fastmail |
| Push | web-push 3.6.7 (VAPID) |
| Live sync | Server-Sent Events + Redis 7 pub/sub |
| Live sync | Server-Sent Events (in-process EventEmitter) |
| Frontend | React 19, Vite 8, vite-plugin-pwa 1.3, TanStack Query 5, Zustand 5 |
| Calendar UI | Schedule-X 4.6 |
@@ -132,13 +132,13 @@ See [`docs/deployment.md`](docs/deployment.md) for Unraid/Docker Compose deploym
Every PR to `main` must pass four jobs before it can merge:
| Job | What it runs |
| -------------------- | -------------------------------------------------------------------------- |
| `CI / fast-checks` | `pnpm lint`, `pnpm format:check`, `pnpm md:lint`, `pnpm typecheck`, PWA unit tests |
| `CI / api` | DB migrations + API test suite against a real MariaDB 11 service container |
| `CI / harness` | Playwright end-to-end harness (WebKit iPhone + Chromium Pixel) |
| `CI / security` | Gitleaks secret scan (all PRs) + `pnpm audit` + outdated report (code PRs) |
| `CI / gate` | Aggregate: asserts all jobs above passed or were legitimately skipped |
| Job | What it runs |
| ------------------ | ---------------------------------------------------------------------------------- |
| `CI / fast-checks` | `pnpm lint`, `pnpm format:check`, `pnpm md:lint`, `pnpm typecheck`, PWA unit tests |
| `CI / api` | DB migrations + API test suite against a real MariaDB 11 service container |
| `CI / harness` | Playwright end-to-end harness (WebKit iPhone + Chromium Pixel) |
| `CI / security` | Gitleaks secret scan (all PRs) + `pnpm audit` + outdated report (code PRs) |
| `CI / gate` | Aggregate: asserts all jobs above passed or were legitimately skipped |
`fast-checks` and `security` always run. `api` and `harness` are skipped for doc-only PRs (no changes outside `.gitea/`, `.planning/`, or `*.md`). The `gate` job is the single required check for merge. Defined in `.gitea/workflows/ci.yml`.
+7 -3
View File
@@ -1,3 +1,4 @@
# syntax=docker/dockerfile:1
# Built from the REPO ROOT context (see docker-compose.yml: build.context: .)
# so the pnpm workspace manifest + lockfile are available for a deterministic,
# workspace-aware install. apps/api is one package in the pnpm workspace.
@@ -12,7 +13,8 @@ FROM base AS builder
COPY pnpm-workspace.yaml pnpm-lock.yaml package.json ./
COPY apps/api/package.json ./apps/api/
COPY apps/pwa/package.json ./apps/pwa/
RUN pnpm install --frozen-lockfile --filter @familysync/api...
RUN --mount=type=cache,target=/pnpm-store,id=pnpm-store,sharing=locked \
pnpm install --frozen-lockfile --filter @familysync/api... --store-dir /pnpm-store
COPY apps/api ./apps/api
RUN pnpm --filter @familysync/api build
@@ -28,7 +30,8 @@ FROM base AS pwa-builder
COPY pnpm-workspace.yaml pnpm-lock.yaml package.json ./
COPY apps/api/package.json ./apps/api/
COPY apps/pwa/package.json ./apps/pwa/
RUN pnpm install --frozen-lockfile --filter @familysync/pwa...
RUN --mount=type=cache,target=/pnpm-store,id=pnpm-store,sharing=locked \
pnpm install --frozen-lockfile --filter @familysync/pwa... --store-dir /pnpm-store
COPY apps/pwa ./apps/pwa
RUN pnpm --filter @familysync/pwa build
@@ -36,7 +39,8 @@ FROM base AS production
COPY pnpm-workspace.yaml pnpm-lock.yaml package.json ./
COPY apps/api/package.json ./apps/api/
COPY apps/pwa/package.json ./apps/pwa/
RUN pnpm install --frozen-lockfile --prod --filter @familysync/api...
RUN --mount=type=cache,target=/pnpm-store,id=pnpm-store,sharing=locked \
pnpm install --frozen-lockfile --prod --filter @familysync/api... --store-dir /pnpm-store
COPY --from=builder /app/apps/api/dist ./apps/api/dist
WORKDIR /app/apps/api
# Enforce production identity — engages the NODE_ENV=production hard guard
+26 -26
View File
@@ -125,27 +125,27 @@ Migration files are written to `src/db/migrations/` and checked into source cont
## Environment variables
| Variable | Required | Description |
| ----------------------------- | ------------------- | ---------------------------------------------------------------------------------- |
| `DB_HOST` | Yes | MariaDB host |
| `DB_USER` | Yes | MariaDB user |
| `DB_PASSWORD` | Yes | MariaDB password |
| `DB_NAME` | Yes | MariaDB database name |
| `DB_PORT` | No (default `3306`) | MariaDB port |
| `OIDC_ISSUER` | Yes (production) | Authelia issuer URL |
| `OIDC_CLIENT_ID` | Yes (production) | OIDC client ID |
| `OIDC_CLIENT_SECRET` | Yes (production) | OIDC client secret |
| `OIDC_AUTH_EXTERNAL_URL` | Yes (production) | External-facing URL for redirect_uri behind Pangolin tunnel |
| `OIDC_REDIRECT_URI` | No | Explicit redirect URI (overrides the `${OIDC_AUTH_EXTERNAL_URL}/callback` default) |
| `VAPID_SUBJECT` | Yes (push) | `mailto:` or `https:` operator identifier |
| `VAPID_PUBLIC_KEY` | Yes (push) | VAPID public key |
| `VAPID_PRIVATE_KEY` | Yes (push) | VAPID private key |
| `APP_PASSWORD_ENCRYPTION_KEY` | Yes | AES-256-GCM key (64-char hex) for stored Fastmail app passwords |
| `LOCAL_SESSION_SECRET` | Yes (local auth) | HS256 signing key for local-session JWT cookies (min 32 chars) |
| `LOCAL_SESSION_EXPIRES` | No (default `86400`)| Local session lifetime in seconds |
| `DEV_AUTH_BYPASS` | No | Set to `true` (non-production only) to skip OIDC and inject a dev user |
| `NODE_ENV` | No | Set to `production` to enforce OIDC unconditionally |
| `TZ` | No | IANA timezone fallback when household_timezone is not set in app_config |
| Variable | Required | Description |
| ----------------------------- | -------------------- | ---------------------------------------------------------------------------------- |
| `DB_HOST` | Yes | MariaDB host |
| `DB_USER` | Yes | MariaDB user |
| `DB_PASSWORD` | Yes | MariaDB password |
| `DB_NAME` | Yes | MariaDB database name |
| `DB_PORT` | No (default `3306`) | MariaDB port |
| `OIDC_ISSUER` | Yes (production) | Authelia issuer URL |
| `OIDC_CLIENT_ID` | Yes (production) | OIDC client ID |
| `OIDC_CLIENT_SECRET` | Yes (production) | OIDC client secret |
| `OIDC_AUTH_EXTERNAL_URL` | Yes (production) | External-facing URL for redirect_uri behind Pangolin tunnel |
| `OIDC_REDIRECT_URI` | No | Explicit redirect URI (overrides the `${OIDC_AUTH_EXTERNAL_URL}/callback` default) |
| `VAPID_SUBJECT` | Yes (push) | `mailto:` or `https:` operator identifier |
| `VAPID_PUBLIC_KEY` | Yes (push) | VAPID public key |
| `VAPID_PRIVATE_KEY` | Yes (push) | VAPID private key |
| `APP_PASSWORD_ENCRYPTION_KEY` | Yes | AES-256-GCM key (64-char hex) for stored Fastmail app passwords |
| `LOCAL_SESSION_SECRET` | Yes (local auth) | HS256 signing key for local-session JWT cookies (min 32 chars) |
| `LOCAL_SESSION_EXPIRES` | No (default `86400`) | Local session lifetime in seconds |
| `DEV_AUTH_BYPASS` | No | Set to `true` (non-production only) to skip OIDC and inject a dev user |
| `NODE_ENV` | No | Set to `production` to enforce OIDC unconditionally |
| `TZ` | No | IANA timezone fallback when household_timezone is not set in app_config |
> **Note:** `CREDENTIAL_ENCRYPTION_KEY` was renamed to `APP_PASSWORD_ENCRYPTION_KEY`. Update any existing `.env` files if upgrading from an earlier phase.
@@ -155,11 +155,11 @@ See [../../docs/CONFIGURATION.md](../../docs/CONFIGURATION.md) for the full refe
The API supports two non-exclusive auth modes, determined at startup:
| Mode | When active | How it works |
| ---- | ----------- | ------------ |
| **Local** | Always (default) | `POST /api/auth/local/login` with username + password; issues an HS256 JWT `local-session` cookie. Requires `LOCAL_SESSION_SECRET`. |
| **OIDC** | When `OIDC_ISSUER` + `OIDC_CLIENT_ID` are set (env or app_config) | `@hono/oidc-auth` authorization-code + PKCE against Authelia. Local users can upgrade to OIDC via `POST /api/me/link-oidc`. |
| **Dev bypass** | `DEV_AUTH_BYPASS=true` in non-production | Skips both guards and injects a synthetic dev user. Blocked in `NODE_ENV=production` by boot guard. |
| Mode | When active | How it works |
| -------------- | ----------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| **Local** | Always (default) | `POST /api/auth/local/login` with username + password; issues an HS256 JWT `local-session` cookie. Requires `LOCAL_SESSION_SECRET`. |
| **OIDC** | When `OIDC_ISSUER` + `OIDC_CLIENT_ID` are set (env or app_config) | `@hono/oidc-auth` authorization-code + PKCE against Authelia. Local users can upgrade to OIDC via `POST /api/me/link-oidc`. |
| **Dev bypass** | `DEV_AUTH_BYPASS=true` in non-production | Skips both guards and injects a synthetic dev user. Blocked in `NODE_ENV=production` by boot guard. |
`GET /api/auth/mode` returns `{ localEnabled, oidcEnabled }` before authentication — the PWA uses this to decide which login form to show.
+100 -6
View File
@@ -11,12 +11,13 @@
* - No console.log of request bodies or passwords in any handler (T-10-10).
*
* Routes:
* GET /api/admin/members list members + credential status (UI-SPEC Surface 2)
* POST /api/admin/members create local member: users row + local_credentials (AUTH-LOCAL-07)
* POST /api/admin/members/:id/password admin reset local member password (AUTH-LOCAL-08)
* POST /api/admin/credentials validate+encrypt+store for any member (ADMIN-01)
* GET /api/admin/calendars list synced calendars (UI-SPEC Surface 5)
* PUT /api/admin/calendars/:id/shared exclusive is_shared designation (ADMIN-02)
* GET /api/admin/members list members + credential status + isAdmin (UI-SPEC Surface 2)
* POST /api/admin/members create local member: users row + local_credentials (AUTH-LOCAL-07)
* PATCH /api/admin/members/:id update member profile: displayName and/or isAdmin (Plan 20-01)
* POST /api/admin/members/:id/password admin reset local member password (AUTH-LOCAL-08)
* POST /api/admin/credentials validate+encrypt+store for any member (ADMIN-01)
* GET /api/admin/calendars list synced calendars (UI-SPEC Surface 5)
* PUT /api/admin/calendars/:id/shared exclusive is_shared designation (ADMIN-02)
*
* Mounted in index.ts: app.route('/api/admin', adminRouter)
*/
@@ -105,6 +106,7 @@ adminRouter.get('/members', async (c) => {
id: users.id,
displayName: users.displayName,
color: users.color,
isAdmin: users.isAdmin, // Plan 20-01: feeds editor admin toggle initial state (D-02)
credentialId: memberCredentials.id,
localCredId: localCredentials.id, // LEFT JOIN — null when no local_credentials row
})
@@ -116,6 +118,7 @@ adminRouter.get('/members', async (c) => {
id: row.id,
displayName: row.displayName,
color: row.color,
isAdmin: row.isAdmin, // Plan 20-01
hasCredential: row.credentialId !== null,
hasLocalCredential: row.localCredId !== null, // AUTH-LOCAL-17
}));
@@ -208,6 +211,97 @@ adminRouter.post('/members', zValidator('json', createMemberSchema, noEchoHook),
}
});
// ---------------------------------------------------------------------------
// PATCH /api/admin/members/:id
//
// Updates a member's displayName and/or isAdmin flag (Plan 20-01, D-02, D-03).
// Security:
// - requireAdmin: inherited from adminRouter.use('*', requireAdmin) (D-02 — no second guard)
// - noEchoHook: applied for consistency with other admin write routes (T-20-04)
// - D-03 last-admin guard: rejects isAdmin=false when target is the sole remaining admin (T-20-02)
// - parsePositiveIntParam: rejects malformed ids (T-20-03)
// ---------------------------------------------------------------------------
const updateMemberSchema = z
.object({
displayName: z.string().min(1).max(256).optional(),
isAdmin: z.boolean().optional(),
})
.refine((data) => data.displayName !== undefined || data.isAdmin !== undefined, {
message: 'At least one field must be provided',
});
adminRouter.patch('/members/:id', zValidator('json', updateMemberSchema, noEchoHook), async (c) => {
const targetId = parsePositiveIntParam(c.req.param('id'));
if (targetId === null) {
return c.json({ error: 'Invalid member id' }, 400);
}
const { displayName, isAdmin } = c.req.valid('json');
// T-20-04: NEVER log request body
// Build a partial set() from whichever fields are present — validated non-empty by schema
const updates: { displayName?: string; isAdmin?: boolean } = {};
if (displayName !== undefined) updates.displayName = displayName;
if (isAdmin !== undefined) updates.isAdmin = isAdmin;
try {
// CR-01: wrap the guard + update in a transaction so the last-admin check and
// the UPDATE are atomic. Without a transaction, two concurrent demotions both
// read count=2, both pass the guard, and both commit — leaving zero admins.
// The locking read (FOR UPDATE via raw SQL suffix) serialises concurrent
// demotions: the second PATCH blocks until the first commits and then re-reads
// a count of 1, triggering the 'last-admin' error correctly.
let lastAdminViolation = false;
let notFound = false;
await db.transaction(async (tx) => {
// Re-read the target inside the transaction so we see the committed state
const [target] = await tx
.select({ id: users.id, isAdmin: users.isAdmin })
.from(users)
.where(eq(users.id, targetId))
.limit(1);
if (!target) {
notFound = true;
return;
}
// D-03 last-admin guard: reject demotion of the only remaining admin (T-20-02).
// Use a raw locking read to serialise concurrent demotions. Drizzle 0.45.x does
// not expose a first-class .for('update') on select; appending FOR UPDATE via a
// raw sql suffix achieves the same serialisation in InnoDB.
if (isAdmin === false && target.isAdmin) {
const [[{ count }]] = (await tx.execute(
sql`SELECT COUNT(*) AS count FROM ${users} WHERE ${users.isAdmin} = true FOR UPDATE`,
)) as unknown as [{ count: number | string }[], unknown];
if (Number(count) <= 1) {
lastAdminViolation = true;
return;
}
}
await tx.update(users).set(updates).where(eq(users.id, targetId));
});
if (notFound) {
return c.json({ error: 'Member not found' }, 404);
}
if (lastAdminViolation) {
return c.json({ error: 'Cannot remove the last admin' }, 409);
}
return c.json({ ok: true }, 200);
} catch (err) {
console.error(
'[admin/PATCH /members/:id] Unexpected error:',
err instanceof Error ? err.message : String(err),
);
return c.json({ error: 'Service unavailable' }, 503);
}
});
// ---------------------------------------------------------------------------
// POST /api/admin/members/:id/password
//
+197
View File
@@ -1071,3 +1071,200 @@ describe('POST /api/admin/members', () => {
expect(parsed.error).toBe('Invalid request');
});
});
// ===========================================================================
// PATCH /api/admin/members/:id — member-profile update + last-admin guard (Plan 20-01)
// ===========================================================================
describe('PATCH /api/admin/members/:id', () => {
// Test A: happy path — update displayName only
it('Test A (happy path displayName): PATCH with { displayName } as admin returns 200; GET reflects new name', async () => {
const adminId = await seedUser('admin-patch-name', true);
const memberId = await seedUser('member-patch-target', false);
currentDevUserId = adminId;
const app = await getApp();
const res = await app.fetch(
jsonRequest('PATCH', `/api/admin/members/${memberId}`, { displayName: 'New Name' }),
);
expect(res.status).toBe(200);
const body = (await res.json()) as { ok: boolean };
expect(body.ok).toBe(true);
// GET /members should reflect the updated displayName
const getRes = await app.fetch(jsonRequest('GET', '/api/admin/members'));
expect(getRes.status).toBe(200);
const getBody = (await getRes.json()) as {
members: Array<{ id: number; displayName: string }>;
};
const updated = getBody.members.find((m) => m.id === memberId);
expect(updated).toBeDefined();
expect(updated!.displayName).toBe('New Name');
});
// Test B: happy path — promote non-admin to admin
it('Test B (happy path isAdmin promote): PATCH with { isAdmin: true } returns 200; GET shows isAdmin true', async () => {
const adminId = await seedUser('admin-patch-promote', true);
const memberId = await seedUser('member-patch-promote', false);
currentDevUserId = adminId;
const app = await getApp();
const res = await app.fetch(
jsonRequest('PATCH', `/api/admin/members/${memberId}`, { isAdmin: true }),
);
expect(res.status).toBe(200);
const getRes = await app.fetch(jsonRequest('GET', '/api/admin/members'));
expect(getRes.status).toBe(200);
const getBody = (await getRes.json()) as {
members: Array<{ id: number; isAdmin: boolean }>;
};
const promoted = getBody.members.find((m) => m.id === memberId);
expect(promoted).toBeDefined();
expect(promoted!.isAdmin).toBe(true);
});
// Test C: last-admin guard — only admin cannot demote themselves
it('Test C (last-admin guard): with exactly one admin, PATCH { isAdmin: false } returns 409; member stays admin', async () => {
const adminId = await seedUser('admin-last-admin', true);
currentDevUserId = adminId;
const app = await getApp();
const res = await app.fetch(
jsonRequest('PATCH', `/api/admin/members/${adminId}`, { isAdmin: false }),
);
expect(res.status).toBe(409);
const body = (await res.json()) as { error: string };
expect(typeof body.error).toBe('string');
expect(body.error.length).toBeGreaterThan(0);
// The admin flag must still be true after the rejected demotion
const getRes = await app.fetch(jsonRequest('GET', '/api/admin/members'));
expect(getRes.status).toBe(200);
const getBody = (await getRes.json()) as {
members: Array<{ id: number; isAdmin: boolean }>;
};
const adminRow = getBody.members.find((m) => m.id === adminId);
expect(adminRow).toBeDefined();
expect(adminRow!.isAdmin).toBe(true);
});
// Test D: self-demotion allowed when another admin exists
it('Test D (self-demotion allowed): with two admins, PATCH { isAdmin: false } returns 200; one admin remains', async () => {
const adminId1 = await seedUser('admin-demote-1', true);
const adminId2 = await seedUser('admin-demote-2', true);
// Log in as adminId1 to perform the self-demotion
currentDevUserId = adminId1;
const app = await getApp();
const res = await app.fetch(
jsonRequest('PATCH', `/api/admin/members/${adminId1}`, { isAdmin: false }),
);
expect(res.status).toBe(200);
// Switch to adminId2 to verify the outcome — adminId1 is now non-admin
// and can no longer call GET /members (would 403).
currentDevUserId = adminId2;
const getRes = await app.fetch(jsonRequest('GET', '/api/admin/members'));
expect(getRes.status).toBe(200);
const getBody = (await getRes.json()) as {
members: Array<{ id: number; isAdmin: boolean }>;
};
const row1 = getBody.members.find((m) => m.id === adminId1);
const row2 = getBody.members.find((m) => m.id === adminId2);
expect(row1!.isAdmin).toBe(false);
expect(row2!.isAdmin).toBe(true);
});
// Test E: auth boundary — non-admin gets 403
it('Test E (auth boundary): non-admin PATCH returns 403', async () => {
const adminId = await seedUser('admin-patch-auth', true);
const nonAdminId = await seedUser('non-admin-patch', false);
currentDevUserId = nonAdminId;
const app = await getApp();
const res = await app.fetch(
jsonRequest('PATCH', `/api/admin/members/${adminId}`, { displayName: 'Hacked' }),
);
expect(res.status).toBe(403);
});
// Test F: validation — wrong type and malformed id
it('Test F (validation): PATCH with { isAdmin: "yes" } returns 400 { error: "Invalid request" }', async () => {
const adminId = await seedUser('admin-patch-validation', true);
const memberId = await seedUser('member-patch-validation', false);
currentDevUserId = adminId;
const app = await getApp();
const res = await app.fetch(
jsonRequest('PATCH', `/api/admin/members/${memberId}`, { isAdmin: 'yes' }),
);
expect(res.status).toBe(400);
const body = (await res.json()) as { error: string };
expect(body.error).toBe('Invalid request');
});
it('Test F (malformed id): PATCH with malformed :id (e.g. "1abc") returns 400', async () => {
const adminId = await seedUser('admin-patch-badid', true);
currentDevUserId = adminId;
const app = await getApp();
const res = await app.fetch(
jsonRequest('PATCH', '/api/admin/members/1abc', { displayName: 'Test' }),
);
expect(res.status).toBe(400);
});
// Test G: not found — non-existent member id
it('Test G (not found): PATCH non-existent member id returns 404', async () => {
const adminId = await seedUser('admin-patch-notfound', true);
currentDevUserId = adminId;
const app = await getApp();
const res = await app.fetch(
jsonRequest('PATCH', '/api/admin/members/99999999', { displayName: 'Ghost' }),
);
expect(res.status).toBe(404);
});
// Test H (WR-06): empty {} body must return 400, not crash Drizzle with a 503
it('Test H (WR-06 empty body): PATCH with {} returns 400 { error: "Invalid request" }', async () => {
const adminId = await seedUser('admin-patch-empty', true);
const memberId = await seedUser('member-patch-empty', false);
currentDevUserId = adminId;
const app = await getApp();
const res = await app.fetch(jsonRequest('PATCH', `/api/admin/members/${memberId}`, {}));
expect(res.status).toBe(400);
const body = (await res.json()) as { error: string };
expect(body.error).toBe('Invalid request');
});
});
// ===========================================================================
// GET /api/admin/members — isAdmin field (Plan 20-01)
// ===========================================================================
describe('GET /api/admin/members — isAdmin field', () => {
it('Test H (GET isAdmin field): each member object includes a boolean isAdmin field', async () => {
const adminId = await seedUser('admin-isadmin-field', true);
const memberId = await seedUser('member-isadmin-field', false);
currentDevUserId = adminId;
const app = await getApp();
const res = await app.fetch(jsonRequest('GET', '/api/admin/members'));
expect(res.status).toBe(200);
const body = (await res.json()) as {
members: Array<{ id: number; isAdmin: boolean }>;
};
// Both seeded users should have a boolean isAdmin field
const adminRow = body.members.find((m) => m.id === adminId);
const memberRow = body.members.find((m) => m.id === memberId);
expect(adminRow).toBeDefined();
expect(typeof adminRow!.isAdmin).toBe('boolean');
expect(adminRow!.isAdmin).toBe(true);
expect(memberRow).toBeDefined();
expect(typeof memberRow!.isAdmin).toBe('boolean');
expect(memberRow!.isAdmin).toBe(false);
});
});
+11 -11
View File
@@ -32,17 +32,17 @@ The API backend must also be running for most features. See [GETTING-STARTED.md]
## Scripts
| Command | What it does |
| ------------------------------------------------ | ------------------------------------------------------------------------- |
| `pnpm --filter @familysync/pwa dev` | Start Vite dev server (HMR) |
| `pnpm --filter @familysync/pwa build` | Type-check then build production bundle (`tsc && vite build`) |
| `pnpm --filter @familysync/pwa preview` | Serve the production build locally |
| `pnpm --filter @familysync/pwa lint` | Run ESLint over `src/` and `e2e/` with zero warnings allowed |
| `pnpm --filter @familysync/pwa typecheck` | Run `tsc --noEmit` for both `src/` and `e2e/` tsconfigs |
| `pnpm --filter @familysync/pwa test` | Run Vitest unit/integration suite once (`vitest run`) |
| `pnpm --filter @familysync/pwa test:e2e` | Run Playwright end-to-end tests headlessly |
| `pnpm --filter @familysync/pwa test:e2e:ui` | Open the Playwright UI runner |
| `pnpm --filter @familysync/pwa test:e2e:headed` | Run Playwright tests in a headed browser |
| Command | What it does |
| ----------------------------------------------- | ------------------------------------------------------------- |
| `pnpm --filter @familysync/pwa dev` | Start Vite dev server (HMR) |
| `pnpm --filter @familysync/pwa build` | Type-check then build production bundle (`tsc && vite build`) |
| `pnpm --filter @familysync/pwa preview` | Serve the production build locally |
| `pnpm --filter @familysync/pwa lint` | Run ESLint over `src/` and `e2e/` with zero warnings allowed |
| `pnpm --filter @familysync/pwa typecheck` | Run `tsc --noEmit` for both `src/` and `e2e/` tsconfigs |
| `pnpm --filter @familysync/pwa test` | Run Vitest unit/integration suite once (`vitest run`) |
| `pnpm --filter @familysync/pwa test:e2e` | Run Playwright end-to-end tests headlessly |
| `pnpm --filter @familysync/pwa test:e2e:ui` | Open the Playwright UI runner |
| `pnpm --filter @familysync/pwa test:e2e:headed` | Run Playwright tests in a headed browser |
## Source layout
-1
View File
@@ -15,7 +15,6 @@ The stack must include:
- API on `:3000` started with `DEV_AUTH_BYPASS=true` (see Security Guardrail below)
- PWA dev server on `:5173` (`pnpm --filter @familysync/pwa dev`)
- Dev MariaDB on `:3306` (exposed via `docker-compose.dev.yml`)
- Redis on `:6379`
**`DEV_AUTH_BYPASS=true` MUST be set in the API's environment BEFORE the API process starts.** The harness cannot inject it at runtime — the API reads the env var once at startup. If the API is running without it, all `/api/*` requests return an auth redirect and every spec fails.
+6
View File
@@ -20,6 +20,10 @@ test.describe('Admin Timezone section — 18-04 round-trip', () => {
test.skip(testInfo.project.name !== 'desktop', 'Admin timezone UI is desktop-only');
await page.goto('/admin');
await expect(page.getByRole('heading', { name: 'Admin Settings' })).toBeVisible();
// Phase 17 (D-10) reworked /admin into a two-tab layout; the Timezone section
// now lives in the "Settings" tab panel, which is hidden until selected. Open
// it before asserting the section.
await page.getByRole('tab', { name: 'Settings' }).click();
// Wait for the Timezone section to load (requires the 18-02 GET endpoint)
const tzSection = page.getByRole('region', { name: 'Timezone' });
await expect(tzSection).toBeVisible({ timeout: 15_000 });
@@ -98,6 +102,8 @@ test.describe('Admin Timezone section — 18-04 round-trip', () => {
// Reload and verify persistence
await page.reload();
await expect(page.getByRole('heading', { name: 'Admin Settings' })).toBeVisible();
// Reload resets to the default "Members & Accounts" tab — re-open Settings.
await page.getByRole('tab', { name: 'Settings' }).click();
await expect(tzSection).toBeVisible({ timeout: 15_000 });
const inputAfterReload = page.getByRole('combobox', { name: 'Household timezone' });
await expect(inputAfterReload).toHaveValue('America/Chicago');
+1
View File
@@ -4,6 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<!-- favicon.ico is hand-maintained (not emitted by pwa:icons); regenerate manually when the brand mark changes — see scripts/copy-pwa-icons.mjs (IN-02). -->
<link rel="icon" href="/favicon.ico" sizes="any" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" sizes="180x180" />
<meta name="theme-color" content="#e8915a" />
+2 -2
View File
@@ -5,7 +5,7 @@
* Auth: DEV_AUTH_BYPASS=true on the API (never storageState D-01/Pitfall 14)
* SW: serviceWorkers: 'block' on all profiles (D-02/Pitfall 15)
* baseURL: env-driven PLAYWRIGHT_BASE_URL (D-08/Rule 8)
* webServer: manages Vite only API+MariaDB+Redis stay compose-managed (D-10)
* webServer: manages Vite only API+MariaDB stay compose-managed (D-10)
*
* Run:
* pnpm --filter @familysync/pwa test:e2e
@@ -63,7 +63,7 @@ export default defineConfig({
},
],
// D-10: manage Vite only; API+MariaDB+Redis are compose-managed
// D-10: manage Vite only; API+MariaDB are compose-managed
// reuseExistingServer: reuse operator's pnpm dev locally; start fresh in CI
webServer: {
command: 'pnpm --filter @familysync/pwa dev',
+2 -2
View File
@@ -1,4 +1,4 @@
import { defineConfig, minimal2023Preset } from '@vite-pwa/assets-generator/config'
import { defineConfig, minimal2023Preset } from '@vite-pwa/assets-generator/config';
export default defineConfig({
preset: {
@@ -7,4 +7,4 @@ export default defineConfig({
images: ['public/logo.svg'],
// Do NOT set overrideManifestIcons: true — the manifest is maintained by hand
// in vite.config.ts (plan 17-04); auto-override would stomp the explicit entries.
})
});
+6
View File
@@ -9,6 +9,12 @@
* chain so Windows contributors and minimal CI containers can run `pwa:icons`.
*
* Keep COPIES in sync with the manifest `icons` array in vite.config.ts.
*
* NOTE (IN-02): public/favicon.ico is NOT produced by this script the
* minimal2023Preset does not emit a .ico. It is hand-maintained and committed
* directly in public/, and referenced by index.html (`<link rel="icon"
* href="/favicon.ico">`). When the brand mark changes, regenerate favicon.ico
* manually so it does not go stale relative to the SVG/PNG outputs below.
*/
import { copyFileSync, existsSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
+17 -1
View File
@@ -78,7 +78,23 @@ function OidcRedirect() {
useEffect(() => {
window.location.replace('/api/login');
}, []);
return <div aria-hidden="true" />;
// IN-03: render a perceivable status (not an empty aria-hidden div) so the
// transition is announced to screen-reader/keyboard users if the redirect is slow.
return (
<div
role="status"
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
minHeight: '100vh',
color: 'var(--color-text-muted, #6b7280)',
fontSize: '0.95rem',
}}
>
Redirecting to sign in
</div>
);
}
export default function App() {
+137
View File
@@ -684,3 +684,140 @@ describe('fetchAdminResetPassword — URL contract (Phase 19, AUTH-LOCAL-08)', (
);
});
});
// ── Phase 20 (Plan 20-02): updateMemberProfile + AdminMember.isAdmin ─────────
// TDD RED: these tests MUST fail before the implementation is added to client.ts.
describe('updateMemberProfile — URL + verb contract (Phase 20, Plan 20-02)', () => {
beforeEach(() => {
vi.stubGlobal('fetch', vi.fn());
});
afterEach(() => {
vi.unstubAllGlobals();
});
it('PATCHes /api/admin/members/:id (must match PATCH /members/:id in admin.ts)', async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
type: 'basic',
status: 200,
} as unknown as Response);
const { updateMemberProfile } = await import('./client.js');
await updateMemberProfile(7, { displayName: 'Alice' });
expect(fetch).toHaveBeenCalledWith(
'/api/admin/members/7',
expect.objectContaining({ method: 'PATCH' }),
);
});
it('sends credentials:include and redirect:manual', async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
type: 'basic',
status: 200,
} as unknown as Response);
const { updateMemberProfile } = await import('./client.js');
await updateMemberProfile(3, { isAdmin: true });
expect(fetch).toHaveBeenCalledWith(
'/api/admin/members/3',
expect.objectContaining({
credentials: 'include',
redirect: 'manual',
}),
);
});
it('resolves void on 200', async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
type: 'basic',
status: 200,
} as unknown as Response);
const { updateMemberProfile } = await import('./client.js');
const result = await updateMemberProfile(7, { displayName: 'Bob' });
expect(result).toBeUndefined();
});
it('throws SessionExpiredError on opaqueredirect', async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: false,
type: 'opaqueredirect',
status: 0,
} as unknown as Response);
const { updateMemberProfile, SessionExpiredError } = await import('./client.js');
await expect(updateMemberProfile(7, { displayName: 'X' })).rejects.toBeInstanceOf(
SessionExpiredError,
);
});
it('throws SessionExpiredError on 401', async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: false,
type: 'basic',
status: 401,
} as unknown as Response);
const { updateMemberProfile, SessionExpiredError } = await import('./client.js');
await expect(updateMemberProfile(7, { displayName: 'X' })).rejects.toBeInstanceOf(
SessionExpiredError,
);
});
it('throws Error("last-admin") on 409 (last-admin demotion sentinel)', async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: false,
type: 'basic',
status: 409,
} as unknown as Response);
const { updateMemberProfile } = await import('./client.js');
await expect(updateMemberProfile(7, { isAdmin: false })).rejects.toThrow('last-admin');
});
it('throws Error("last-admin") on 422 as well (server may return either)', async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: false,
type: 'basic',
status: 422,
} as unknown as Response);
const { updateMemberProfile } = await import('./client.js');
await expect(updateMemberProfile(7, { isAdmin: false })).rejects.toThrow('last-admin');
});
it('throws a generic error on any other non-ok status (not last-admin sentinel)', async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: false,
type: 'basic',
status: 500,
} as unknown as Response);
const { updateMemberProfile, SessionExpiredError } = await import('./client.js');
await expect(updateMemberProfile(7, { displayName: 'X' })).rejects.toSatisfy(
(e: unknown) =>
e instanceof Error && !(e instanceof SessionExpiredError) && e.message !== 'last-admin',
);
});
});
describe('AdminMember.isAdmin field (Phase 20, Plan 20-02)', () => {
it('AdminMember interface has isAdmin: boolean (compile-time type check via runtime shape)', () => {
// Construct a conforming object — TypeScript will error at compile time if
// isAdmin is missing from the AdminMember interface (caught by tsc --noEmit).
const member: import('./client.js').AdminMember = {
id: 1,
displayName: 'Test',
color: '#abc',
isAdmin: true,
hasCredential: false,
hasLocalCredential: false,
};
expect(member.isAdmin).toBe(true);
});
});
+31
View File
@@ -233,6 +233,36 @@ export async function fetchAdminResetPassword(
}
}
/**
* PATCH /api/admin/members/:id update a member's display name and/or admin flag (Phase 20, D-02).
*
* Admin-only; server enforces requireAdmin. Sends only the fields that are present in `body`
* (partial update the server schema marks both fields optional).
*
* Status codes:
* 200 success (resolves void)
* 401 / opaqueredirect throws SessionExpiredError (session expired; existing convention)
* 409 / 422 throws Error('last-admin') the D-03 sentinel: demoting the last admin is
* rejected server-side; the editor branches on this message to show inline copy.
* other non-ok generic error
*/
export async function updateMemberProfile(
memberId: number,
body: { displayName?: string; isAdmin?: boolean },
): Promise<void> {
const res = await fetch(`/api/admin/members/${memberId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
redirect: 'manual',
body: JSON.stringify(body),
});
if (res.type === 'opaqueredirect' || res.status === 401) throw new SessionExpiredError();
if (res.status === 409 || res.status === 422) throw new Error('last-admin');
if (!res.ok) throw new Error(`updateMemberProfile failed: ${res.status}`);
}
/**
* POST /api/me/link-oidc initiate the OIDC-link flow for the current local user (Surface 13).
*
@@ -564,6 +594,7 @@ export interface AdminMember {
id: number;
displayName: string | null;
color: string;
isAdmin: boolean; // Phase 20 — drives the editor admin toggle initial state (D-02)
hasCredential: boolean;
hasLocalCredential: boolean; // true when a local_credentials row exists for this member (Phase 19)
}
+6 -1
View File
@@ -153,7 +153,12 @@ export function CalendarShell() {
// IANATimezone (the config's timezone type) is declared but not exported by @schedule-x/calendar,
// so derive it from useCalendarApp's config parameter rather than importing it.
type SxTimeZone = NonNullable<Parameters<typeof useCalendarApp>[0]['timezone']>;
const displayTimeZone: SxTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
// IN-07: memoize so this stable value never changes identity across renders —
// it feeds the useCalendarApp config the file works hard to keep stable.
const displayTimeZone = useMemo<SxTimeZone>(
() => Intl.DateTimeFormat().resolvedOptions().timeZone,
[],
);
// useCalendarApp — config is stable; plugins passed as second argument
const calendar = useCalendarApp(
@@ -0,0 +1,936 @@
/**
* MemberEditorSheet unified member editor sheet (Phase 20, D-05, D-06, D-07).
*
* Single component with a `mode` prop:
* - 'edit' three per-section saves: Profile, Set new password, App password
* - 'create' single form: display name, username, initial password, confirm
*
* UI-SPEC §Surface B (20-UI-SPEC.md):
* - role="dialog", aria-modal, useFocusTrap, Escape closes, focus returns to trigger
* - Phone: fixed bottom bottom-sheet; Desktop: centered modal (480px)
* - zIndex 301 / backdrop 300 / overlay rgba(0,0,0,0.32)
* - Per-section saves keep the sheet open; create-mode save closes it
* - Admin toggle: role="switch", aria-checked (Accessibility Contract)
*
* Security:
* T-20-07: password/app-password fields are write-only: never prefilled,
* autoComplete="new-password", never logged (T-10-15/16 preserved)
* T-20-08: app-password save routes through saveCredential server-side CalDAV
* validation; invalid password surfaces copy, nothing stored
* T-20-09: admin toggle is cosmetic; D-03 409 guard enforced server-side (Plan 20-01)
*
* Retired copy (D-06): per-row credential buttons and standalone password-reset button removed from this phase.
*/
import { useState, useEffect, useRef, useCallback } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Loader2 } from 'lucide-react';
import {
updateMemberProfile,
fetchAdminResetPassword,
fetchCreateMember,
saveCredential,
type AdminMember,
} from '../api/client.js';
import { useIsPhone } from '../hooks/useIsPhone.js';
import { useFocusTrap } from '../hooks/useFocusTrap.js';
// ── Types ──────────────────────────────────────────────────────────────────
export type MemberEditorSheetMode = 'edit' | 'create';
interface MemberEditorSheetProps {
isOpen: boolean;
onClose: () => void;
mode: MemberEditorSheetMode;
/** Present in edit mode; absent in create mode */
member?: AdminMember;
/** Ref to the trigger element — focus returns here on close (a11y) */
triggerRef?: React.RefObject<HTMLElement | null>;
/** Lift toast copy up to AdminPage which owns the toast state */
onToast: (message: string) => void;
}
// ── Copywriting (UI-SPEC §Copywriting Contract) ───────────────────────────
function headingFor(mode: MemberEditorSheetMode): string {
return mode === 'edit' ? 'Edit member' : 'Add member';
}
const CALDAV_FAILURE_TEXT =
"Invalid password — CalDAV validation failed. Check the scope is 'Calendars & Contacts (CalDAV)' and try again.";
const CALDAV_VALIDATING_TEXT = 'Validating against CalDAV…';
const CALDAV_LINK_HREF = 'https://app.fastmail.com/settings/security/devicetokens';
const CALDAV_LINK_TEXT = 'Get an app password';
// ── Shared field style helpers ─────────────────────────────────────────────
const inputStyle = (hasError: boolean): React.CSSProperties => ({
width: '100%',
boxSizing: 'border-box',
padding: 'var(--space-3, 12px) var(--space-4, 16px)',
border: `1px solid ${hasError ? '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',
minHeight: '44px',
});
const labelStyle: React.CSSProperties = {
display: 'block',
fontSize: 'var(--text-label-size, 13px)',
fontWeight: 600,
color: 'var(--color-text-primary)',
marginBottom: 'var(--space-1, 4px)',
};
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-4, 16px)',
};
const sectionDividerStyle: React.CSSProperties = {
borderTop: '1px solid var(--color-border-subtle)',
margin: 'var(--space-6, 24px) 0',
};
const fieldContainerStyle: React.CSSProperties = {
marginBottom: 'var(--space-3, 12px)',
};
const inlineErrorStyle: React.CSSProperties = {
fontSize: 'var(--text-label-size, 13px)',
fontWeight: 400,
color: 'var(--color-destructive, #DC2626)',
marginTop: 'var(--space-2, 8px)',
};
const helperTextStyle: React.CSSProperties = {
fontSize: 'var(--text-label-size, 13px)',
fontWeight: 400,
color: 'var(--color-text-secondary)',
marginBottom: 'var(--space-3, 12px)',
lineHeight: 1.4,
};
function primaryButtonStyle(disabled: boolean): React.CSSProperties {
return {
background: disabled ? 'var(--color-border, #E2E4E9)' : 'var(--color-member-0, #e8915a)',
color: '#ffffff',
border: 'none',
cursor: disabled ? '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',
display: 'flex',
alignItems: 'center',
gap: 'var(--space-2, 8px)',
};
}
const cancelButtonStyle: React.CSSProperties = {
background: 'none',
border: 'none',
cursor: '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)',
};
const actionsRowStyle: React.CSSProperties = {
display: 'flex',
justifyContent: 'flex-end',
gap: 'var(--space-3, 12px)',
marginTop: 'var(--space-4, 16px)',
};
// ── Component ──────────────────────────────────────────────────────────────
export function MemberEditorSheet({
isOpen,
onClose,
mode,
member,
triggerRef,
onToast,
}: MemberEditorSheetProps) {
const queryClient = useQueryClient();
const phone = useIsPhone();
// Focus management refs
const headingRef = useRef<HTMLHeadingElement>(null);
const dialogRef = useRef<HTMLDivElement>(null);
const handleDialogKeyDown = useFocusTrap(dialogRef);
// ── Profile section state ────────────────────────────────────────────────
const [displayName, setDisplayName] = useState(member?.displayName ?? '');
const [isAdmin, setIsAdmin] = useState(member?.isAdmin ?? false);
const [profileError, setProfileError] = useState<string | null>(null);
// ── Set new password section state ──────────────────────────────────────
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [passwordError, setPasswordError] = useState<string | null>(null);
// ── App password section state ───────────────────────────────────────────
// NOTE: GET /api/admin/members does NOT return fastmailEmail; the stored
// fastmail_email lives only on the member_credentials row, which is not
// surfaced to the admin list. So the email field starts BLANK on edit —
// the admin must re-enter it. This is intentional (see D-CONTEXT note).
const [fastmailEmail, setFastmailEmail] = useState('');
const [appPassword, setAppPassword] = useState('');
const [appPasswordError, setAppPasswordError] = useState<string | null>(null);
// ── Create mode state ───────────────────────────────────────────────────
const [createDisplayName, setCreateDisplayName] = useState('');
const [createUsername, setCreateUsername] = useState('');
const [createPassword, setCreatePassword] = useState('');
const [createConfirmPassword, setCreateConfirmPassword] = useState('');
const [createError, setCreateError] = useState<string | null>(null);
// ── handleClose ──────────────────────────────────────────────────────────
// WR-04: only reset ephemeral fields (password inputs, error states, create-mode
// fields). Member-derived fields (displayName, isAdmin) are owned by the useEffect
// below and will re-sync from the live member prop when the sheet re-opens or when
// membersQuery refetches — no stale closure problem.
const handleClose = useCallback(() => {
setProfileError(null);
setNewPassword('');
setConfirmPassword('');
setPasswordError(null);
setFastmailEmail('');
setAppPassword('');
setAppPasswordError(null);
setCreateDisplayName('');
setCreateUsername('');
setCreatePassword('');
setCreateConfirmPassword('');
setCreateError(null);
onClose();
// Return focus to trigger element (a11y)
if (triggerRef?.current) {
triggerRef.current.focus();
}
}, [onClose, triggerRef]);
// Sync profile state when member changes (different row opened)
useEffect(() => {
setDisplayName(member?.displayName ?? '');
setIsAdmin(member?.isAdmin ?? false);
setProfileError(null);
}, [member?.id, member?.displayName, member?.isAdmin]);
// Escape key closes the sheet
useEffect(() => {
if (!isOpen) return;
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') handleClose();
};
document.addEventListener('keydown', onKeyDown);
return () => document.removeEventListener('keydown', onKeyDown);
}, [isOpen, handleClose]);
// Focus heading on open (a11y)
useEffect(() => {
if (isOpen && headingRef.current) {
headingRef.current.focus();
}
}, [isOpen]);
// ── Profile mutation (Section 1) ─────────────────────────────────────────
const profileMutation = useMutation({
mutationFn: async () => {
if (!member) throw new Error('no-member');
// WR-01: send only the fields that actually changed to avoid re-writing
// displayName on an admin-toggle-only save (and to prevent 400s when a member
// has a null displayName and the admin only wants to toggle the admin flag).
const payload: { displayName?: string; isAdmin?: boolean } = {};
const trimmed = displayName.trim();
if (trimmed !== (member.displayName ?? '')) {
if (trimmed.length === 0) throw new Error('name-required');
payload.displayName = trimmed;
}
if (isAdmin !== member.isAdmin) payload.isAdmin = isAdmin;
// No-op guard — nothing changed, skip the network call and signal no write
// (IN-04: avoids firing the "Profile saved." toast + refetch on a no-op save).
if (Object.keys(payload).length === 0) return false;
await updateMemberProfile(member.id, payload);
return true;
},
onSuccess: (changed) => {
// IN-04: only surface success feedback when an actual write occurred.
if (!changed) return;
void queryClient.invalidateQueries({ queryKey: ['admin', 'members'] });
onToast('Profile saved.');
// Sheet stays open — per-section save (D-05)
},
onError: (err) => {
const msg = err instanceof Error ? err.message : 'server';
if (msg === 'last-admin') {
// D-03 guard: revert toggle to the actual prior value on the member object.
// WR-02: use member!.isAdmin explicitly rather than `?? true` — the `?? true`
// was accidentally correct only because the guard fires when demoting an admin,
// but it would incorrectly set isAdmin=true for any future error path where
// member is non-null but isAdmin is false.
setIsAdmin(member!.isAdmin);
setProfileError('Cannot remove admin — at least one admin must remain.');
} else if (msg === 'name-required') {
setProfileError('A display name is required before saving.');
} else {
setProfileError('Something went wrong. Please try again.');
}
},
});
// ── Set new password mutation (Section 2) ────────────────────────────────
const passwordMutation = useMutation({
mutationFn: async () => {
if (!member) throw new Error('no-member');
if (newPassword !== confirmPassword) throw new Error('mismatch');
if (newPassword.length < 8) throw new Error('short');
await fetchAdminResetPassword(member.id, newPassword);
},
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['admin', 'members'] });
setNewPassword('');
setConfirmPassword('');
setPasswordError(null);
onToast('Password updated.');
// Sheet stays open — per-section save (D-05)
},
onError: (err) => {
const msg = err instanceof Error ? err.message : 'server';
if (msg === 'mismatch') {
setPasswordError('Passwords do not match.');
} else if (msg === 'short') {
setPasswordError('Password must be at least 8 characters.');
} else {
setPasswordError('Something went wrong. Please try again.');
}
},
});
// ── App password mutation (Section 3) ────────────────────────────────────
const appPasswordMutation = useMutation({
mutationFn: async () => {
if (!member) throw new Error('no-member');
await saveCredential({
userId: member.id,
providerType: 'caldav',
fastmailEmail: fastmailEmail.trim(),
appPassword,
});
},
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['admin', 'members'] });
void queryClient.invalidateQueries({ queryKey: ['me'] });
setFastmailEmail('');
setAppPassword('');
setAppPasswordError(null);
onToast('App password saved.');
// Sheet stays open — per-section save (D-05)
},
onError: () => {
setAppPasswordError(CALDAV_FAILURE_TEXT);
},
});
// ── Create member mutation ────────────────────────────────────────────────
const createMutation = useMutation({
mutationFn: async () => {
if (createPassword !== createConfirmPassword) throw new Error('mismatch');
if (createPassword.length < 8) throw new Error('short');
await fetchCreateMember({
displayName: createDisplayName.trim(),
username: createUsername.trim(),
password: createPassword,
});
},
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['admin', 'members'] });
onToast('Member added.');
// Create mode closes on success (D-07)
handleClose();
},
onError: (err) => {
const msg = err instanceof Error ? err.message : 'server';
if (msg === 'mismatch') {
setCreateError('Passwords do not match.');
} else if (msg === 'short') {
setCreateError('Password must be at least 8 characters.');
} else if (msg === 'conflict' || msg.includes('409')) {
setCreateError('That username is already in use. Choose a different one.');
} else {
setCreateError('Something went wrong. Please try again.');
}
},
});
// ── Render guard ──────────────────────────────────────────────────────────
if (!isOpen) return null;
const heading = headingFor(mode);
// ── Sheet style ───────────────────────────────────────────────────────────
const sheetStyle: React.CSSProperties = phone
? {
position: 'fixed',
bottom: 0,
left: 0,
right: 0,
// WR-03: cap height so content does not spill off screen on short phones
// (iPhone SE 667px with three sections visible). overflowY:'auto' enables
// scroll when content exceeds maxHeight.
maxHeight: '90dvh',
overflowY: 'auto',
background: 'var(--color-surface)',
borderRadius: '12px 12px 0 0',
boxShadow: '0 -4px 24px rgba(0,0,0,0.15)',
padding: 'var(--space-6, 24px)',
// IN-03: pad the bottom to clear iOS home indicator / Android gesture bar
paddingBottom: 'calc(var(--space-6, 24px) + env(safe-area-inset-bottom, 0px))',
zIndex: 301,
fontFamily: 'var(--font-family-base)',
}
: {
position: 'fixed',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
maxWidth: '480px',
width: 'calc(100% - var(--space-8, 32px))',
maxHeight: 'calc(100dvh - var(--space-8, 32px))',
overflowY: 'auto',
background: 'var(--color-surface)',
borderRadius: '12px',
boxShadow: '0 8px 32px rgba(0,0,0,0.18)',
padding: 'var(--space-6, 24px)',
zIndex: 301,
fontFamily: 'var(--font-family-base)',
};
return (
<>
{/* Backdrop — uses --color-overlay per UI-SPEC §Surface B (matches ResetPasswordSheet) */}
<div
onClick={handleClose}
aria-hidden="true"
style={{
position: 'fixed',
inset: 0,
background: 'var(--color-overlay, rgba(0,0,0,0.32))',
zIndex: 300,
}}
/>
{/* Sheet */}
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-label={heading}
onKeyDown={handleDialogKeyDown}
style={sheetStyle}
>
{/* 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 (edit mode only) */}
{mode === 'edit' && member && (
<div
style={{
fontSize: 'var(--text-body-size, 15px)',
fontWeight: 400,
color: 'var(--color-text-secondary)',
marginBottom: 'var(--space-6, 24px)',
}}
>
{member.displayName ?? 'Member'}
</div>
)}
{/* ── EDIT MODE ──────────────────────────────────────────────────── */}
{mode === 'edit' && member && (
<>
{/* Section 1 — Profile */}
<div>
<div style={sectionLabelStyle}>Profile</div>
{/* Display name */}
<div style={fieldContainerStyle}>
<label htmlFor="editor-display-name" style={labelStyle}>
Display name
</label>
<input
id="editor-display-name"
type="text"
maxLength={256}
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
aria-describedby={profileError ? 'profile-error' : undefined}
style={inputStyle(!!profileError)}
/>
</div>
{/* Admin toggle */}
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 'var(--space-4, 16px)',
}}
>
<div>
<div
style={{
fontSize: 'var(--text-body-size, 15px)',
fontWeight: 400,
color: 'var(--color-text-primary)',
}}
>
Admin
</div>
<div
style={{
fontSize: 'var(--text-label-size, 13px)',
fontWeight: 400,
color: 'var(--color-text-secondary)',
}}
>
Can access admin settings
</div>
</div>
{/* Toggle pill — role="switch" per UI-SPEC Accessibility Contract */}
<button
type="button"
role="switch"
aria-checked={isAdmin}
aria-label="Admin"
aria-describedby={profileError ? 'profile-error' : undefined}
onClick={() => {
setProfileError(null);
setIsAdmin((prev) => !prev);
}}
style={{
width: '44px',
height: '24px',
borderRadius: '12px',
border: 'none',
cursor: 'pointer',
background: isAdmin
? 'var(--color-member-0, #e8915a)'
: 'var(--color-border, #E2E4E9)',
position: 'relative',
transition: 'background 0.15s ease',
flexShrink: 0,
}}
>
{/* Thumb */}
<span
aria-hidden="true"
style={{
position: 'absolute',
top: '2px',
left: isAdmin ? '22px' : '2px',
width: '20px',
height: '20px',
borderRadius: '50%',
background: '#ffffff',
transition: 'left 0.15s ease',
}}
/>
</button>
</div>
{/* IN-01: helper text when display name is empty (e.g. null-displayName
OIDC member) so the admin knows why Save is disabled */}
{displayName.trim().length === 0 && !profileError && (
<p style={helperTextStyle}>Enter a display name to enable Save.</p>
)}
{/* Last-admin guard inline error */}
{profileError && (
<div id="profile-error" style={inlineErrorStyle}>
{profileError}
</div>
)}
<div style={actionsRowStyle}>
<button type="button" onClick={handleClose} style={cancelButtonStyle}>
Cancel
</button>
<button
type="button"
disabled={profileMutation.isPending || displayName.trim().length === 0}
onClick={() => {
setProfileError(null);
profileMutation.mutate();
}}
style={primaryButtonStyle(
profileMutation.isPending || displayName.trim().length === 0,
)}
>
{profileMutation.isPending && (
<Loader2
size={14}
aria-hidden="true"
style={{ animation: 'spin 1s linear infinite', flexShrink: 0 }}
/>
)}
Save
</button>
</div>
</div>
{/* Section 2 — Set new password (only for members with local credential) */}
{member.hasLocalCredential && (
<>
<div style={sectionDividerStyle} />
<div>
<div style={sectionLabelStyle}>Set new password</div>
<div style={helperTextStyle}>Leave blank to keep the current password.</div>
{/* New password */}
<div style={fieldContainerStyle}>
<label htmlFor="editor-new-password" style={labelStyle}>
New password
</label>
<input
id="editor-new-password"
type="password"
autoComplete="new-password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
aria-describedby={passwordError ? 'password-error' : undefined}
style={inputStyle(!!passwordError)}
/>
</div>
{/* Confirm new password */}
<div style={fieldContainerStyle}>
<label htmlFor="editor-confirm-password" style={labelStyle}>
Confirm new password
</label>
<input
id="editor-confirm-password"
type="password"
autoComplete="new-password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
aria-describedby={passwordError ? 'password-error' : undefined}
style={inputStyle(!!passwordError)}
/>
</div>
{/* Password error */}
{passwordError && (
<div id="password-error" style={inlineErrorStyle}>
{passwordError}
</div>
)}
<div style={actionsRowStyle}>
<button
type="button"
disabled={
passwordMutation.isPending ||
newPassword.length === 0 ||
confirmPassword.length === 0
}
onClick={() => {
setPasswordError(null);
passwordMutation.mutate();
}}
style={primaryButtonStyle(
passwordMutation.isPending ||
newPassword.length === 0 ||
confirmPassword.length === 0,
)}
>
{passwordMutation.isPending && (
<Loader2
size={14}
aria-hidden="true"
style={{ animation: 'spin 1s linear infinite', flexShrink: 0 }}
/>
)}
Set password
</button>
</div>
</div>
</>
)}
{/* Section 3 — App password (always in edit mode) */}
<>
<div style={sectionDividerStyle} />
<div>
<div style={sectionLabelStyle}>App password</div>
{/* Helper text with link */}
<div style={{ ...helperTextStyle, marginBottom: 'var(--space-4, 16px)' }}>
Fastmail app password scoped to Calendars &amp; Contacts (CalDAV).{' '}
<a
href={CALDAV_LINK_HREF}
target="_blank"
rel="noopener noreferrer"
style={{
color: 'var(--color-member-0, #e8915a)',
textDecoration: 'underline',
}}
>
{CALDAV_LINK_TEXT}
</a>
</div>
{/* In-flight validating state */}
{appPasswordMutation.isPending && (
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 'var(--space-2, 8px)',
fontSize: 'var(--text-label-size, 13px)',
color: 'var(--color-text-secondary)',
marginBottom: 'var(--space-4, 16px)',
}}
>
<Loader2
size={16}
aria-hidden="true"
style={{ animation: 'spin 1s linear infinite', flexShrink: 0 }}
/>
{CALDAV_VALIDATING_TEXT}
</div>
)}
{/* Fastmail email */}
<div style={fieldContainerStyle}>
<label htmlFor="editor-fastmail-email" style={labelStyle}>
Fastmail email
</label>
<input
id="editor-fastmail-email"
type="email"
autoComplete="email"
value={fastmailEmail}
onChange={(e) => setFastmailEmail(e.target.value)}
placeholder="user@fastmail.com"
aria-describedby={appPasswordError ? 'app-password-error' : undefined}
style={inputStyle(!!appPasswordError)}
/>
</div>
{/* App password field — NEVER prefilled (T-20-07) */}
<div style={fieldContainerStyle}>
<label htmlFor="editor-app-password" style={labelStyle}>
App password
</label>
<input
id="editor-app-password"
type="password"
autoComplete="new-password"
value={appPassword}
onChange={(e) => setAppPassword(e.target.value)}
aria-describedby={appPasswordError ? 'app-password-error' : undefined}
style={inputStyle(!!appPasswordError)}
/>
</div>
{/* CalDAV error */}
{appPasswordError && (
<div id="app-password-error" style={inlineErrorStyle}>
{appPasswordError}
</div>
)}
<div style={actionsRowStyle}>
<button
type="button"
disabled={
appPasswordMutation.isPending ||
fastmailEmail.trim().length === 0 ||
appPassword.length === 0
}
onClick={() => {
setAppPasswordError(null);
appPasswordMutation.mutate();
}}
style={primaryButtonStyle(
appPasswordMutation.isPending ||
fastmailEmail.trim().length === 0 ||
appPassword.length === 0,
)}
>
{appPasswordMutation.isPending && (
<Loader2
size={14}
aria-hidden="true"
style={{ animation: 'spin 1s linear infinite', flexShrink: 0 }}
/>
)}
Save app password
</button>
</div>
</div>
</>
</>
)}
{/* ── CREATE MODE ────────────────────────────────────────────────── */}
{mode === 'create' && (
<div>
{/* Display name */}
<div style={fieldContainerStyle}>
<label htmlFor="create-display-name" style={labelStyle}>
Display name
</label>
<input
id="create-display-name"
type="text"
maxLength={256}
value={createDisplayName}
onChange={(e) => setCreateDisplayName(e.target.value)}
aria-describedby={createError ? 'create-error' : undefined}
style={inputStyle(!!createError)}
/>
</div>
{/* Username */}
<div style={fieldContainerStyle}>
<label htmlFor="create-username" style={labelStyle}>
Username
</label>
<input
id="create-username"
type="text"
maxLength={128}
autoComplete="off"
spellCheck={false}
autoCapitalize="none"
value={createUsername}
onChange={(e) => setCreateUsername(e.target.value)}
aria-describedby={createError ? 'create-error' : undefined}
style={inputStyle(!!createError)}
/>
</div>
{/* Initial password */}
<div style={fieldContainerStyle}>
<label htmlFor="create-initial-password" style={labelStyle}>
Initial password
</label>
<input
id="create-initial-password"
type="password"
autoComplete="new-password"
value={createPassword}
onChange={(e) => setCreatePassword(e.target.value)}
aria-describedby={createError ? 'create-error' : undefined}
style={inputStyle(!!createError)}
/>
</div>
{/* Confirm password */}
<div style={fieldContainerStyle}>
<label htmlFor="create-confirm-password" style={labelStyle}>
Confirm password
</label>
<input
id="create-confirm-password"
type="password"
autoComplete="new-password"
value={createConfirmPassword}
onChange={(e) => setCreateConfirmPassword(e.target.value)}
aria-describedby={createError ? 'create-error' : undefined}
style={inputStyle(!!createError)}
/>
</div>
{/* Create error */}
{createError && (
<div id="create-error" style={inlineErrorStyle}>
{createError}
</div>
)}
<div style={actionsRowStyle}>
<button type="button" onClick={handleClose} style={cancelButtonStyle}>
Cancel
</button>
<button
type="button"
disabled={
createMutation.isPending ||
createDisplayName.trim().length === 0 ||
createUsername.trim().length === 0 ||
createPassword.length === 0 ||
createConfirmPassword.length === 0
}
onClick={() => {
setCreateError(null);
createMutation.mutate();
}}
style={primaryButtonStyle(
createMutation.isPending ||
createDisplayName.trim().length === 0 ||
createUsername.trim().length === 0 ||
createPassword.length === 0 ||
createConfirmPassword.length === 0,
)}
>
{createMutation.isPending && (
<Loader2
size={14}
aria-hidden="true"
style={{ animation: 'spin 1s linear infinite', flexShrink: 0 }}
/>
)}
Add member
</button>
</div>
</div>
)}
</div>
</>
);
}
+7 -1
View File
@@ -29,7 +29,13 @@ import { usePushSubscription } from '../hooks/usePushSubscription.js';
import { useIsPhone } from '../hooks/useIsPhone.js';
import { useFocusTrap } from '../hooks/useFocusTrap.js';
import { InstructionSheet } from './InstructionSheet.js';
import { fetchMe, fetchAuthMode, fetchChangePassword, fetchLinkOidc, fetchLocalLogout } from '../api/client.js';
import {
fetchMe,
fetchAuthMode,
fetchChangePassword,
fetchLinkOidc,
fetchLocalLogout,
} from '../api/client.js';
// CR-04: fetch VAPID key (from sessionStorage cache if available) for the
// tap-gated subscribe() path. Same logic as PushPermissionPrompt.
+27 -1
View File
@@ -26,13 +26,39 @@ export function useFocusTrap(
dialogRef.current.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
),
).filter((el) => !el.hasAttribute('disabled') && el.getAttribute('tabindex') !== '-1');
).filter((el) => {
// Exclude disabled / explicitly-untabbable nodes …
if (el.hasAttribute('disabled') || el.getAttribute('tabindex') === '-1') return false;
// … and the `hidden` attribute, which is unambiguous regardless of layout.
if (el.hasAttribute('hidden')) return false;
// … and nodes that are not actually rendered/visible (WR-01). A focusable
// inside a hidden/collapsed block would otherwise become the computed
// first/last and `last.focus()` would no-op, leaking Tab to background
// content that `aria-modal="true"` promises is unreachable.
//
// Guard against a non-layout environment (jsdom): there, every node reports
// all-zero geometry and a null offsetParent, so applying the visibility
// heuristic unconditionally would reject *every* focusable and silently
// disable the trap. Only filter on visibility when there is positive
// evidence a layout engine is present; otherwise treat the node as visible.
const r = el.getBoundingClientRect();
const hasLayout = r.width > 0 || r.height > 0 || el.offsetParent !== null;
if (!hasLayout) return true; // no layout engine → don't filter on visibility
if (el.offsetParent === null) return false;
return r.width > 0 && r.height > 0;
});
if (focusable.length === 0) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
// Boundary-only trap (IN-01): this handler is wired to the dialog's own
// onKeyDown, so it only runs while focus is already inside the dialog
// subtree — a `document`-level containment guard would be required to pull
// back focus that originates outside, and is unnecessary for the current
// always-focus-the-heading-on-open flows. We intentionally do not claim a
// containment guarantee the wiring cannot provide.
if (e.shiftKey) {
// Shift+Tab: if on first element, wrap to last
if (document.activeElement === first) {
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -14,7 +14,7 @@
*/
:root,
[data-theme="light"] {
[data-theme='light'] {
/*
* BASE SURFACE / BORDER / TEXT PALETTE
* */
+6 -1
View File
@@ -42,7 +42,12 @@ export default defineConfig({
icons: [
{ src: '/icon-192.png', sizes: '192x192', type: 'image/png' },
{ src: '/icon-512.png', sizes: '512x512', type: 'image/png' },
{ src: '/icon-maskable-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' },
{
src: '/icon-maskable-512.png',
sizes: '512x512',
type: 'image/png',
purpose: 'maskable',
},
],
},
}),
-4
View File
@@ -23,7 +23,3 @@ services:
mariadb:
ports:
- '3306:3306'
redis:
ports:
- '6379:6379'
-4
View File
@@ -50,9 +50,5 @@ services:
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
# Phase 1: present but unused; Phase 4 wires pub/sub for live list sync
volumes:
mariadb_data:
+98 -97
View File
@@ -25,54 +25,54 @@ No API key or `Authorization` header is used. Credentials are never included in
## Endpoints Overview
| Method | Path | Auth | Description |
| ------ | ------------------------------------- | --------- | -------------------------------------------------- |
| GET | `/health` | None | DB liveness check |
| GET | `/callback` | None | OIDC authorization-code exchange |
| GET | `/api/setup/status` | None | Setup wizard completion status |
| POST | `/api/setup/config` | None | Store OIDC and VAPID config (wizard step 1) |
| POST | `/api/setup/validate/db` | None | Validate DB connectivity (wizard step) |
| POST | `/api/setup/validate/oidc` | None | Validate OIDC issuer discovery (wizard step) |
| POST | `/api/setup/validate/vapid` | None | Validate VAPID key pair (wizard step) |
| POST | `/api/setup/credential` | None | Store first admin's Fastmail credential (wizard) |
| POST | `/api/setup/complete` | None | Lock the setup wizard |
| GET | `/api/auth/mode` | None | Auth mode discovery (local vs OIDC enabled) |
| POST | `/api/auth/local/login` | None | Local username+password login |
| POST | `/api/auth/local/logout` | None | Clear local session cookie |
| GET | `/api/auth/local/logout` | None | Clear local session cookie (browser redirect alias)|
| GET | `/api/login` | OIDC | OIDC login entry point, redirects to `/` |
| GET | `/api/me` | Session | Current user identity, role, and setup status |
| POST | `/api/me/credential` | Session | Member self-service Fastmail credential update |
| POST | `/api/me/password` | Session | Member self-change local password |
| POST | `/api/me/link-oidc` | Session | Initiate OIDC identity link for a local user |
| GET | `/api/events` | Session | Windowed calendar occurrences |
| POST | `/api/events/create` | Session | Enqueue a new event write |
| PATCH | `/api/events/:uid/edit` | Session | Enqueue an event update |
| DELETE | `/api/events/:uid` | Session | Enqueue an event delete |
| GET | `/api/events/sync-status` | Session | Outbox status for a UID |
| GET | `/api/events/writable-calendars` | Session | Calendars the member can write to |
| GET | `/api/lists` | Session | All lists accessible to the member |
| POST | `/api/lists` | Session | Create a list |
| PATCH | `/api/lists/:id` | Session | Update list name or sharing |
| DELETE | `/api/lists/:id` | Session | Delete a list (owner only) |
| GET | `/api/lists/:id/items` | Session | All items in a list |
| POST | `/api/lists/:id/items` | Session | Add an item to a list |
| PATCH | `/api/list-items/:itemId` | Session | Update a single list item field |
| DELETE | `/api/list-items/:itemId` | Session | Delete a list item |
| GET | `/api/sse/heartbeat` | Session | SSE heartbeat stream |
| GET | `/api/sse/lists` | Session | Scoped live-list SSE stream |
| GET | `/api/push/vapid-public-key` | Session | VAPID public key for push subscribe |
| POST | `/api/push/subscription` | Session | Register a push subscription |
| DELETE | `/api/push/subscription` | Session | Remove push subscriptions for caller |
| GET | `/api/admin/members` | Admin | List members with credential status |
| POST | `/api/admin/members` | Admin | Create a new local member |
| POST | `/api/admin/members/:id/password` | Admin | Reset a local member's password |
| POST | `/api/admin/credentials` | Admin | Validate and store a member's Fastmail credential |
| GET | `/api/admin/calendars` | Admin | List synced calendars |
| PUT | `/api/admin/calendars/:id/shared` | Admin | Designate the shared family calendar |
| GET | `/api/admin/config/timezone` | Admin | Get household timezone |
| PUT | `/api/admin/config/timezone` | Admin | Set household timezone |
| POST | `/api/admin/config/timezone/seed` | Admin | Seed household timezone if not yet set |
| Method | Path | Auth | Description |
| ------ | --------------------------------- | ------- | --------------------------------------------------- |
| GET | `/health` | None | DB liveness check |
| GET | `/callback` | None | OIDC authorization-code exchange |
| GET | `/api/setup/status` | None | Setup wizard completion status |
| POST | `/api/setup/config` | None | Store OIDC and VAPID config (wizard step 1) |
| POST | `/api/setup/validate/db` | None | Validate DB connectivity (wizard step) |
| POST | `/api/setup/validate/oidc` | None | Validate OIDC issuer discovery (wizard step) |
| POST | `/api/setup/validate/vapid` | None | Validate VAPID key pair (wizard step) |
| POST | `/api/setup/credential` | None | Store first admin's Fastmail credential (wizard) |
| POST | `/api/setup/complete` | None | Lock the setup wizard |
| GET | `/api/auth/mode` | None | Auth mode discovery (local vs OIDC enabled) |
| POST | `/api/auth/local/login` | None | Local username+password login |
| POST | `/api/auth/local/logout` | None | Clear local session cookie |
| GET | `/api/auth/local/logout` | None | Clear local session cookie (browser redirect alias) |
| GET | `/api/login` | OIDC | OIDC login entry point, redirects to `/` |
| GET | `/api/me` | Session | Current user identity, role, and setup status |
| POST | `/api/me/credential` | Session | Member self-service Fastmail credential update |
| POST | `/api/me/password` | Session | Member self-change local password |
| POST | `/api/me/link-oidc` | Session | Initiate OIDC identity link for a local user |
| GET | `/api/events` | Session | Windowed calendar occurrences |
| POST | `/api/events/create` | Session | Enqueue a new event write |
| PATCH | `/api/events/:uid/edit` | Session | Enqueue an event update |
| DELETE | `/api/events/:uid` | Session | Enqueue an event delete |
| GET | `/api/events/sync-status` | Session | Outbox status for a UID |
| GET | `/api/events/writable-calendars` | Session | Calendars the member can write to |
| GET | `/api/lists` | Session | All lists accessible to the member |
| POST | `/api/lists` | Session | Create a list |
| PATCH | `/api/lists/:id` | Session | Update list name or sharing |
| DELETE | `/api/lists/:id` | Session | Delete a list (owner only) |
| GET | `/api/lists/:id/items` | Session | All items in a list |
| POST | `/api/lists/:id/items` | Session | Add an item to a list |
| PATCH | `/api/list-items/:itemId` | Session | Update a single list item field |
| DELETE | `/api/list-items/:itemId` | Session | Delete a list item |
| GET | `/api/sse/heartbeat` | Session | SSE heartbeat stream |
| GET | `/api/sse/lists` | Session | Scoped live-list SSE stream |
| GET | `/api/push/vapid-public-key` | Session | VAPID public key for push subscribe |
| POST | `/api/push/subscription` | Session | Register a push subscription |
| DELETE | `/api/push/subscription` | Session | Remove push subscriptions for caller |
| GET | `/api/admin/members` | Admin | List members with credential status |
| POST | `/api/admin/members` | Admin | Create a new local member |
| POST | `/api/admin/members/:id/password` | Admin | Reset a local member's password |
| POST | `/api/admin/credentials` | Admin | Validate and store a member's Fastmail credential |
| GET | `/api/admin/calendars` | Admin | List synced calendars |
| PUT | `/api/admin/calendars/:id/shared` | Admin | Designate the shared family calendar |
| GET | `/api/admin/config/timezone` | Admin | Get household timezone |
| PUT | `/api/admin/config/timezone` | Admin | Set household timezone |
| POST | `/api/admin/config/timezone/seed` | Admin | Seed household timezone if not yet set |
---
@@ -129,12 +129,12 @@ Stores non-secret OIDC and VAPID configuration into `app_config`. Returns `423`
}
```
| Field | Type | Required | Constraints |
| ---------------- | ------ | -------- | ------------------------------ |
| `oidcIssuer` | string | Yes | HTTPS URL |
| `oidcClientId` | string | Yes | 1256 characters |
| `vapidPublicKey` | string | Yes | 1512 characters |
| `appExternalUrl` | string | Yes | HTTPS URL, max 512 characters |
| Field | Type | Required | Constraints |
| ---------------- | ------ | -------- | ----------------------------- |
| `oidcIssuer` | string | Yes | HTTPS URL |
| `oidcClientId` | string | Yes | 1256 characters |
| `vapidPublicKey` | string | Yes | 1512 characters |
| `appExternalUrl` | string | Yes | HTTPS URL, max 512 characters |
**Response 200**
@@ -187,10 +187,10 @@ Creates the first admin user (no OIDC identity yet, `claimed: false`) and valida
}
```
| Field | Type | Required | Constraints |
| --------------- | ------ | -------- | ---------------- |
| Field | Type | Required | Constraints |
| --------------- | ------ | -------- | ------------------------------- |
| `fastmailEmail` | string | Yes | Valid email, max 256 characters |
| `appPassword` | string | Yes | 1500 characters |
| `appPassword` | string | Yes | 1500 characters |
Zod validation errors for this route never echo received values (the app password is never included in error responses).
@@ -233,6 +233,7 @@ Pre-auth endpoint. Returns which authentication methods are currently enabled. U
Validates a username + password against `local_credentials` and issues a signed `local-session` JWT cookie. Pre-auth — reachable without a session.
Rate limiting is per-username (not per-IP):
- 5 failures within 60 seconds → `429 Too Many Requests`
- 10 cumulative failures → `423 Account Locked` (auto-expires after 15 minutes or on admin password reset)
@@ -244,10 +245,10 @@ Timing-oracle defense: `verifyPassword` (scrypt) is always called, even for unkn
{ "username": "alice", "password": "hunter2" }
```
| Field | Type | Required | Constraints |
| ---------- | ------ | -------- | ---------------- |
| Field | Type | Required | Constraints |
| ---------- | ------ | -------- | -------------------------- |
| `username` | string | Yes | 1128 characters (trimmed) |
| `password` | string | Yes | 11000 characters |
| `password` | string | Yes | 11000 characters |
Zod validation errors never echo received values.
@@ -296,14 +297,14 @@ Display name is derived from OIDC claims in priority order: `name` → `preferre
}
```
| Field | Type | Description |
| -------------------- | ------- | ---------------------------------------------------------------- |
| `id` | integer | Stable member ID |
| `displayName` | string | Derived from OIDC claims or set by admin |
| `color` | string | Member's assigned color (hex) |
| `isAdmin` | boolean | Whether the member has the admin role |
| `needsProviderSetup` | boolean | True when no Fastmail credential is stored for this member |
| `hasLocalCredential` | boolean | True when a local username/password credential exists |
| Field | Type | Description |
| -------------------- | ------- | ---------------------------------------------------------- |
| `id` | integer | Stable member ID |
| `displayName` | string | Derived from OIDC claims or set by admin |
| `color` | string | Member's assigned color (hex) |
| `isAdmin` | boolean | Whether the member has the admin role |
| `needsProviderSetup` | boolean | True when no Fastmail credential is stored for this member |
| `hasLocalCredential` | boolean | True when a local username/password credential exists |
**Error responses:** `401` if the session is invalid.
@@ -323,11 +324,11 @@ Member self-service endpoint to set or rotate their own Fastmail CalDAV app pass
}
```
| Field | Type | Required | Constraints |
| --------------- | ------ | -------- | ---------------- |
| `providerType` | string | Yes | Must be `"caldav"` |
| Field | Type | Required | Constraints |
| --------------- | ------ | -------- | ------------------------------- |
| `providerType` | string | Yes | Must be `"caldav"` |
| `fastmailEmail` | string | Yes | Valid email, max 256 characters |
| `appPassword` | string | Yes | 1500 characters |
| `appPassword` | string | Yes | 1500 characters |
**Response 200** — `{ "ok": true }`
@@ -348,9 +349,9 @@ Self-service password change for local-auth members. Requires the current passwo
}
```
| Field | Type | Required | Constraints |
| ----------------- | ------ | -------- | ---------------- |
| `currentPassword` | string | Yes | 1+ characters |
| Field | Type | Required | Constraints |
| ----------------- | ------ | -------- | -------------------- |
| `currentPassword` | string | Yes | 1+ characters |
| `newPassword` | string | Yes | Minimum 8 characters |
**Response 200** — `{ "ok": true }`
@@ -942,10 +943,10 @@ Creates a new local-auth member: inserts a `users` row and a `local_credentials`
}
```
| Field | Type | Required | Constraints |
| ----------------- | ------ | -------- | ---------------- |
| `displayName` | string | Yes | 1256 characters |
| `username` | string | Yes | 1128 characters |
| Field | Type | Required | Constraints |
| ----------------- | ------ | -------- | -------------------- |
| `displayName` | string | Yes | 1256 characters |
| `username` | string | Yes | 1128 characters |
| `initialPassword` | string | Yes | Minimum 8 characters |
Zod validation errors never echo received values (the initial password is never included in error responses).
@@ -997,12 +998,12 @@ Validates and stores a Fastmail CalDAV app password for any household member. Pe
}
```
| Field | Type | Required | Constraints |
| --------------- | ------- | -------- | ---------------- |
| `userId` | integer | Yes | Positive integer |
| `providerType` | string | Yes | Must be `"caldav"` |
| Field | Type | Required | Constraints |
| --------------- | ------- | -------- | ------------------------------- |
| `userId` | integer | Yes | Positive integer |
| `providerType` | string | Yes | Must be `"caldav"` |
| `fastmailEmail` | string | Yes | Valid email, max 256 characters |
| `appPassword` | string | Yes | 1500 characters |
| `appPassword` | string | Yes | 1500 characters |
Zod validation errors and CalDAV validation failures return `400` with `{ "error": "Invalid request" }` — the app password is never echoed.
@@ -1022,7 +1023,7 @@ Lists all synced calendars with their shared-calendar designation.
{
"calendars": [
{ "id": 1, "displayName": "Personal", "isShared": false },
{ "id": 2, "displayName": "Family", "isShared": true }
{ "id": 2, "displayName": "Family", "isShared": true }
]
}
```
@@ -1065,8 +1066,8 @@ Validates and upserts the household IANA timezone into `app_config`.
{ "timezone": "America/Toronto" }
```
| Field | Type | Required | Constraints |
| ---------- | ------ | -------- | -------------------------- |
| Field | Type | Required | Constraints |
| ---------- | ------ | -------- | ------------------------------------ |
| `timezone` | string | Yes | Valid IANA timezone, 164 characters |
**Response 200** — `{ "ok": true }`
@@ -1105,17 +1106,17 @@ All error responses use a consistent JSON envelope.
{ "error": "Human-readable message" }
```
| HTTP Status | Meaning |
| ----------- | ------------------------------------------------------------------------------ |
| `400` | Invalid request parameters (e.g., malformed date window) |
| `401` | Session missing or invalid |
| HTTP Status | Meaning |
| ----------- | -------------------------------------------------------------------------------------------------------- |
| `400` | Invalid request parameters (e.g., malformed date window) |
| `401` | Session missing or invalid |
| `403` | Authenticated but not authorized (wrong owner, sharee attempted owner-only op, non-admin on admin route) |
| `404` | Resource not found |
| `409` | Conflict (e.g., duplicate username) |
| `422` | Valid request but cannot be fulfilled (e.g., user has no calendar configured) |
| `423` | Locked (setup already complete, or account locked after too many failed logins)|
| `429` | Too many requests (login rate limit exceeded for this username) |
| `503` | DB or downstream service unavailable |
| `404` | Resource not found |
| `409` | Conflict (e.g., duplicate username) |
| `422` | Valid request but cannot be fulfilled (e.g., user has no calendar configured) |
| `423` | Locked (setup already complete, or account locked after too many failed logins) |
| `429` | Too many requests (login rate limit exceeded for this username) |
| `503` | DB or downstream service unavailable |
Zod validation failures return `400` with a structured body from `@hono/zod-validator` rather than the `{ "error": "..." }` envelope. Exception: credential and password routes use a `noEchoHook` that always returns `{ "error": "Invalid request" }` to prevent echoing submitted secrets in error details.
+44 -45
View File
@@ -88,40 +88,40 @@ familysync/
### Directory Rationale
| Directory | Purpose |
| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `apps/api/src/routes/` | One file per resource — `events.ts`, `lists.ts`, `me.ts`, `push.ts`, `sse.ts`, `health.ts`, `admin.ts`, `setup.ts`, `authMode.ts`, `localAuth.ts` |
| `apps/api/src/broker/` | All CalDAV I/O: `client.ts` (tsdav factory), `sync.ts` (REPORT→DB), `poller.ts` (5-min ctag check), `outboxWorker.ts` (async write-back), `expand.ts` (RRULE expansion), `write.ts` (PUT/DELETE), `vevent.ts` (ICS builder), `crypto.ts` (AES-256-GCM for app passwords) |
| Directory | Purpose |
| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `apps/api/src/routes/` | One file per resource — `events.ts`, `lists.ts`, `me.ts`, `push.ts`, `sse.ts`, `health.ts`, `admin.ts`, `setup.ts`, `authMode.ts`, `localAuth.ts` |
| `apps/api/src/broker/` | All CalDAV I/O: `client.ts` (tsdav factory), `sync.ts` (REPORT→DB), `poller.ts` (5-min ctag check), `outboxWorker.ts` (async write-back), `expand.ts` (RRULE expansion), `write.ts` (PUT/DELETE), `vevent.ts` (ICS builder), `crypto.ts` (AES-256-GCM for app passwords) |
| `apps/api/src/auth/` | `middleware.ts` (re-exports `@hono/oidc-auth`), `devBypass.ts` (DEV_AUTH_BYPASS inject), `localAuthMiddleware.ts` (local-session cookie → user), `localCredentials.ts` (scrypt hash/verify), `localSession.ts` (JWT cookie issue/verify/clear), `oidcConfig.ts` (env+DB fallback for OIDC config), `linkNonceStore.ts` (single-use OIDC-link nonces), `linkOidc.ts` (bind OIDC identity to local user), `persistSessionCookie.ts` (session lifetime extension), `user.ts` (upsert on first OIDC login) |
| `apps/api/src/db/` | `schema.ts` (Drizzle `mysqlTable` definitions), `client.ts` (mysql2 pool), `migrations/` (drizzle-kit output) |
| `apps/api/src/lib/` | Stateless helpers: `listEmitter.ts` (EventEmitter fan-out), `listChangeDispatcher.ts`, `eventChangeDispatcher.ts`, `pushDispatcher.ts` (VAPID send), `pushCoalescer.ts`, `listAccess.ts`, `rank.ts` (fractional indexing), `bootGuards.ts` (startup safety assertions), `requireAdmin.ts` (admin-role guard), `setupGuard.ts` (isSetupLocked check) |
| `apps/pwa/src/api/` | Thin typed fetch wrappers — `client.ts` (events, me, sync-status, auth-mode, local login/logout), `listsClient.ts` (lists and items) |
| `apps/pwa/src/store/` | `calendarStore.ts` and `listsStore.ts` — Zustand UI-only state (no server data) |
| `apps/pwa/src/hooks/` | `useListSSE.ts` (bounded-backoff EventSource), `usePushSubscription.ts` (VAPID subscribe) |
| `apps/api/src/db/` | `schema.ts` (Drizzle `mysqlTable` definitions), `client.ts` (mysql2 pool), `migrations/` (drizzle-kit output) |
| `apps/api/src/lib/` | Stateless helpers: `listEmitter.ts` (EventEmitter fan-out), `listChangeDispatcher.ts`, `eventChangeDispatcher.ts`, `pushDispatcher.ts` (VAPID send), `pushCoalescer.ts`, `listAccess.ts`, `rank.ts` (fractional indexing), `bootGuards.ts` (startup safety assertions), `requireAdmin.ts` (admin-role guard), `setupGuard.ts` (isSetupLocked check) |
| `apps/pwa/src/api/` | Thin typed fetch wrappers — `client.ts` (events, me, sync-status, auth-mode, local login/logout), `listsClient.ts` (lists and items) |
| `apps/pwa/src/store/` | `calendarStore.ts` and `listsStore.ts` — Zustand UI-only state (no server data) |
| `apps/pwa/src/hooks/` | `useListSSE.ts` (bounded-backoff EventSource), `usePushSubscription.ts` (VAPID subscribe) |
---
## Key Abstractions
| Abstraction | File | Description |
| ------------------------------------------ | ------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `app` (Hono) | `apps/api/src/index.ts` | Root Hono app; mounts all routes and serves the PWA static build |
| Drizzle schema | `apps/api/src/db/schema.ts` | Single source of truth for all table definitions (`users`, `memberCredentials`, `localCredentials`, `calendars`, `calendarEvents`, `calendarOutbox`, `lists`, `listShares`, `listItems`, `pushSubscriptions`, `appConfig`) |
| `syncCalendar` | `apps/api/src/broker/sync.ts` | REPORT → ical.js parse → `onDuplicateKeyUpdate` upsert into MariaDB |
| `expandOccurrences` | `apps/api/src/broker/expand.ts` | Server-side RRULE expansion using `ical.js` + `rrule`; never runs in the browser |
| `CalendarOccurrence` | `apps/api/src/broker/expand.ts` | Wire type for a single concrete event occurrence; mirrored in the PWA's `api/client.ts` |
| `calendarOutbox` table | `apps/api/src/db/schema.ts` | Transactional outbox pattern — CalDAV writes are enqueued here and drained asynchronously |
| `runOutboxDrain` | `apps/api/src/broker/outboxWorker.ts` | Drains pending outbox rows every 15s; handles retry backoff, 412 conflict, dead-lettering, and edit-as-move ordering |
| `publishListEvent` / `subscribeListEvents` | `apps/api/src/lib/listEmitter.ts` | In-process EventEmitter fan-out keyed per list; SSE route subscribes on open and unsubscribes on disconnect |
| `dispatchPush` | `apps/api/src/lib/pushDispatcher.ts` | Centralised VAPID-signed push sender; handles 410/404 subscription pruning |
| `issueLocalSessionCookie` / `verifyLocalSessionCookie` | `apps/api/src/auth/localSession.ts` | Issues and verifies the `local-session` JWT cookie used by local username/password auth |
| `localAuthMiddleware` | `apps/api/src/auth/localAuthMiddleware.ts` | Reads `local-session` cookie → populates `c.get('user')`; no-op passthrough when cookie absent (OIDC guard fires for unauthenticated requests) |
| `linkOidcToUser` / `OidcLinkConflictError` | `apps/api/src/auth/linkOidc.ts` | Binds an OIDC iss+sub to an existing local user; throws `OidcLinkConflictError` on identity collision |
| `localCredentials` table | `apps/api/src/db/schema.ts` | Per-member local login credentials (scrypt PHC hash); a row exists iff the member can log in with username/password |
| `appConfig` table | `apps/api/src/db/schema.ts` | Key/value store for setup wizard output (OIDC config, VAPID public key, setup_complete flag) |
| `isSetupLocked` | `apps/api/src/lib/setupGuard.ts` | Returns true when the first-run wizard is complete; setup mutation routes call this as their first guard |
| `SessionExpiredError` | `apps/pwa/src/api/client.ts` | Typed error thrown by all fetch wrappers on 401/opaqueredirect; global `QueryCache` handler arms the session-expiry interstitial |
| Zustand stores | `apps/pwa/src/store/` | UI-only ephemeral state (open panels, selected date, active tab); server state always in TanStack Query |
| Abstraction | File | Description |
| ------------------------------------------------------ | ------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `app` (Hono) | `apps/api/src/index.ts` | Root Hono app; mounts all routes and serves the PWA static build |
| Drizzle schema | `apps/api/src/db/schema.ts` | Single source of truth for all table definitions (`users`, `memberCredentials`, `localCredentials`, `calendars`, `calendarEvents`, `calendarOutbox`, `lists`, `listShares`, `listItems`, `pushSubscriptions`, `appConfig`) |
| `syncCalendar` | `apps/api/src/broker/sync.ts` | REPORT → ical.js parse → `onDuplicateKeyUpdate` upsert into MariaDB |
| `expandOccurrences` | `apps/api/src/broker/expand.ts` | Server-side RRULE expansion using `ical.js` + `rrule`; never runs in the browser |
| `CalendarOccurrence` | `apps/api/src/broker/expand.ts` | Wire type for a single concrete event occurrence; mirrored in the PWA's `api/client.ts` |
| `calendarOutbox` table | `apps/api/src/db/schema.ts` | Transactional outbox pattern — CalDAV writes are enqueued here and drained asynchronously |
| `runOutboxDrain` | `apps/api/src/broker/outboxWorker.ts` | Drains pending outbox rows every 15s; handles retry backoff, 412 conflict, dead-lettering, and edit-as-move ordering |
| `publishListEvent` / `subscribeListEvents` | `apps/api/src/lib/listEmitter.ts` | In-process EventEmitter fan-out keyed per list; SSE route subscribes on open and unsubscribes on disconnect |
| `dispatchPush` | `apps/api/src/lib/pushDispatcher.ts` | Centralised VAPID-signed push sender; handles 410/404 subscription pruning |
| `issueLocalSessionCookie` / `verifyLocalSessionCookie` | `apps/api/src/auth/localSession.ts` | Issues and verifies the `local-session` JWT cookie used by local username/password auth |
| `localAuthMiddleware` | `apps/api/src/auth/localAuthMiddleware.ts` | Reads `local-session` cookie → populates `c.get('user')`; no-op passthrough when cookie absent (OIDC guard fires for unauthenticated requests) |
| `linkOidcToUser` / `OidcLinkConflictError` | `apps/api/src/auth/linkOidc.ts` | Binds an OIDC iss+sub to an existing local user; throws `OidcLinkConflictError` on identity collision |
| `localCredentials` table | `apps/api/src/db/schema.ts` | Per-member local login credentials (scrypt PHC hash); a row exists iff the member can log in with username/password |
| `appConfig` table | `apps/api/src/db/schema.ts` | Key/value store for setup wizard output (OIDC config, VAPID public key, setup_complete flag) |
| `isSetupLocked` | `apps/api/src/lib/setupGuard.ts` | Returns true when the first-run wizard is complete; setup mutation routes call this as their first guard |
| `SessionExpiredError` | `apps/pwa/src/api/client.ts` | Typed error thrown by all fetch wrappers on 401/opaqueredirect; global `QueryCache` handler arms the session-expiry interstitial |
| Zustand stores | `apps/pwa/src/store/` | UI-only ephemeral state (open panels, selected date, active tab); server state always in TanStack Query |
---
@@ -234,21 +234,20 @@ routes/setup.ts ──→ db (app_config)
## Infrastructure
| Component | Technology |
| -------------------- | ------------------------------------------------------------------------------------------------------------------- |
| Runtime | Node.js 22 LTS |
| HTTP framework | Hono 4.x (`@hono/node-server`) |
| Database | MariaDB 11 (Docker volume) |
| ORM | Drizzle ORM 0.45.x (`mysql2` dialect) |
| Auth IdP | Authelia (pre-deployed, external) — OIDC authorization code + PKCE; optional when local auth is enabled |
| Component | Technology |
| -------------------- | --------------------------------------------------------------------------------------------------------------------------- |
| Runtime | Node.js 22 LTS |
| HTTP framework | Hono 4.x (`@hono/node-server`) |
| Database | MariaDB 11 (Docker volume) |
| ORM | Drizzle ORM 0.45.x (`mysql2` dialect) |
| Auth IdP | Authelia (pre-deployed, external) — OIDC authorization code + PKCE; optional when local auth is enabled |
| Session middleware | `@hono/oidc-auth` (OIDC session — storage-less signed JWT cookies) + custom `localSession.ts` (local-auth HS256 JWT cookie) |
| Calendar source | Fastmail CalDAV (`caldav.fastmail.com`) — read via `tsdav`, write via transactional outbox |
| Calendar parsing | `ical.js` (VCALENDAR/VEVENT parse) + `rrule` (RRULE expansion) |
| App password storage | AES-256-GCM encrypted in `member_credentials.encrypted_password` |
| Local auth storage | scrypt PHC hash in `local_credentials.password_hash`; session signed with `LOCAL_SESSION_SECRET` env var |
| Push notifications | `web-push` (VAPID) → APNs (iOS) / FCM (Android) |
| Live list sync | In-process Node.js `EventEmitter` → SSE (`text/event-stream`) |
| Redis | Present in stack (image: `redis:7-alpine`); not used in current runtime (reserved for future multi-process pub/sub) |
| PWA | React 19 + Vite 8 + `vite-plugin-pwa` (Workbox `injectManifest` mode) |
| Networking | Pangolin/Newt tunnel — no open ports; split-DNS internal domain |
| Deployment | Docker Compose on Unraid; single `api` container serves both the API and the PWA static build |
| Calendar source | Fastmail CalDAV (`caldav.fastmail.com`) — read via `tsdav`, write via transactional outbox |
| Calendar parsing | `ical.js` (VCALENDAR/VEVENT parse) + `rrule` (RRULE expansion) |
| App password storage | AES-256-GCM encrypted in `member_credentials.encrypted_password` |
| Local auth storage | scrypt PHC hash in `local_credentials.password_hash`; session signed with `LOCAL_SESSION_SECRET` env var |
| Push notifications | `web-push` (VAPID) → APNs (iOS) / FCM (Android) |
| Live list sync | In-process Node.js `EventEmitter` → SSE (`text/event-stream`) |
| PWA | React 19 + Vite 8 + `vite-plugin-pwa` (Workbox `injectManifest` mode) |
| Networking | Pangolin/Newt tunnel — no open ports; split-DNS internal domain |
| Deployment | Docker Compose on Unraid; single `api` container serves both the API and the PWA static build |
+21 -21
View File
@@ -10,14 +10,14 @@ All runtime configuration is supplied via environment variables. There are no JS
### Database
| Variable | Required | Default | Description |
| ------------------ | ------------ | ------------ | ------------------------------------------------------------------------------------------------------------------------ |
| `DB_HOST` | Yes | `localhost` | MariaDB hostname. Use `mariadb` inside Docker Compose; use `localhost` (or `127.0.0.1`) for host-side dev runs. |
| `DB_PORT` | No | `3306` | MariaDB port. |
| `DB_USER` | No | `familysync` | Database user. |
| `DB_PASSWORD` | **Required** | _(none)_ | Database password. Also used by the `mariadb` service as `MARIADB_PASSWORD`. |
| `DB_NAME` | No | `familysync` | Database name. |
| `DB_ROOT_PASSWORD` | **Required** | _(none)_ | MariaDB root password. Used only by the `mariadb` Docker service (`MARIADB_ROOT_PASSWORD`). Not read by the API process. |
| Variable | Required | Default | Description |
| ------------------ | ------------ | ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `DB_HOST` | Yes | `localhost` | MariaDB hostname. Use `mariadb` inside Docker Compose; use `localhost` (or `127.0.0.1`) for host-side dev runs. |
| `DB_PORT` | No | `3306` | MariaDB port. |
| `DB_USER` | No | `familysync` | Database user. |
| `DB_PASSWORD` | **Required** | _(none)_ | Database password. Also used by the `mariadb` service as `MARIADB_PASSWORD`. |
| `DB_NAME` | No | `familysync` | Database name. |
| `DB_ROOT_PASSWORD` | **Required** | _(none)_ | MariaDB root password. Used only by the `mariadb` Docker service (`MARIADB_ROOT_PASSWORD`). Not read by the API process. |
| `DB_ROOT_USER` | No | `root` | MariaDB root username. Read only by `apps/api/test/global-setup.ts` during local test provisioning. Never used by the API or Docker Compose in production. |
Five of these variables — `DB_HOST`, `DB_USER`, `DB_PASSWORD`, `DB_NAME`, `DB_PORT` — are read by `drizzle.config.ts` when running migrations (`db:generate` / `db:migrate`) and by the API process to build its connection pool. `DB_ROOT_PASSWORD` is **not** read by either; it is consumed only by the `mariadb` Docker service. `DB_ROOT_USER` is only used by the local Vitest global setup to create and grant the `familysync_test` database.
@@ -55,10 +55,10 @@ Five of these variables — `DB_HOST`, `DB_USER`, `DB_PASSWORD`, `DB_NAME`, `DB_
These variables govern the stateless local-auth path introduced in Phase 19. Local auth issues a separate `local-session` JWT cookie (distinct from `oidc-auth`) signed with `LOCAL_SESSION_SECRET`.
| Variable | Required | Default | Description |
| ----------------------- | ------------------------ | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Variable | Required | Default | Description |
| ----------------------- | ------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `LOCAL_SESSION_SECRET` | **Required** (non-bypass) | _(none)_ | 32+ character secret used to sign and verify `local-session` JWT cookies (HS256). Generate with `openssl rand -base64 32`. The API refuses to start with a fatal error if this is absent or shorter than 32 characters, unless `DEV_AUTH_BYPASS=true`. |
| `LOCAL_SESSION_EXPIRES` | No | `86400` | `local-session` cookie `Max-Age` in seconds (default 1 day). Mirrors `OIDC_AUTH_EXPIRES` but applies to the local-auth cookie. Malformed (non-numeric) values silently fall back to the default. Source: `apps/api/src/auth/localSession.ts`. |
| `LOCAL_SESSION_EXPIRES` | No | `86400` | `local-session` cookie `Max-Age` in seconds (default 1 day). Mirrors `OIDC_AUTH_EXPIRES` but applies to the local-auth cookie. Malformed (non-numeric) values silently fall back to the default. Source: `apps/api/src/auth/localSession.ts`. |
**Security note:** `LOCAL_SESSION_SECRET` must be a distinct value from `OIDC_AUTH_SECRET`. Both are JWT signing keys, but they govern different cookies and must not be shared.
@@ -90,10 +90,10 @@ npx web-push generate-vapid-keys --json
### Runtime Mode
| Variable | Required | Default | Description |
| ----------------- | -------- | ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `NODE_ENV` | No | _(not set)_ | Set to `production` in the production Docker Compose. When `production`, the dev-auth bypass is unconditionally disabled regardless of `DEV_AUTH_BYPASS`. |
| `DEV_AUTH_BYPASS` | No | _(not set)_ | Set to `true` to bypass OIDC authentication for local development without a live Authelia instance. **Only active when `NODE_ENV !== 'production'`.** The production `docker-compose.yml` must never include this variable. |
| Variable | Required | Default | Description |
| ----------------- | -------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `NODE_ENV` | No | _(not set)_ | Set to `production` in the production Docker Compose. When `production`, the dev-auth bypass is unconditionally disabled regardless of `DEV_AUTH_BYPASS`. |
| `DEV_AUTH_BYPASS` | No | _(not set)_ | Set to `true` to bypass OIDC authentication for local development without a live Authelia instance. **Only active when `NODE_ENV !== 'production'`.** The production `docker-compose.yml` must never include this variable. |
| `TZ` | No | _(not set)_ | IANA timezone identifier (e.g. `America/Toronto`) used as the server-side fallback for the household timezone when no value is stored in `app_config`. The full fallback chain is: stored DB value → `TZ` env → `Intl.DateTimeFormat().resolvedOptions().timeZone`. Empty or whitespace values are ignored. Source: `apps/api/src/lib/householdTimezone.ts`. |
---
@@ -102,11 +102,11 @@ npx web-push generate-vapid-keys --json
These variables are never needed in production and should not appear in the production `.env`.
| Variable | Scope | Default | Description |
| ----------------------- | ---------------------- | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `FASTMAIL_EMAIL` | Dev spike script only | _(none)_ | Fastmail account email. Read only by `apps/api/src/broker/spike.ts`, a standalone dev script for enumerating CalDAV collections. Not imported by the API or Docker image. |
| `FASTMAIL_APP_PASSWORD` | Dev spike script only | _(none)_ | Fastmail app password. Read only by `apps/api/src/broker/spike.ts`. **Never logged.** Not used by the API in any environment. |
| `PLAYWRIGHT_BASE_URL` | E2E tests only | `http://localhost:5173` | Base URL for Playwright e2e tests. Overridden to `http://127.0.0.1:5173` in CI to avoid IPv6 resolution failures. Source: `apps/pwa/playwright.config.ts`. |
| Variable | Scope | Default | Description |
| ----------------------- | --------------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `FASTMAIL_EMAIL` | Dev spike script only | _(none)_ | Fastmail account email. Read only by `apps/api/src/broker/spike.ts`, a standalone dev script for enumerating CalDAV collections. Not imported by the API or Docker image. |
| `FASTMAIL_APP_PASSWORD` | Dev spike script only | _(none)_ | Fastmail app password. Read only by `apps/api/src/broker/spike.ts`. **Never logged.** Not used by the API in any environment. |
| `PLAYWRIGHT_BASE_URL` | E2E tests only | `http://localhost:5173` | Base URL for Playwright e2e tests. Overridden to `http://127.0.0.1:5173` in CI to avoid IPv6 resolution failures. Source: `apps/pwa/playwright.config.ts`. |
---
@@ -163,7 +163,7 @@ VAPID_SUBJECT=mailto:admin@example.com
### Local Development (host-side)
The dev Docker Compose override (`docker-compose.dev.yml`) exposes MariaDB on `localhost:3306` and Redis on `localhost:6379`. To run the API and PWA directly on the host:
The dev Docker Compose override (`docker-compose.dev.yml`) exposes MariaDB on `localhost:3306`. To run the API and PWA directly on the host:
```bash
# Build the API first (dev script runs compiled output)
+44 -30
View File
@@ -35,7 +35,7 @@ src/
- **Node.js 22 LTS** — the Dockerfile base is `node:22-alpine`; match this locally
- **pnpm 11.5.1** — managed via corepack (`corepack enable pnpm`)
- **Docker + Docker Compose** — for MariaDB and Redis in dev
- **Docker + Docker Compose** — for MariaDB in dev
- **TypeScript 5.x** — installed per-workspace as a dev dependency
## Local Setup
@@ -48,13 +48,13 @@ pnpm install
This installs all workspace packages (`apps/api` and `apps/pwa`) in a single pass.
### 2. Start the dev database and Redis
### 2. Start the dev database
```bash
docker compose -f docker-compose.yml -f docker-compose.dev.yml up mariadb redis -d
docker compose -f docker-compose.yml -f docker-compose.dev.yml up mariadb -d
```
The dev override (`docker-compose.dev.yml`) exposes MariaDB on `localhost:3306` and Redis on `localhost:6379`.
The dev override (`docker-compose.dev.yml`) exposes MariaDB on `localhost:3306`.
### 3. Configure environment variables
@@ -100,19 +100,19 @@ Vite serves the PWA with HMR on the configured dev port. The PWA's API calls tar
### Root workspace scripts
| Command | Description |
| ------------------------ | ------------------------------------------------------------- |
| `pnpm dev:api` | Start API dev watcher (`node --watch dist/index.js`) |
| `pnpm dev:pwa` | Start Vite dev server for the PWA |
| `pnpm build` | Build both `apps/api` (tsc) and `apps/pwa` (tsc + vite build) |
| `pnpm test` | Run API test suite (`vitest run` in `apps/api`) |
| `pnpm test:e2e` | Run Playwright e2e harness (`apps/pwa`) |
| `pnpm lint` | ESLint across all workspaces (`pnpm -r --if-present lint`) |
| `pnpm format` | Reformat all files with Prettier (`prettier --write .`) |
| `pnpm format:check` | Check formatting without writing (`prettier --check .`) |
| `pnpm typecheck` | `tsc --noEmit` in all workspaces |
| `pnpm md:lint` | Markdown lint (`markdownlint-cli2`) across the repo |
| `pnpm generate-secrets` | Generate VAPID and session secret values via `scripts/generate-secrets.mjs` |
| Command | Description |
| ----------------------- | --------------------------------------------------------------------------- |
| `pnpm dev:api` | Start API dev watcher (`node --watch dist/index.js`) |
| `pnpm dev:pwa` | Start Vite dev server for the PWA |
| `pnpm build` | Build both `apps/api` (tsc) and `apps/pwa` (tsc + vite build) |
| `pnpm test` | Run API test suite (`vitest run` in `apps/api`) |
| `pnpm test:e2e` | Run Playwright e2e harness (`apps/pwa`) |
| `pnpm lint` | ESLint across all workspaces (`pnpm -r --if-present lint`) |
| `pnpm format` | Reformat all files with Prettier (`prettier --write .`) |
| `pnpm format:check` | Check formatting without writing (`prettier --check .`) |
| `pnpm typecheck` | `tsc --noEmit` in all workspaces |
| `pnpm md:lint` | Markdown lint (`markdownlint-cli2`) across the repo |
| `pnpm generate-secrets` | Generate VAPID and session secret values via `scripts/generate-secrets.mjs` |
### `apps/api` scripts
@@ -157,9 +157,9 @@ pnpm md:lint # Markdown lint (also runs in CI fast-checks)
Config: `eslint.config.js` (root, flat ESLint 9 format). The config covers:
- **All `apps/**/*.{ts,tsx}`** — `js.configs.recommended` + `tseslint.configs.recommendedTypeChecked` with `projectService: true` (type-aware rules, auto-discovers all `tsconfig.json` files)
- **`apps/pwa/**/*.{ts,tsx}` additionally** — `eslint-plugin-react` + `eslint-plugin-react-hooks` (React 19 flat config; React Compiler rules disabled — this codebase does not use the Compiler)
- **All `apps/**/*.{ts,tsx}`** — `eslint-plugin-security` (14 of 15 rules at error; `detect-object-injection` disabled due to high false-positive rate on schema-derived numeric keys)
- **All `apps/**/\*.{ts,tsx}`** — `js.configs.recommended`+`tseslint.configs.recommendedTypeChecked`with`projectService: true`(type-aware rules, auto-discovers all`tsconfig.json` files)
- **`apps/pwa/**/\*.{ts,tsx}`additionally** —`eslint-plugin-react`+`eslint-plugin-react-hooks` (React 19 flat config; React Compiler rules disabled — this codebase does not use the Compiler)
- **All `apps/**/\*.{ts,tsx}`** — `eslint-plugin-security`(14 of 15 rules at error;`detect-object-injection` disabled due to high false-positive rate on schema-derived numeric keys)
- **Tool configs + test dirs** (`drizzle.config.ts`, `vitest.config.ts`, `apps/api/tests/**`, `apps/pwa/e2e/**`) — type-aware rules disabled via `disableTypeChecked` (these files are outside the main tsconfig projects)
- **Prettier integration**`eslint-config-prettier` last in the config disables all formatting rules that conflict with Prettier
@@ -195,16 +195,31 @@ Run `pnpm typecheck` before opening a PR to catch errors that vitest and Vite bu
Every PR to `main` runs through `.gitea/workflows/ci.yml`. A `changes` path-filter job determines whether code files changed; the `api` and `harness` jobs are skipped entirely for doc-only PRs (changes only to `.planning/**`, `.gitea/**`, or `*.md` files).
| Job | Runs on | Checks |
| ------------- | ------------------- | ----------------------------------------------------------------------------------------------------------- |
| `fast-checks` | Every PR | `pnpm lint``pnpm format:check``pnpm md:lint``pnpm typecheck``pnpm --filter @familysync/pwa test` |
| `api` | Code-change PRs only | DB migrations + `pnpm --filter @familysync/api test` (vitest against a MariaDB 11 service container) |
| `harness` | Code-change PRs only | DB migrations + seed dev user + API build + Playwright e2e (WebKit + Chromium) with `DEV_AUTH_BYPASS=true` |
| `security` | Every PR | Gitleaks secret scan (PR diff); `pnpm audit` (High+Critical blocking) + outdated report on code-change PRs |
| `gate` | Always | Final aggregator — requires `fast-checks` and `security` to succeed; `api` and `harness` may be skipped |
| Job | Runs on | Checks |
| ------------- | -------------------- | ------------------------------------------------------------------------------------------------------------ |
| `fast-checks` | Every PR | `pnpm lint``pnpm format:check``pnpm md:lint``pnpm typecheck``pnpm --filter @familysync/pwa test` |
| `api` | Code-change PRs only | DB migrations + `pnpm --filter @familysync/api test` (vitest against a MariaDB 11 service container) |
| `harness` | Code-change PRs only | DB migrations + seed dev user + API build + Playwright e2e (WebKit + Chromium) with `DEV_AUTH_BYPASS=true` |
| `security` | Every PR | Gitleaks secret scan (PR diff); `pnpm audit` (High+Critical blocking) + outdated report on code-change PRs |
| `gate` | Always | Final aggregator — requires `fast-checks` and `security` to succeed; `api` and `harness` may be skipped |
All five jobs must pass (or be legitimately skipped) before a PR can merge. See [docs/TESTING.md](TESTING.md) for test suite details.
### CI dependency caches
CI uses two persistent cache paths inside job containers:
| Path | Content |
| ---------------- | ----------------------------------------------------------------------- |
| `/pnpm-store` | pnpm content-addressable store (`--store-dir /pnpm-store`) |
| `/ms-playwright` | Playwright browser binaries (`PLAYWRIGHT_BROWSERS_PATH=/ms-playwright`) |
These paths must be bind-mounted from host directories in the act_runner `config.yaml`
`container.options` field — that is a **host-side change, not tracked in this repo**. Without the
mounts, CI still works correctly — pnpm creates an ephemeral store at `/pnpm-store` inside the
container and Playwright downloads browsers fresh each run. The mounts only eliminate repeat
downloads across runs.
## Drizzle Migration Workflow
Schema changes follow a strict two-step process. **`drizzle-kit push` is not available** — it has been removed from the scripts because it emits a false destructive diff (table truncation) on populated MariaDB databases.
@@ -238,17 +253,16 @@ Migration files live in `apps/api/src/db/migrations/` and are committed to versi
## Docker Compose Dev Stack
```bash
# Bring up the full dev stack (API in Docker + MariaDB + Redis, with ports exposed)
# Bring up the full dev stack (API in Docker + MariaDB, with ports exposed)
docker compose -f docker-compose.yml -f docker-compose.dev.yml up
# Bring up only backing services (run API on host for faster iteration)
docker compose -f docker-compose.yml -f docker-compose.dev.yml up mariadb redis -d
docker compose -f docker-compose.yml -f docker-compose.dev.yml up mariadb -d
```
The dev override:
- Exposes MariaDB on `localhost:3306`
- Exposes Redis on `localhost:6379`
- Mounts `apps/api/src` into the container for live source access
- Sets `NODE_ENV=development`
+4 -3
View File
@@ -12,7 +12,7 @@ This guide walks from a fresh clone to a running local development environment.
| ----------------------- | ------------------ | ---------------------------------------------------------------------------------- |
| Node.js | `22 LTS` | Matches the `node:22-alpine` base in `apps/api/Dockerfile` |
| pnpm | `11.5.1` | Pinned in `package.json` `packageManager` field; enable via `corepack enable pnpm` |
| Docker + Docker Compose | Any recent version | Used to run MariaDB and Redis locally |
| Docker + Docker Compose | Any recent version | Used to run MariaDB locally |
**Node version management:** If you use nvm or fnm, install Node 22 LTS and set it as the default before continuing. There is no `.nvmrc` in the repo; the target version comes from the Dockerfile.
@@ -72,10 +72,10 @@ Open `.env` and fill in the required values. See [docs/CONFIGURATION.md](CONFIGU
### 5. Start the database services
```bash
docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d mariadb redis
docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d mariadb
```
This starts MariaDB (bound to `localhost:3306`) and Redis (`localhost:6379`) using the dev override. Wait for MariaDB to pass its health check before proceeding.
This starts MariaDB (bound to `localhost:3306`) using the dev override. Wait for MariaDB to pass its health check before proceeding.
### 6. Run database migrations
@@ -134,6 +134,7 @@ Or set `DB_HOST=localhost` directly in your `.env` for host-side dev.
**`[FATAL] LOCAL_SESSION_SECRET is not set or is shorter than 32 characters`**
The API refuses to start in non-bypass mode without a valid `LOCAL_SESSION_SECRET`. Either:
- Set `DEV_AUTH_BYPASS=true` in `.env` for local dev (bypass mode exempts the requirement), or
- Run `pnpm generate-secrets` and add the generated `LOCAL_SESSION_SECRET` value to `.env`.
+26 -26
View File
@@ -6,10 +6,10 @@
Both apps use **Vitest** (`^4.1.8`).
| App | Environment | Global setup | Per-file setup |
| ---------- | ----------- | ----------------------------------- | ---------------------------- |
| `apps/api` | `node` | `apps/api/test/global-setup.ts` | `apps/api/test/setup.ts` |
| `apps/pwa` | `jsdom` | — | `apps/pwa/src/test-setup.ts` |
| App | Environment | Global setup | Per-file setup |
| ---------- | ----------- | ------------------------------- | ---------------------------- |
| `apps/api` | `node` | `apps/api/test/global-setup.ts` | `apps/api/test/setup.ts` |
| `apps/pwa` | `jsdom` | — | `apps/pwa/src/test-setup.ts` |
**apps/api global setup** (`test/global-setup.ts`) runs once before any test file. Locally it provisions an isolated `familysync_test` database (root connection → `CREATE DATABASE IF NOT EXISTS familysync_test` → GRANT → `drizzle migrate`) and then truncates every table to give each run a clean slate. Under CI (`process.env.CI` truthy) it returns immediately — the CI `api` job provisions its own `familysync` service container via `db:migrate`.
@@ -53,11 +53,11 @@ pnpm --filter @familysync/api exec vitest run tests/routes/lists.test.ts
The PWA has a Playwright harness configured in `apps/pwa/playwright.config.ts` with three device profiles:
| Profile | Viewport | Engine | User-Agent |
| --------- | --------- | -------- | ------------------------- |
| `iphone` | 390×844 | WebKit | Mobile Safari (iPhone 14) |
| `pixel` | 412×915 | Chromium | Chrome Android (Pixel 7) |
| `desktop` | 1280×720 | Chromium | Desktop Chrome |
| Profile | Viewport | Engine | User-Agent |
| --------- | -------- | -------- | ------------------------- |
| `iphone` | 390×844 | WebKit | Mobile Safari (iPhone 14) |
| `pixel` | 412×915 | Chromium | Chrome Android (Pixel 7) |
| `desktop` | 1280×720 | Chromium | Desktop Chrome |
All profiles block the service worker (`serviceWorkers: 'block'`) so the Workbox SW does not intercept requests during tests. Auth is handled via `DEV_AUTH_BYPASS=true` on the API — never via stored browser state.
@@ -89,7 +89,7 @@ pnpm --filter @familysync/pwa test:e2e:ui
pnpm --filter @familysync/pwa test:e2e:headed
```
The `baseURL` is driven by `PLAYWRIGHT_BASE_URL` (default: `http://localhost:5173`). In local mode the config reuses a running Vite dev server; in CI it starts Vite itself. The API, MariaDB, and Redis must already be running via Docker Compose before launching e2e tests locally — see `docs/DEVELOPMENT.md`.
The `baseURL` is driven by `PLAYWRIGHT_BASE_URL` (default: `http://localhost:5173`). In local mode the config reuses a running Vite dev server; in CI it starts Vite itself. The API and MariaDB must already be running via Docker Compose before launching e2e tests locally — see `docs/DEVELOPMENT.md`.
### Type checking (separate from tests — required)
@@ -232,17 +232,17 @@ The throwaway credentials (`DB_USER=familysync`, `DB_PASSWORD=testpass`) are sco
Runs the Playwright mobile and desktop e2e harness (iphone + pixel + desktop) against a runner-hosted dev stack. Skipped for doc-only PRs.
| Step | Detail |
| ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
| MariaDB service | Same `mariadb:11` setup as the `api` job |
| Schema migrations | `pnpm --filter @familysync/api db:migrate` |
| Dev user seed | Inserts `users` row id=1 (`INSERT IGNORE`) for `DEV_AUTH_BYPASS` |
| Local credentials seed | Inserts `local_credentials` row for dev user (username: `devuser`, password: `devpass`) via inline scrypt hash — Phase 19 requirement |
| API build | `pnpm --filter @familysync/api build` (dist/ is gitignored) |
| Playwright install | `npx playwright install --with-deps webkit chromium` (no cache) |
| API start + tests | API started as a background process in the same step as `playwright test` to survive the step boundary; `DEV_AUTH_BYPASS=true`, `NODE_ENV=development` |
| Base URL | `http://127.0.0.1:5173` (not `localhost` — runner resolves `localhost` to `::1` but Vite binds IPv4-only) |
| Artifacts on fail | Traces, screenshots, videos, and HTML report uploaded via `ChristopherHX/gitea-upload-artifact@v4` (standard `upload-artifact` aborts on Gitea) |
| Step | Detail |
| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
| MariaDB service | Same `mariadb:11` setup as the `api` job |
| Schema migrations | `pnpm --filter @familysync/api db:migrate` |
| Dev user seed | Inserts `users` row id=1 (`INSERT IGNORE`) for `DEV_AUTH_BYPASS` |
| Local credentials seed | Inserts `local_credentials` row for dev user (username: `devuser`, password: `devpass`) via inline scrypt hash — Phase 19 requirement |
| API build | `pnpm --filter @familysync/api build` (dist/ is gitignored) |
| Playwright install | `npx playwright install --with-deps webkit chromium` (no cache) |
| API start + tests | API started as a background process in the same step as `playwright test` to survive the step boundary; `DEV_AUTH_BYPASS=true`, `NODE_ENV=development` |
| Base URL | `http://127.0.0.1:5173` (not `localhost` — runner resolves `localhost` to `::1` but Vite binds IPv4-only) |
| Artifacts on fail | Traces, screenshots, videos, and HTML report uploaded via `ChristopherHX/gitea-upload-artifact@v4` (standard `upload-artifact` aborts on Gitea) |
The API process is started and the Playwright suite invoked within a single CI step. Starting the API in an earlier step causes it to be reaped at the step boundary before Playwright runs.
@@ -250,11 +250,11 @@ The API process is started and the Playwright suite invoked within a single CI s
Runs secret scanning and dependency audits. Always runs regardless of the `changes` filter (secrets can appear in doc-only commits). Dependency audit and outdated checks run only when code changes are detected.
| Step | Tool/Command | Detail |
| ------------------- | ------------------------------- | -------------------------------------------------------------- |
| Secret scan | `gitleaks` (v8.30.1) | Scans the PR diff range; blocks on any finding |
| Dependency audit | `node scripts/check-audit.mjs` | Blocks on High or Critical severity vulnerabilities |
| Outdated report | `node scripts/check-outdated.mjs` | Advisory only — always exits 0, logged but never gates |
| Step | Tool/Command | Detail |
| ---------------- | --------------------------------- | ------------------------------------------------------ |
| Secret scan | `gitleaks` (v8.30.1) | Scans the PR diff range; blocks on any finding |
| Dependency audit | `node scripts/check-audit.mjs` | Blocks on High or Critical severity vulnerabilities |
| Outdated report | `node scripts/check-outdated.mjs` | Advisory only — always exits 0, logged but never gates |
### `gate`
+12 -12
View File
@@ -16,11 +16,10 @@ Self-hosted Docker deployment on Unraid behind Authelia OIDC and a Pangolin/Newt
The production compose file brings up three services:
| Service | Image | Purpose |
| --------- | ---------------------------------------------------- | ---------------------------------------------------------- |
| `api` | Built from `apps/api/Dockerfile` target `production` | Hono API + compiled React PWA, listens on port 3000 |
| `mariadb` | `mariadb:11` | Persistent MariaDB database |
| `redis` | `redis:7-alpine` | Present for live list sync (pub/sub); unused until Phase 4 |
| Service | Image | Purpose |
| --------- | ---------------------------------------------------- | --------------------------------------------------- |
| `api` | Built from `apps/api/Dockerfile` target `production` | Hono API + compiled React PWA, listens on port 3000 |
| `mariadb` | `mariadb:11` | Persistent MariaDB database |
---
@@ -32,13 +31,13 @@ FamilySync uses a self-hosted Gitea Actions runner. Two workflows govern the rel
Triggered on every pull request targeting `main`. The workflow runs a `changes` filter job first, then launches the following jobs in parallel:
| Job | Runs when | What it checks |
| ------------- | -------------------- | --------------------------------------------------------------------------------------------------------- |
| `fast-checks` | Always | Lint (`pnpm lint`), format check (`pnpm format:check`), markdown lint (`pnpm md:lint`), typecheck, PWA unit tests |
| `api` | Code-changing PRs only | DB migrations + API integration tests against a live MariaDB service container |
| `harness` | Code-changing PRs only | Full Playwright E2E suite (iPhone + Pixel + desktop profiles) against the compiled API |
| `security` | Always | Secret scan (gitleaks, PR diff); dependency audit and outdated report on code-changing PRs |
| `gate` | Always | Aggregates results — fails if any non-skipped required job did not succeed |
| Job | Runs when | What it checks |
| ------------- | ---------------------- | ----------------------------------------------------------------------------------------------------------------- |
| `fast-checks` | Always | Lint (`pnpm lint`), format check (`pnpm format:check`), markdown lint (`pnpm md:lint`), typecheck, PWA unit tests |
| `api` | Code-changing PRs only | DB migrations + API integration tests against a live MariaDB service container |
| `harness` | Code-changing PRs only | Full Playwright E2E suite (iPhone + Pixel + desktop profiles) against the compiled API |
| `security` | Always | Secret scan (gitleaks, PR diff); dependency audit and outdated report on code-changing PRs |
| `gate` | Always | Aggregates results — fails if any non-skipped required job did not succeed |
The `api` and `harness` jobs are **skipped on doc-only PRs** (changes confined to `.gitea/**`, `.planning/**`, or `*.md` files). A doc-only PR must pass `fast-checks` and `security`; the heavy jobs are not required.
@@ -62,6 +61,7 @@ The current milestone prefix (`v1.1`) is set in the `MILESTONE` env var at the t
The immutable `:<milestone>-<sha>` tag is pushed first. `:latest` is only moved after the immutable tag has landed, so a failed second push can never leave `:latest` advanced without a corresponding rollback tag.
Before pushing, the workflow runs two image hygiene assertions:
1. **Static assertions** — verifies `.dockerignore` contains all required exclusion patterns and that the build targets `--target production`.
2. **Boot-smoke** — starts the image with `NODE_ENV=production` and `DEV_AUTH_BYPASS=true` and asserts that it refuses to start (confirming the D-08 guard fires in the shipped image).