# Auth & Login — current-system spec (parity bar for the rebuild)

> Email + password authentication backed by Supabase Auth, with a forced
> first-login password change, POST sign-out, per-request session refresh via
> Next.js middleware, and a fully auth-gated app shell. There is no self-serve
> signup, no password reset, no MFA, no invite flow — users exist only because
> the seed script (`scripts/seed.ts`) created them with a one-time temporary
> password. In the rebuild this entire feature is REPLACED by id-auth Pattern B
> (see Rebuild deltas); this spec exists so nothing user-visible is lost by
> accident and so the guard rails (view-as vs. credentials) carry over.

## User workflows

- **Rian or Adi opens the app** → if not signed in, lands on `/login`, enters
  email + password, gets dropped on the dashboard (`/`).
- **A newly seeded user's first login**: rian runs `npm run seed`, captures the
  temporary password printed ONCE to stdout, hands it to the user out-of-band.
  The user signs in with it and is immediately trapped on `/change-password`
  (every navigation redirects there) until they set a real password. Rian's
  original ask (2026-05-20): "I want users to create a password upon first
  login. In the meantime they get a temporary password to log in the first
  time."
- **Signing out**: "Sign out" button in the top nav (a plain POST form).
- **Forgot password**: no in-app flow — rian resets it manually in the
  Supabase dashboard.
