Auth UI: register, login, logout pages (#67) #71

Merged
james merged 1 commit from 67-auth-ui into main 2026-06-16 13:17:46 +00:00
Owner

Closes #67.

Summary

  • /login and /register at the root (not under (app)/) — showing the nav shell to an unauthenticated user would be wrong. Both are async server components that call getServerSession() and redirect() if the visitor already has a session.
  • Forms mirror the /notes TanStack pattern: useForm({ validators: { onChange: zSchema } }) with zod form-shaped schemas in lib/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 split (proxy.ts + ADR-0013): /api/* paths always get 401 JSON regardless of headers (a crafted Sec-Fetch-Dest: document can't smuggle an HTML redirect out of an API endpoint). Non-API paths use Sec-Fetch-Dest / Sec-Fetch-Mode to 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-store added 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.ts validates ?next= redirect targets: same-origin only, rejects backslashes / control chars / protocol-relative / absolute URLs / /api/* / /login* / /register*. Parsed with new URL(raw, "http://placeholder.invalid") so trailing slashes / queries / hashes all reduce to the same rejection rule.
  • Logout = server action (logoutAction in app/(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 existing POST /api/auth/logout route stays for non-browser clients.
  • Landing page stays static. app/page.tsx keeps its prerendered shell + mounts <AuthStatus />, a client island that calls /api/auth/me after 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.ts extracted from password.ts so lib/dto/auth.ts can import MIN_PASSWORD_LENGTH without dragging @node-rs/argon2 into the client bundle. password.ts re-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/login which is already public via the /api/auth/ prefix.
  • /register — same reasoning. Public because creating an account is the entry surface.

Build / route summary

○  /                 static (public landing, client island AuthStatus)
○  /offline          static (force-static, precache intact)
○  /profile, /skills, /experience, /projects, /network  static placeholders
ƒ  /login, /register dynamic (server-component session gate)
ƒ  /notes            dynamic (server prefetch)
ƒ  /api/auth/*       dynamic
ƒ  Proxy (Middleware)

/ and /offline staying ○ (Static) is the load-bearing thing: ADR-0008's precache invariant + the PWA install path are preserved.

What didn't change

  • Backend auth contract. lib/dto/user.ts's hand-rolled parseRegisterRequest / parseLoginRequest stay — 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

  • The proxy is a bigger surface than before. The Sec-Fetch-Dest heuristic 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-store on every authed response forfeits some intermediary cache hits. Acceptable for user-state-dependent surfaces.
  • Form-shape vs server-shape duplication from ADR-0012 applies (zRegisterForm has confirmPassword, the server doesn't). Bounded; conversion is one line in onSubmit.

Test plan

  • npm run typecheck / npm run lint / npm test — all green. 137 passed, 32 skipped (169 total). 12 new tests across tests/auth/safe-next.test.ts (10) and tests/proxy.test.ts (8 new assertions covering Sec-Fetch-* branches + Cache-Control header).
  • npm run build — succeeds. / and /offline still ○ (Static).
  • npm run dev — manual probes:
    • /login HTML carries the form (email + password + autoFocus + autocomplete attributes).
    • /profile unauth, no Sec-Fetch headers → 302 /login?next=%2Fprofile.
    • /profile unauth with Sec-Fetch-Dest: document → 302 same redirect.
    • /api/notes unauth → 401 JSON.
    • /api/notes unauth with Sec-Fetch-Dest: document → 401 JSON (API discriminator wins).
    • Register a user → /profile accessible; /login then 307-redirects to /profile (already authed).
    • Production build response on authed /profile includes Cache-Control: ... no-store ... (BFCache hole closed).
  • CI: build, dual-engine tests, security scans, gitleaks.
  • Manual browser: the full sign-up → use app → logout → press-back flow; confirm the post-logout page is not served from BFCache; confirm the inline form field errors render before submit; confirm autoFocus and autocomplete actually work in the installed PWA.

🤖 Generated with Claude Code

Closes #67. ## Summary - **`/login` and `/register`** at the root (not under `(app)/`) — showing the nav shell to an unauthenticated user would be wrong. Both are async server components that call `getServerSession()` and `redirect()` if the visitor already has a session. - Forms mirror the `/notes` TanStack pattern: `useForm({ validators: { onChange: zSchema } })` with **zod form-shaped schemas** in `lib/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 split (`proxy.ts` + ADR-0013):** `/api/*` paths *always* get 401 JSON regardless of headers (a crafted `Sec-Fetch-Dest: document` can't smuggle an HTML redirect out of an API endpoint). Non-API paths use `Sec-Fetch-Dest` / `Sec-Fetch-Mode` to 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-store`** added 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.ts`** validates `?next=` redirect targets: same-origin only, rejects backslashes / control chars / protocol-relative / absolute URLs / `/api/*` / `/login*` / `/register*`. Parsed with `new URL(raw, "http://placeholder.invalid")` so trailing slashes / queries / hashes all reduce to the same rejection rule. - **Logout = server action** (`logoutAction` in `app/(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 existing `POST /api/auth/logout` route stays for non-browser clients. - **Landing page stays static.** `app/page.tsx` keeps its prerendered shell + mounts `<AuthStatus />`, a client island that calls `/api/auth/me` after 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.ts` extracted from `password.ts` so `lib/dto/auth.ts` can import `MIN_PASSWORD_LENGTH` without dragging `@node-rs/argon2` into the client bundle. `password.ts` re-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/login` which is already public via the `/api/auth/` prefix. - `/register` — same reasoning. Public because creating an account is the entry surface. ## Build / route summary ``` ○ / static (public landing, client island AuthStatus) ○ /offline static (force-static, precache intact) ○ /profile, /skills, /experience, /projects, /network static placeholders ƒ /login, /register dynamic (server-component session gate) ƒ /notes dynamic (server prefetch) ƒ /api/auth/* dynamic ƒ Proxy (Middleware) ``` `/` and `/offline` staying `○ (Static)` is the load-bearing thing: ADR-0008's precache invariant + the PWA install path are preserved. ## What didn't change - Backend auth contract. `lib/dto/user.ts`'s hand-rolled `parseRegisterRequest` / `parseLoginRequest` stay — 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 - The proxy is a bigger surface than before. The `Sec-Fetch-Dest` heuristic 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-store` on every authed response forfeits some intermediary cache hits. Acceptable for user-state-dependent surfaces. - Form-shape vs server-shape duplication from ADR-0012 applies (`zRegisterForm` has `confirmPassword`, the server doesn't). Bounded; conversion is one line in `onSubmit`. ## Test plan - [x] `npm run typecheck` / `npm run lint` / `npm test` — all green. **137 passed, 32 skipped (169 total).** 12 new tests across `tests/auth/safe-next.test.ts` (10) and `tests/proxy.test.ts` (8 new assertions covering Sec-Fetch-* branches + Cache-Control header). - [x] `npm run build` — succeeds. `/` and `/offline` still `○ (Static)`. - [x] `npm run dev` — manual probes: - `/login` HTML carries the form (email + password + autoFocus + autocomplete attributes). - `/profile` unauth, no Sec-Fetch headers → 302 `/login?next=%2Fprofile`. - `/profile` unauth with `Sec-Fetch-Dest: document` → 302 same redirect. - `/api/notes` unauth → 401 JSON. - `/api/notes` unauth with `Sec-Fetch-Dest: document` → 401 JSON (API discriminator wins). - Register a user → `/profile` accessible; `/login` then 307-redirects to `/profile` (already authed). - Production build response on authed `/profile` includes `Cache-Control: ... no-store ...` (BFCache hole closed). - [ ] CI: build, dual-engine tests, security scans, gitleaks. - [ ] **Manual browser:** the full sign-up → use app → logout → press-back flow; confirm the post-logout page is not served from BFCache; confirm the inline form field errors render before submit; confirm autoFocus and autocomplete actually work in the installed PWA. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Auth UI: register, login, logout pages (#67)
All checks were successful
Secrets / gitleaks (pull_request) Successful in 22s
PR / OSV-Scanner (pull_request) Successful in 35s
PR / Static analysis (Semgrep) (pull_request) Successful in 44s
PR / npm audit (pull_request) Successful in 47s
PR / Lint (pull_request) Successful in 52s
PR / Typecheck (pull_request) Successful in 56s
PR / Test (sqlite) (pull_request) Successful in 1m4s
PR / Test (postgres) (pull_request) Successful in 1m6s
PR / Build (pull_request) Successful in 1m20s
PR / Trivy (image) (pull_request) Successful in 1m31s
09f8ca2f37
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
james merged commit 7e0fc74e90 into main 2026-06-16 13:17:46 +00:00
Sign in to join this conversation.
No description provided.