Engineering log
Registration and login UI, wired to the existing Better Auth backend
Built email/password sign-up, log-in, and log-out UI on top of the identity backend from the previous slice - the first user-facing consumer of Better Auth. A review before opening the PR correctly flagged that registration triggers real UK GDPR obligations this slice hadn't addressed, so the scope grew to include self-service account deletion (Article 17) and data export (Article 15) rather than shipping registration ahead of them. Deliberately still excludes MFA enrollment/challenge UI - no account has MFA enabled and that is a separate future slice. Caught and fixed a real rendering regression before it shipped: an initial server-side session read in the header would have forced every page on the site into dynamic rendering.
Decisions
- Scoped this slice to email/password sign-up/log-in/log-out only, deliberately excluding MFA enrollment and challenge UI - no account has MFA enabled yet (no enrollment flow exists), so there is nothing to challenge, and building enrollment UI is its own separate slice.
- Implemented the OWASP-documented 'redirect back to what the user wanted' pattern (features/auth/safe-redirect.ts) rather than a naive redirect query param - validates the target is a same-site relative path, explicitly rejecting protocol-relative URLs (//evil.com) and javascript: schemes, falling back to / on anything unsafe. Passed through as Better Auth's own callbackURL option rather than a manual post-auth redirect.
- Caught a real rendering regression during the first build: reading auth.api.getSession() in SiteHeader (rendered via app/layout.tsx on every page) forced every previously-static page (/, /about, /log, /projects, etc.) into dynamic, server-rendered-on-demand rendering - confirmed directly via the build output's route table before and after. Reverted to a purely client-side session read (Better Auth's own authClient.useSession() hook) in a small leaf component instead, restoring static generation everywhere except the two auth pages themselves, which already need dynamic rendering to read the redirect query param.
- Log-in shows a single generic 'Invalid email or password' message regardless of which credential was wrong, rather than surfacing Better Auth's own more specific error text - distinguishing 'no such user' from 'wrong password' enables account enumeration.
- No new dependency added for form interaction testing (@testing-library/user-event is not in package.json) - used the already-available fireEvent from @testing-library/react instead, per the Standardized Tooling rule against introducing a dependency on a whim.
- Account deletion uses password re-confirmation only, not Better Auth's optional sendDeleteAccountVerification email-link flow - confirmed directly in the installed package's compiled source that the email step is only invoked when that option is configured (checked with `?.`), so omitting it deletes immediately once the password is confirmed, with no new email-sending infrastructure needed for this slice.
- Added no beforeDelete/afterDelete hook for cascading cleanup of session/account/user_security rows on account deletion - db/schema.ts already defines all three with `onDelete: "cascade"` foreign keys to user.id, and this was proven, not assumed, by inserting real rows into a local Postgres, deleting the user row directly, and confirming via psql that all three related rows were gone.
Milestones
- Added features/auth/auth-client.ts (createAuthClient from better-auth/react, no plugins - MFA is self-built, not Better Auth's twoFactorClient) and features/auth/safe-redirect.ts (isSafeRedirectPath/resolveSafeRedirectPath).
- Added app/sign-up/{page.tsx,sign-up-form.tsx} and app/log-in/{page.tsx,log-in-form.tsx} - Server Component shells reading and validating the ?redirect= searchParam, wrapping 'use client' form leaves.
- Added components/navigation/account-nav.tsx - a 'use client' leaf using authClient.useSession() to show a log-in link (with the current path as the redirect target) or a log-out button, wired into components/layout/site-header.tsx.
- Both /sign-up and /log-in are noindex,follow (no unique indexable content, should never rank ahead of the content pages they gate).
- Added unit tests for safe-redirect.ts (16 cases covering every rejection path) and component tests for SignUpForm, LogInForm, and AccountNav (mocking authClient and next/navigation).
- Enabled Better Auth's user.deleteUser (auth.ts) and built app/account/page.tsx (server-side session-gated, redirecting to /log-in?redirect=%2Faccount when signed out - correct here, unlike SiteHeader, since this page is already inherently dynamic/per-user with no static-rendering benefit to preserve) with DeleteAccountForm (password + typed confirmation phrase) and ExportDataButton.
- Added app/api/account/export/route.ts (GET, session-gated) returning the requesting user's own name/email/verification/timestamps and whether MFA is enabled - deliberately excludes the password hash and the raw encrypted MFA secret, which have no legitimate use to the user and would be a new exposure surface if the export itself were intercepted.
- Rewrote app/legal/page.tsx's 'What is not collected' section (previously false the moment accounts exist) and added an 'Account data' section disclosing what's collected at sign-up, the lawful basis (contract, UK GDPR Article 6(1)(b)), the session cookie (strictly necessary, PECR-consent-exempt but disclosed anyway), and how to exercise access/export/erasure rights via /account or the legal contact email for anything not yet self-service.
- Updated AccountNav to show an 'Account' link alongside 'Log out' when signed in, linking to the new page.
Validation
- Confirmed the SiteHeader rendering regression and its fix directly against next build's own route table output (○ Static vs ƛ Dynamic markers), not just by inspection - before the fix, /, /about, /log, /projects etc. all showed ƛ; after, only /sign-up and /log-in do.
- Ran the full real flow end-to-end against a genuine local Docker Postgres 16 (per src/web/AGENTS.md's Local Postgres for Migration Testing workflow) and the actual dev server, via direct API calls (not just the unit/component tests): sign-up (real user row confirmed via psql), get-session (valid session confirmed), sign-in, wrong-password rejection (401), get-session after sign-out (confirmed null - session genuinely invalidated), and sign-out's CSRF/origin protection (confirmed it correctly rejects a request with no Origin header and accepts one with a matching same-origin header, exactly what a real browser fetch sends).
- Separately proved the cascade-delete claim before relying on it: inserted a real user plus dependent user_security and session rows directly into local Postgres, deleted the user row, and confirmed via psql that all three were gone with zero application code - only then wired deleteUser into auth.ts.
- Re-ran the full real end-to-end flow a second time for the account deletion/export slice specifically: signed up a real user, called GET /api/account/export with the session cookie (confirmed the real returned payload's shape and that it excludes the MFA secret), confirmed 401 with no session, called /api/auth/delete-user with the wrong password (confirmed 400 INVALID_PASSWORD, user still exists) then the correct password (confirmed 200), and confirmed via psql that the user row was genuinely gone and get-session returned null afterwards.
- npm run lint (zero warnings), npm run typecheck, npm run build, and npm run test:unit (62 tests across 12 files, all passing) all run clean after every change in this slice.
- Confirmed authClient.useSession()'s return shape ({ data, isPending }) directly against better-auth's own shipped type declarations before relying on it, rather than assuming from documentation prose alone.