After setup is complete, manually visiting /setup shows the 'already complete' surface (or redirects away) — not the wizard
After completing the wizard (incl. Fastmail credential) and landing in the app, the /calendar 'Set up your calendar' banner does NOT show for the operator
The setup gate respects the loading state to avoid a flash of the wizard
path
provides
contains
apps/pwa/src/App.tsx
/setup route gated on setupComplete (alreadyLocked or redirect); ['me'] freshness reconciled with wizard completion
alreadyLocked
from
to
via
pattern
App.tsx /setup route
setupQuery.data.setupComplete
alreadyLocked={setupComplete === true} or Navigate to /calendar
alreadyLocked|setupComplete
from
to
via
pattern
SetupBanner needsProviderSetup
['me'] query freshness
['me'] refetched after wizard completion so the banner reflects the claimed credential
needsProviderSetup|invalidateQueries
Close UAT gaps 5 and 6 — both on the PWA app-shell gate (`App.tsx`), with the banner component (`SetupBanner.tsx`).
Gap 5 (major): App.tsx renders <Route path="/setup" element={<SetupPage />} /> with no alreadyLocked prop and no setupComplete check. The * gate only redirects OTHER routes TO /setup when incomplete; there is no reverse guard. When setupComplete === true, manually visiting /setup still mounts the full wizard. The backend already 423s mutations, so this is purely a frontend gating gap. Fix: gate the /setup route on setupComplete — pass alreadyLocked={setupComplete === true} (SetupPage already supports this prop → renders Surface 8 "Setup already complete") or Navigate to /calendar; respect setupLoading to avoid a flash.
Gap 6 (major, root_cause PRELIMINARY): after finishing the wizard and landing on /calendar, the "Set up your calendar / Set up now" banner still shows. Task 1 is an investigation step to confirm the mechanism before prescribing the exact fix; Task 2 implements the confirmed fix.
Purpose: A completed instance must not re-expose the wizard, and must not nag the operator to set up a calendar they already configured in the wizard.
Output: /setup route gated post-completion; ['me'] reconciled so the banner does not show after wizard completion.
Task 1: Gate the /setup route on setupComplete (gap 5)
apps/pwa/src/App.tsx, apps/pwa/src/App.test.tsx
In apps/pwa/src/App.tsx, the `} />` (line ~139) currently mounts the wizard unconditionally. Add a reverse guard using the already-present `setupComplete` / `setupLoading` values (derived at lines ~132-133 from `setupQuery`):
- While `setupLoading` is true → render the existing no-flash placeholder (`
`) for the /setup element (do not show the wizard before status resolves).
- When `setupComplete === true` → render `` (SetupPage already supports the `alreadyLocked` prop → Surface 8 "Setup already complete"). Using the prop (rather than Navigate) keeps the operator on /setup with a clear terminal surface, matching the UAT expectation that manual /setup navigation shows the "already complete" surface. (Navigate to /calendar is an acceptable alternative if the executor finds the prop path conflicts with routing — but the prop path is preferred and already wired/tested in SetupPage.)
- When `setupComplete === false` (or undefined post-load) → render `` (the active wizard) as today.
Implement this by replacing the static `element={<SetupPage />}` with an inline conditional element expression mirroring the existing `*`-route gate style.
In apps/pwa/src/App.test.tsx: add a test that mocks `fetchSetupStatus` → `{ setupComplete: true }`, navigates to /setup (set `window.history.pushState({}, '', '/setup')` in the test per the existing URL-isolation pattern), and asserts the "Setup already complete" surface renders (and the active wizard's Step 1 heading "Welcome to FamilySync Setup" does NOT). Keep the existing setupComplete:false → wizard test passing.
cd apps/pwa && pnpm test -- App 2>&1 | tail -20
Visiting /setup with setupComplete===true renders the "Setup already complete" surface (not the wizard); setupComplete===false still renders the wizard; loading state shows no wizard flash; App.test.tsx GREEN.
Task 2: Diagnose + fix the persistent calendar banner (gap 6)
apps/pwa/src/App.tsx, apps/pwa/src/components/SetupBanner.tsx, apps/pwa/src/App.test.tsx
STEP A — Investigate/confirm root cause (the UAT root_cause is PRELIMINARY). Determine which of two mechanisms causes the banner to persist after wizard completion. Use the code already in context plus a focused trace:
- Mechanism (i) — claiming/linking gap: the wizard credential is stored against the unclaimed user (oidcIss=null), and first OIDC login does NOT bind it to the operator, so `needsProviderSetup` stays true. Verify by reading `apps/api/src/auth/user.ts` upsertUser first-login-claims branch: confirm whether the claim `db.update(users)...where(eq(users.id, unclaimed.id))` PRESERVES the same users.id (so the member_credentials row keyed on userId stays linked → needsProviderSetup=false). The 12-03-SUMMARY and the claim branch indicate the id IS preserved and is_admin/credential link is retained — i.e. mechanism (i) is NOT the cause. CONFIRM this by reading user.ts directly; if confirmed, the credential IS linked and needsProviderSetup is correctly false after first login.
- Mechanism (ii) — ['me'] staleness/refetch gap: `meQuery` uses `staleTime: 5 * 60 * 1000` (App.tsx ~line 88 and SetupBanner.tsx ~line 40). If `['me']` was populated BEFORE wizard completion / first claim (e.g. an earlier visit), the cached `needsProviderSetup=true` is served for up to 5 minutes after the operator authenticates post-wizard, so the banner shows even though the DB now says false.
Record the confirmed mechanism in the SUMMARY. The expected finding (per the claim-branch evidence) is mechanism (ii): a ['me'] freshness/refetch gap, NOT a linking gap.
STEP B — Implement the fix for the CONFIRMED mechanism:
- If mechanism (ii) (expected): ensure `['me']` is fresh on entry to the authenticated app shell after wizard completion. Preferred: invalidate/refetch `['me']` when the app transitions into the `setupComplete===true` shell, OR reduce the staleness window so the post-auth boot refetches member status. Concretely — when the setup gate resolves to the completed shell (the `setupComplete===true` branch in App.tsx), trigger a one-shot `queryClient.invalidateQueries({ queryKey: ['me'] })` (guarded so it does not loop), or set the `['me']` query's `staleTime` to 0 for the boot fetch so `needsProviderSetup` reflects the just-claimed credential. Keep the SetupBanner's success-only dismissal contract intact (do NOT add an X/dismiss button — the banner must still clear via needsProviderSetup=false).
- If STEP A instead confirms mechanism (i) (a real linking gap): the fix belongs on the backend — adjust the claim/credential linking in apps/api/src/auth/user.ts or apps/api/src/routes/setup.ts so the operator's claimed user owns the wizard-stored credential (needsProviderSetup=false). In that case add apps/api/src/auth/user.ts (+ its test) to files_modified and add a backend regression test asserting the claimed user has a credential.
Do NOT add a dismiss button to SetupBanner.tsx (T-05-24 / success-only contract). The banner must continue to clear ONLY via needsProviderSetup becoming false.
In apps/pwa/src/App.test.tsx (or SetupBanner test): add a regression test for the confirmed mechanism. For mechanism (ii): assert that on the completed-shell boot, ['me'] is refetched (or staleTime is 0) such that a `needsProviderSetup=false` response hides the banner; assert the SetupBanner is absent when needsProviderSetup is false.
cd apps/pwa && pnpm test -- App SetupBanner 2>&1 | tail -25
Root cause confirmed and documented; the fix ensures `needsProviderSetup` reflects the wizard-claimed credential on app entry so the "Set up your calendar" banner does NOT show post-wizard; no dismiss button added; tests GREEN.
<threat_model>
Trust Boundaries
Boundary
Description
client routing → setup surface
Frontend gating of /setup; backend already enforces 423
['me'] cache → UI gating
Stale member status must not mislead UX (banner)
STRIDE Threat Register
Threat ID
Category
Component
Disposition
Mitigation Plan
T-12-04
Tampering/Replay
/setup post-completion
mitigate
Frontend reverse-gate renders Surface 8; backend 423 on mutations remains the authoritative boundary (unchanged)
T-12-10
Spoofing
first-login claim
accept
Claim query is oidcIss IS NULL AND claimed=false LIMIT 1; investigation confirms id-preserving link; no change unless mechanism (i) found
- `cd apps/pwa && pnpm test -- App SetupBanner` GREEN
- `grep -nE "alreadyLocked" apps/pwa/src/App.tsx` shows the /setup route is gated on setupComplete
- `grep -nE "X button|dismiss|onClose.*banner|aria-label=\"Dismiss\"" apps/pwa/src/components/SetupBanner.tsx` returns nothing new (no dismiss added)
- SUMMARY documents the confirmed gap-6 mechanism (i vs ii)
- `cd apps/pwa && pnpm typecheck` clean
<success_criteria>
Gap 5 closed: /setup post-completion shows the "already complete" surface, not the wizard.
Gap 6 closed: the calendar banner does not show after the operator completes the wizard; root cause documented; success-only banner contract preserved.
</success_criteria>
Create `.planning/phases/12-initial-setup-wizard/12-07-SUMMARY.md` when done