Devafusion.net

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.

This site uses Google Analytics to understand traffic. See the privacy and cookies page for details.