Auth UI: register, login, logout pages (#67) #71
No reviewers
Labels
No labels
area:auth
area:ci
area:db
area:infra
area:native
area:pwa
area:service
epic
feature
foundation
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
james/carol!71
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "67-auth-ui"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes #67.
Summary
/loginand/registerat the root (not under(app)/) — showing the nav shell to an unauthenticated user would be wrong. Both are async server components that callgetServerSession()andredirect()if the visitor already has a session./notesTanStack pattern:useForm({ validators: { onChange: zSchema } })with zod form-shaped schemas inlib/dto/auth.ts+ co-located error-code → human copy maps (LOGIN_ERROR_COPY,REGISTER_ERROR_COPY). Mutation function returns{ ok: true } | { ok: false, code, message }so the form branches declaratively without throw/catch round-trips. On success:router.push(safeNext(next))+router.refresh().proxy.ts+ ADR-0013):/api/*paths always get 401 JSON regardless of headers (a craftedSec-Fetch-Dest: documentcan't smuggle an HTML redirect out of an API endpoint). Non-API paths useSec-Fetch-Dest/Sec-Fetch-Modeto choose between 302-to-/login?next=…and 401 JSON, falling back to redirect when the headers are absent (old browser / non-browser caller).Cache-Control: private, no-storeadded to every authed proxy pass-through — closes the BFCache hole where pressing Back after logout restored a serialised DOM of the previously-authed page with prior-user data visible.lib/auth/safe-next.tsvalidates?next=redirect targets: same-origin only, rejects backslashes / control chars / protocol-relative / absolute URLs //api/*//login*//register*. Parsed withnew URL(raw, "http://placeholder.invalid")so trailing slashes / queries / hashes all reduce to the same rejection rule.logoutActioninapp/(app)/components/logout-action.ts) wired into a<form action={...}>button in the top nav.redirect("/login")runs in the same response that clears the cookie — no race window where the page is rendered with authed chrome after logout. The existingPOST /api/auth/logoutroute stays for non-browser clients.app/page.tsxkeeps its prerendered shell + mounts<AuthStatus />, a client island that calls/api/auth/meafter first paint to render either "Continue to app" or "Sign in" / "Register". Protects the PWA install path from going dynamic (ADR-0008 invariant).lib/auth/password-policy.tsextracted frompassword.tssolib/dto/auth.tscan importMIN_PASSWORD_LENGTHwithout dragging@node-rs/argon2into the client bundle.password.tsre-exports it for callers that prefer the original location.Public route additions (PR-justified per ADR-0004)
/login— public because that is how an existing user gets a session. Carries no per-user data; the form posts to/api/auth/loginwhich is already public via the/api/auth/prefix./register— same reasoning. Public because creating an account is the entry surface.Build / route summary
/and/offlinestaying○ (Static)is the load-bearing thing: ADR-0008's precache invariant + the PWA install path are preserved.What didn't change
lib/dto/user.ts's hand-rolledparseRegisterRequest/parseLoginRequeststay — zod adoption is per-PR (ADR-0012's out-of-scope note).app/(app)/route group and nav. Logout adds a button; existing routes untouched.Sharp edges flagged
Sec-Fetch-Destheuristic is the load-bearing decision; tests cover the branches but the policy reads as more complex than a one-line gate. ADR-0013 spells out the alternatives and why each was rejected.Cache-Control: no-storeon every authed response forfeits some intermediary cache hits. Acceptable for user-state-dependent surfaces.zRegisterFormhasconfirmPassword, the server doesn't). Bounded; conversion is one line inonSubmit.Test plan
npm run typecheck/npm run lint/npm test— all green. 137 passed, 32 skipped (169 total). 12 new tests acrosstests/auth/safe-next.test.ts(10) andtests/proxy.test.ts(8 new assertions covering Sec-Fetch-* branches + Cache-Control header).npm run build— succeeds./and/offlinestill○ (Static).npm run dev— manual probes:/loginHTML carries the form (email + password + autoFocus + autocomplete attributes)./profileunauth, no Sec-Fetch headers → 302/login?next=%2Fprofile./profileunauth withSec-Fetch-Dest: document→ 302 same redirect./api/notesunauth → 401 JSON./api/notesunauth withSec-Fetch-Dest: document→ 401 JSON (API discriminator wins)./profileaccessible;/loginthen 307-redirects to/profile(already authed)./profileincludesCache-Control: ... no-store ...(BFCache hole closed).🤖 Generated with Claude Code