- **Identity display**: signed-in name + role shown in the nav at all times;
  rian explicitly expects super-admin status to be visible ("it says 'owner'.
  should it say super admin?", 2026-05-21).

## Complete behavior

### Middleware (session refresh + gates) — `middleware.ts` → `lib/supabase/middleware.ts:updateSession`

- **B1.** The root middleware runs on every request matching
  `'/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)'`
  — i.e. everything except Next static assets, image optimizer, favicon, and
  image-extension files. It DOES run on server actions, `/auth/signout`, and
  all pages. Its only body is `return await updateSession(request)`.
- **B2.** `updateSession` builds a `createServerClient` (`@supabase/ssr`)
  wired to the request cookies: `getAll()` reads `request.cookies`;
  `setAll(cookiesToSet)` writes each cookie onto `request.cookies`, rebuilds
  `supabaseResponse = NextResponse.next({ request })`, then sets each cookie
  (with its options) on the response. This is the standard supabase-ssr
  refresh dance — refreshed tokens propagate to both the downstream handler
  and the browser.
- **B3.** `await supabase.auth.getUser()` is what actually refreshes an
  expired session token. It runs on EVERY matched request.
- **B4.** `PUBLIC_PATHS = new Set(['/login'])` — `/login` is the ONLY public
  path. `/change-password` is NOT public (requires a session).
- **B5.** Signed-in user requesting `/login` → `redirect('/')`. Signed-in
  users can never see the login page.
- **B6.** No user on any non-public path → `redirect('/login')`. **No
  `?next=` return-URL is preserved** — after login you always land on `/`.
- **B7.** If a user exists and `pathname !== '/change-password'`: middleware
  queries `profiles.must_change_password` for `user.id` (`.select(...).eq('id',
  user.id).single()`). If truthy → `redirect('/change-password')`. This is
  the ONLY place the forced-change redirect happens; `/change-password` must
  stay excluded or it loops. Cost: one extra DB query on every matched
  request.
- **B8.** The middleware client is a **plain cookie client — view-as-unaware**.
  The auth gate and must-change-password check always evaluate against the
  REAL signed-in user, never the impersonated one.
- **B9.** The `.single()` error in B7 is discarded (only `data` is
  destructured). A signed-in user with **no `profiles` row** silently skips
  the forced-change redirect and proceeds.

### Login — `app/login/page.tsx` + `app/login/actions.ts`

- **B10.** `/login` is a server component. Heading: "Sign in". Reads
  `searchParams` (Promise-style, awaited) for `error` and renders it inside
  the form as `<p className="error">{error}</p>`.
- **B11.** Form posts to server action `login(formData)`. Fields:
  `email` (`type="email"`, `autoComplete="email"`, `required`, `autoFocus`) and
  `password` (`type="password"`, `autoComplete="current-password"`,
  `required`). Submit button text: "Sign in".
- **B12.** `login`: `email = String(formData.get('email') ?? '').trim()` —
  email IS trimmed; **password is NOT trimmed**
  (`String(formData.get('password') ?? '')`).
- **B13.** Calls `supabase.auth.signInWithPassword({ email, password })` on
  the server client from `lib/supabase/server.ts:createClient()` (the
  view-as-aware factory — irrelevant at login since no view-as cookie is
  normally present).
- **B14.** On error →
  `redirect('/login?error=' + encodeURIComponent(error.message))` — the RAW
  Supabase error message is surfaced verbatim (e.g. "Invalid login
  credentials"). On success → `redirect('/')`.
- **B15.** No in-app rate limiting, captcha, MFA, or lockout — only whatever
  Supabase Auth enforces upstream.

### Forced password change — `app/change-password/page.tsx` + `actions.ts`

- **B16.** Page heading: "Set a new password"; subtext: "Choose a password
  you'll use from now on." Fields `password` and `confirm`, both
  `type="password"`, `autoComplete="new-password"`, `minLength={8}`,
  `required` (`password` has `autoFocus`). Button: "Save password". `?error=`
  displayed the same way as login.
- **B17.** `changePassword(formData)` check order (each failure is a
  `redirect('/change-password?error=' + encodeURIComponent(msg))`):
  1. **View-as guard first**: if the `view_as_user_id` cookie
     (`VIEW_AS_COOKIE` from `lib/effective-user.ts`) is present AT ALL →
     error `'Exit view-as before changing your password.'`. Rationale: with
     view-as active, `createClient()` returns an impersonated client and
     `auth.updateUser({ password })` would change the TARGET user's password.
     The guard fires on cookie presence alone — even a cookie that
     `createClient()` would ignore (set by a non-super-admin) blocks the
     change (conservative by design; see E5).
  2. `password.length < 8` → `'Password must be at least 8 characters.'`
     (server-side re-validation of the client `minLength`).
  3. `password !== confirm` → `'Passwords do not match.'`
  4. `auth.getUser()` returns no user → `redirect('/login')`.
  5. `auth.updateUser({ password })` — on error, redirect with the raw
     Supabase error message.
  6. `profiles.update({ must_change_password: false }).eq('id', user.id)` —
     the user updates their OWN row (RLS must permit self-update of this
     column). On error, redirect with the raw message.
  7. Success → `redirect('/')`.
- **B18.** No password complexity rule beyond length ≥ 8. No check that the
  new password differs from the temporary one.

### Sign-out — `app/auth/signout/route.ts`

- **B19.** `POST` only (no GET handler). `createClient()` →
  `await supabase.auth.signOut()` →
  `NextResponse.redirect(new URL('/login', request.url), { status: 303 })`.
- **B20.** Triggered by a plain `<form action="/auth/signout" method="post">`
  in `components/nav.tsx` with a "Sign out" button (class `nav-signout`).
- **B21.** Sign-out **does NOT clear the `view_as_user_id` cookie** (8-hour
  maxAge). Known wart: a stale view-as cookie survives sign-out and, after
  signing back in as a super admin, would be honored again; for non-admins it
  blocks `/change-password` with a confusing message (E5). Additionally, if
  view-as is active, `createClient()` returns the direct-JWT impersonation
  client (`persistSession: false`), so `signOut()` may not perform normal
  cookie-based teardown. The rebuild must not reproduce either behavior.

### Auth-gated app shell — `app/(app)/layout.tsx` + `components/nav.tsx`

- **B22.** Every app page lives under the `(app)` route group. Its layout is a
  second auth gate (belt and suspenders over the middleware):
  `createClient()` → `auth.getUser()`; no user → `redirect('/login')`.
- **B23.** Then `getAppOrg(supabase)` (hard-coded slug `bowden-works`,
  `lib/org.ts`) and `getEffectiveUser(supabase, org.id)`
  (`lib/effective-user.ts`); if `effective` resolves to null →
  `redirect('/login')`. Consequence: if real-user resolution fails for any
  reason (e.g. the `sb-*-auth-token` cookie-decode in
  `readRealUserIdFromCookie` breaks after a supabase-ssr format change), the
  user appears signed out even with a valid session.
- **B24.** Nav identity display: `effective.full_name || effective.email`
  followed by a smaller muted parenthetical role label:
  `'super admin'` when `effective.is_super_admin`, else
  `effective.org_role ?? 'no role'`. This satisfies rian's explicit
  requirement that super-admin status be visible (not shown as "owner").
- **B25.** Nav link structure (`components/nav.tsx`, in order): Summary (`/`),
  Entries, Projects — a hover dropdown (top-level Projects → `/projects` plus
  Manage → `/projects/manage`; the top-level link stays clickable), Invoices
  (owner-only), Import, Team, Tools (owner-only, its own submenu); owner-only
  links are filtered by `canTransfer`. The nav also hosts the sign-out form
  (B20), the comments bell, and the view-as switcher/banner (specced in the
  view-as doc — out of scope here beyond the intersections B17.1 and B21).

### Clients, cookies, environment

- **B26.** Browser client (`lib/supabase/client.ts`):
  `createBrowserClient(NEXT_PUBLIC_SUPABASE_URL, NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY)`.
  Server clients and middleware use the same two env var NAMES. (The
  view-as JWT path additionally needs `SUPABASE_JWT_SECRET`; the seed script
  needs `SUPABASE_SECRET_KEY` — names only, see view-as / setup docs.)
- **B27.** Auth cookies are HTTP-only, written by `@supabase/ssr` through the
  Next.js cookie API, chunked as `sb-<project-ref>-auth-token(.0,.1,…)`.
  Session lifetime is the Supabase project default — no "remember me"
  toggle.
- **B28.** Error-handling convention across all three flows:
  **redirect-on-fail with `?error=<encoded message>`, fall-through on
  success** (server actions throw on `redirect()`, so each failure branch
  ends the action).

### User provisioning (seed — there is no other way to create a user)

- **B29.** `scripts/seed.ts` (run as `npm run seed`, uses the Supabase admin
  API with `SUPABASE_SECRET_KEY`) creates exactly two users:
  `rian@rian.ca` (full_name "Rian", `is_super_admin: true`; org member
  `bowden-works` role `owner`) and `info@adipramono.com` (full_name "Adi",
  `is_super_admin: false`; `bowden-works` role `manager`, `tingang` role
  `owner`). Orgs seeded: `Bowden Works`/`bowden-works`, `Tingang`/`tingang`.
- **B30.** Temp password: `randomBytes(14).toString('base64url')` (18
  URL-safe chars), passed to `auth.admin.createUser` with
  `email_confirm: true`, printed once to stdout, never stored.
- **B31.** `profiles` upsert sets `must_change_password: true` **only for
  newly created users**. Idempotent: re-running updates roles/flags but never
  touches existing users' passwords or re-arms the forced change.

### App shell: error boundary — `app/(app)/error.tsx`

- **B32.** A client-component error boundary covers the entire `(app)` route
  group — it catches anything thrown during a page render OR a server-action
  invocation, app-wide. On mount it `console.error`s the full error object
  (prefixed `[(app) error boundary]`) for inspection.
- **B33.** Stale-deploy detection: if
  `error.message.includes('Failed to find Server Action')` → heading
  **"Page is out of date"** with copy explaining the tab loaded before a
  recent deploy, so its buttons point at code that no longer exists on the
  server — refresh and try again. Caveat baked into the component: production
  Next.js obscures `error.message` (only `error.digest` survives), so this
  branch reliably fires only in dev; that's why BOTH branches lead with the
  refresh-first affordance (B34).
- **B34.** Any other error → heading **"Something went wrong"** with copy
  "The action couldn't complete. Most of the time a hard refresh fixes it; if
  it keeps happening, send the error digest to Rian." Primary button
  "Refresh page" does a full `window.location.reload()` (bypasses bfcache,
  re-pulls the latest JS bundle); secondary button "Try again without
  refreshing" calls the boundary's `reset()` (re-renders the segment without
  a reload).
- **B35.** A "Technical details" `<details>` disclosure exposes
  `error.digest` and `error.message` (each as a labeled `<code>`, rendered
  only when present).

## Data model

| Table / column | Semantics for this feature |
|---|---|
| `auth.users` (Supabase-managed) | Credential store; password hashing, session issuance. Not touched by app SQL. |
| `profiles.id` | PK, FK to `auth.users.id`. One row per user. |
| `profiles.email`, `profiles.full_name` | Display (nav label B24; `full_name` may be null → email shown). |
| `profiles.must_change_password` (boolean) | The forced-change flag. Set `true` by seed on creation (B31); read by middleware on every request (B7); set `false` by `changePassword` step 6 (B17). RLS must allow: self-read (middleware runs as the user) and self-update of this column. |
| `profiles.is_super_admin` (boolean, global/cross-org) | Read for the nav role label (B24); primary consumer is view-as (other doc). |
| `organization_members.role` (enum `owner|manager|member`) | Source of `effective.org_role` for the nav label (B24) via `getEffectiveUser`. |
| `organizations.slug` | `getAppOrg` looks up the hard-coded `'bowden-works'` (B23). |

Migration context: initial schema in `supabase/migrations/0001_init.sql`
(a.k.a. `20260520120001_init.sql`); the launch-day too-many-redirects loop was
an RLS recursion bug fixed in `20260521120001_fix_recursive_rls` (SECURITY
DEFINER helpers `current_user_org_ids()`, `current_user_is_super_admin()` —
see `organizations` feature doc / decisions.md #008).

## Permissions

- **Sign in**: anyone with a valid Supabase Auth credential pair. There is no
  app-level allowlist beyond "a row exists in `auth.users`".
- **See any app page**: any authenticated user (middleware B6 + layout B22).
  Data visibility beyond that is RLS + per-feature permission functions
  (`canTransfer`, `canManageTeam`, `canManageImports`, `canEditEntry`,
  `canEnterViewAs` in `lib/permissions.ts` — specced in their feature docs).
  A user with a session but no `bowden-works` membership gets an empty/
  "Organization not found" experience, not an auth error.
- **Change own password**: the signed-in user only (`auth.updateUser` acts on
  the session user; the `profiles` update is `.eq('id', user.id)` + RLS
  self-update).
- **Create users / reset passwords**: nobody in-app. Seed script or Supabase
  dashboard (rian) only.
- **No permission function gates login/logout/change-password** — the only
  guard is the view-as cookie check (B17.1).

## Edge cases & error behavior

- **E1.** Missing `profiles` row → forced-change check silently skipped (B9);
  user proceeds. Also `getEffectiveUser` would return null → bounced to
  `/login` by the layout (B23) — so in practice a profile-less user can log
  in but can't use the app.
- **E2.** Raw Supabase error messages are user-visible via `?error=` on both
  `/login` and `/change-password` (B14, B17.5) — leaks provider wording;
  acceptable at current scale, flagged for the rebuild's structured-error
  convention.
- **E3.** No return-URL preservation: deep link → `/login` → success →
  always `/` (B6). Mild UX wart, never complained about.
- **E4.** `/change-password` requires a session (not in `PUBLIC_PATHS`);
  an anonymous visitor there is bounced to `/login` (B4/B6). The page itself
  is reachable by ANY signed-in user, not just those with the flag set —
  it doubles as a voluntary change-password page.
- **E5.** Stale `view_as_user_id` cookie (survives sign-out, B21) blocks
  `/change-password` for its 8-hour life with "Exit view-as before changing
  your password." — confusing for a non-super-admin who cannot see the
  view-as UI to "exit" anything. Known wart; do not reproduce.
- **E6.** Middleware DB query per request (B7) is a deliberate cost accepted
  at 2-user scale; a rebuild at larger scale should carry the flag in the
  session/claims instead.
- **E7.** `readRealUserIdFromCookie` (view-as system) decodes supabase-ssr
  cookie internals without verification; a format change silently makes
  `getEffectiveUser` return null → all users bounced to `/login` despite
  valid sessions (B23). Fragility documented in the view-as spec; listed
  here because the SYMPTOM presents as an auth failure.
- **E8.** Rollbacks: none needed — every mutation in this feature is a
  single Supabase call; the only two-step sequence (updateUser then profiles
  update, B17.5–6) can strand a user who set a new password but kept
  `must_change_password = true`; the loop then re-prompts them, which is
  self-healing (they set it again).
- **E9.** Double-submit on login/change-password is unguarded (no pending
  state); harmless (idempotent) but violates rian's "actions need progress
  feedback" preference.

## UI notes

- Both auth pages are bare server-rendered forms: `<main>`, an `<h1>`, labeled
  inputs, one submit button, error as `<p className="error">` (global CSS,
  `app/globals.css`). Change-password subtext is inline-styled grey — inline
  styles are pervasive app-wide and explicitly do NOT port (frontend rule:
  zero inline styles).
- Signed-in display in nav: name (or email) + muted role parenthetical;
  amber banner + "Exit view" when impersonating (view-as doc).
- Rian's taste profile (transcript extraction §17) applied to this feature:
  he never criticized the login page itself, but the relevant preferences
  are (a) **super-admin label must be visible** — he flagged "owner" as
  wrong on day 2; (b) **actions need progress indicators** — the current
  login/save buttons give no pending feedback; (c) sensible defaults and no
  redundant chrome — the minimal two-field forms are fine as-is; (d) dislikes
  "important info in small grey font" — keep role/identity legible.

## Rebuild deltas

The rebuild REPLACES this feature wholesale with **id-auth Pattern B**
(00-vision-and-scope.md phase-out row "demoing-era password login …
must_change_password"; 04-architecture.md "Auth middleware (Pattern B,
default-deny)"; 07-judgment-calls.md #1). Mapping, behavior by behavior:

**Moves to id-auth (deleted from the app):**
- `/login` page, `login` action, `signInWithPassword` (B10–B15) → Caddy
  `import id-auth` with `account_required:true`; login happens at
  `auth.bowden.works`. The app never sees a password.
- Temp-password provisioning via seed (B29–B31) → `srv-gw id-user-create` +
  `srv-gw id-user-set-password` (interactive) + `srv-gw id-user-grant`.
- Forced first-login change, `/change-password`, `profiles.must_change_password`
  (B7, B16–B18) → id-auth's problem entirely; the column and flow are dropped.
- Middleware session refresh (B1–B3, B27) → replaced by the Pattern B
  app session (kit shipped 2026-07-07 — `/srv/system/id-auth/app-auth/`):
  OAuth2+PKCE sign-in at auth.bowden.works, audience-bound id_token
  verified per PATTERN-B.md, the app keeps its own short session keyed
  on the BW username. No session → redirect into the sign-in flow.
  Dev stub: env-gated `AUTH_DEV_USER`, local tests only (judgment #13).
- Password reset / MFA / invite gaps (docs/features/auth.md §4.6) → id-auth's
  roadmap, not the app's.

**Survives in-app (re-implement):**
- The default-deny gated shell (B4–B6, B22–B23): public allowlist is only
  `/healthz` + static assets; no route-level opt-outs.
- Identity resolution: grant header → `users` row (keyed on
  `idauth_username`, 02-domain-model.md §users; replaces `profiles` +
  `auth.users`). Auto-provision on first sight = a user row with NO party
  link and zero capabilities — access requires an owner attaching a person
  party.
- Signed-in display incl. visible super-admin label (B24) → SPA nav, fed by
  the actor context.
- Sign-out (B19–B20) → a nav link to the id-auth logout endpoint (no app
  route needed).
- The credentials-while-impersonating guard rail (B17.1) → carried over as
  "no password/credential actions while impersonating" in the authz engine
  (04-architecture.md "View-as = context swap"); view-as JWT minting retires
  (07-judgment-calls.md #7). Fix the B21/E5 warts by construction: view-as is
  server-side actor context, not a client cookie that can go stale.
- The stale-deploy error treatment (B32–B35) → the SPA equivalent is
  **version-skew detection**: every API response carries the app version, the
  client compares it against its own build, and skew gets the same
  refresh-first "Page is out of date → Refresh" treatment instead of a
  generic error (04-architecture.md "Errors").

## Test specs

Carried IDs: AUTH-001, AUTH-002 (from docs/features/auth.md §4.7, previously
🟡/unimplemented). New IDs continue the series.

- T-AUTH-001: given `profiles.must_change_password = true`, when the user requests any path except `/change-password`, then they are redirected to `/change-password` (carries AUTH-001).
- T-AUTH-002: given the user submits a valid ≥8-char matching password pair, when `changePassword` runs, then the auth password is updated AND `must_change_password` becomes false AND they land on `/` (carries AUTH-002).
- T-AUTH-003: given no session, when requesting any non-`/login` path (including `/change-password`), then redirect to `/login` with no `?next=` param.
- T-AUTH-004: given a valid session, when requesting `/login`, then redirect to `/`.
- T-AUTH-005: given bad credentials, when `login` runs, then redirect to `/login?error=<encoded provider message>` and the message renders in the form.
- T-AUTH-006: given an email with surrounding whitespace and a password with surrounding whitespace, when `login` runs, then the email is trimmed and the password is passed untrimmed.
- T-AUTH-007: given a 7-char password, when `changePassword` runs, then error 'Password must be at least 8 characters.'; given mismatched confirm, then 'Passwords do not match.' (in that order — length checked first).
- T-AUTH-008: given ANY `view_as_user_id` cookie is present (even one that would be ignored), when `changePassword` runs, then error 'Exit view-as before changing your password.' and no password change occurs.
- T-AUTH-009: given a signed-in user, when POSTing `/auth/signout`, then the session is ended and the response is a 303 redirect to `/login`.
- T-AUTH-010: given an active view-as cookie, when POSTing `/auth/signout`, then (current behavior) the view-as cookie survives — rebuild MUST invert this: impersonation state cannot outlive the session.
- T-AUTH-011: given a session whose user has no `profiles` row, when navigating, then no forced-change redirect fires (middleware) but the app layout bounces them to `/login` (no effective user).
- T-AUTH-012: given `must_change_password = true`, when the user requests `/change-password` itself, then NO redirect loop occurs (path excluded from the check).
- T-AUTH-013: given a super admin, then the nav role label reads "super admin"; given a non-admin org member, it reads their `org_role`; given no membership, "no role".
- T-AUTH-014: given a request for `/_next/static/*` or `*.png`, then the auth middleware does not run (matcher exclusion).
- T-AUTH-015: given the seed script runs twice, then existing users keep their password and `must_change_password` is not re-armed; new users get a printed one-time temp password and the flag set true.
- T-AUTH-016: (rebuild, Pattern B) given no valid app session, when any non-allowlisted route is requested, then 401/redirect into the BW sign-in flow; given a first-ever BW username completing sign-in, then a zero-capability `users` row is auto-provisioned and no data is accessible.
- T-AUTH-017: given an error whose `message` contains 'Failed to find Server Action' reaches the `(app)` error boundary, then the heading is "Page is out of date" with the stale-tab copy, and the primary "Refresh page" button triggers a full `window.location.reload()`.
- T-AUTH-018: given any other error reaches the boundary, then the heading is "Something went wrong" with the "send the error digest to Rian" copy; "Refresh page" reloads the window and "Try again without refreshing" calls `reset()` without a reload.
- T-AUTH-019: given an error carrying a `digest` and/or `message`, then the "Technical details" disclosure exposes each as a labeled code value, omitting absent fields.
