Skip to content

feat: Stage 1 authentication#752

Open
Nickatak wants to merge 13 commits into
hackforla:developfrom
Nickatak:feat/auth
Open

feat: Stage 1 authentication#752
Nickatak wants to merge 13 commits into
hackforla:developfrom
Nickatak:feat/auth

Conversation

@Nickatak
Copy link
Copy Markdown
Member

@Nickatak Nickatak commented May 13, 2026

feat/auth: Stage 1 authentication

Stacks on develop (the modernization chain landed 2026-05-07
through #747). First "real feature" PR after pattern work.

Summary

Wires up the Stage 1 auth flow end-to-end. Frontend already had
LoginForm + SignupForm at /login and /signup rendering /
validating but their submit handlers console.log'd - this PR
makes them actually authenticate against the backend.

Splits CustomUser and the /api/users/<uuid>/ endpoint out into
a new accounts Django app, then adds the five /api/auth/*
endpoints (signup, login, logout, me, csrf) using Django session

  • cookie auth. SPA gets a shared apiFetch client + AuthContext
    provider; the forms call into the context and redirect on success.

Same-origin via Next rewrites in dev / compose / stage / prod
means no CORS surface anywhere; CSRF flows through the csrftoken
cookie + X-CSRFToken header.

A clean-install audit + browser walk-through (run before opening
this PR) added the last four commits: a Next-proxy trailing-slash
fix (the SPA's /api/* calls 404'd through the dev/compose proxy),
a CSRF_TRUSTED_ORIGINS fix (logout failed Django's CSRF Origin
check behind the proxy), two doc corrections, and a deliberately
minimal signed-in nav indicator. The two proxy bugs are
dev/compose-only -- deployed stage/prod route /api/* to Django on
the same hostname via the ALB -- but they made the SPA auth flow
non-functional in local dev, which is where every contributor runs
it. See Open questions for the design-side
questions that surfaced (signed-in nav shape, logout placement,
email-verification transport).

Open questions

Things that need an answer (from the wireframes, from devops, or
just a call) before the relevant pieces are finished. Flagged here
rather than guessed at.

  1. Signed-in nav, for real. There is no Figma frame for the
    logged-in state, but the wireframes do show the difference: the
    "Log In" button is replaced by a "View open roles" button.
    That button needs a destination, and the open-roles / opportunities
    browse page doesn't exist in the frontend yet -- only the
    qualifier flow, whose own end-of-flow "View available roles"
    button is currently stubbed to href="/". (Backend has
    /api/opportunities/ and /api/roles/; no listing UI consumes
    them.) Commit 10 ships an interim "Log out" button in that
    slot instead. Confirm: target is "View open roles"; interim
    "Log out" placeholder is acceptable until the roles page lands.
  2. Where does logout live? If the nav slot becomes "View open
    roles" per Q1, there is no logout control anywhere in the designed
    UI -- no account menu, no avatar dropdown. Options: fold it under
    a "View open roles" dropdown, add a separate nav element, put it
    on a (not-yet-designed) account page, or accept "no visible logout
    in Stage 1" (session just expires). Needs a call.
  3. Email transport for verification. The wireframes include an
    email-verification step (scratch/planning/planned_auth.md
    deferred it for Stage 1 precisely because it needs email
    transport). Building it is fine -- but what are we sending
    through: Mailgun, AWS SES, something else? Partly a devops
    question (CTJ declares the env vars, devops populates the
    provider + credentials per environment); CTJ just needs to know
    which client to wire. Resolving it determines whether email
    verification is in-scope for this PR's Stage 1 or a follow-up.

Commits

Thirteen commits; tests fold into the commit that introduces the
code they cover. Each commit passes make lint + both test suites at
its tip. One leading chore: commit applies tree-wide pre-commit
auto-fixes that surfaced the first time make lint ran
post-modernization (separated so the feature diff stays readable).
Commits 8-11 (fix: / docs: / nav feat: / fix:) land the
findings from a clean-install audit and a manual browser walk-through
(see the test plan): two dev/compose-only proxy bugs and two doc
corrections. Trailing commits 12-13 close two lint-job failures that
CI surfaced after the audit work: 12 closes a lint:types failure
by typing the vi.spyOn spies via MockInstance<F> directly; 13
closes a lint:dead (knip) failure by dropping a dead export on the
API client's internal error-body type.

  1. chore: Apply tree-wide pre-commit auto-fixes (18 files;
    EOF / CRLF / prettier-format only)
  2. refactor: Split CustomUser and user resource code into a new accounts app (30 files; structural; nuked + regenerated
    migrations; existing tests still pass)
  3. feat: Same-origin via Next rewrites for host-based dev
    (3 files; Makefile FRONTEND_RUN macro + comment cleanups)
  4. feat: Add Stage 1 auth endpoints (signup, login, logout, me, csrf) (6 files; pins
    DEFAULT_AUTHENTICATION_CLASSES = [SessionAuthentication];
    drops the broken opportunities field on
    CustomUserReadSerializer; 14 new tests + 1 uncomment)
  5. feat: Add frontend API client + AuthContext (6 files;
    apiFetch + typed authApi + AuthProvider mounted in root
    layout; 16 new tests)
  6. feat: Wire LoginForm and SignupForm to the auth backend
    (7 files; replaces console.log onSubmits; 7 new tests)
  7. docs: Document accounts app and Stage 1 auth flow (2 files;
    project layout, API table, auth narrative, test shape,
    permissions paths, lint command args)
  8. fix: Preserve trailing slashes when proxying /api and /admin to Django (1 file; next.config.ts rewrite destinations always
    carry a trailing slash + paired slash/no-slash sources). Found
    during a clean-install audit: Next's default trailingSlash: false
    308-strips the slash before rewrites() runs, so the SPA's
    trailing-slash API calls reached Django slashless, missed every
    path(), and hit the api_not_found catch-all -- the browser auth
    flow through :3000 was non-functional. Unit tests missed it
    (vitest mocks fetch); deployed stage was unaffected (ALB
    path-routes /api/* directly).
  9. docs: Fix healthcheck URL slash and stale docker-compose invocation (2 files; quickstart-guide.md /api/healthcheck ->
    /api/healthcheck/ per ADR-0011; installation.md step 3
    docker compose up --watch -> make docker-up per ADR-0014)
  10. feat: Show signed-in state in the site nav (2 files;
    HeaderNav becomes a "use client" component reading
    useAuth() -> "Log out" button when signed in, "Log In" link
    otherwise; consumes the previously-unused logout action;
    3 new tests). Deliberately minimal interim -- no Figma frame
    exists for the signed-in nav; no avatar / menu / name. See
    follow-ups.
  11. fix: Trust the proxied SPA origin for CSRF (CSRF_TRUSTED_ORIGINS)
    (5 files; new env-driven CSRF_TRUSTED_ORIGINS setting + dev
    default, env examples, backend.md note, 2 new tests). The SPA
    is proxied to Django on a different Host, so an authenticated
    POST arrives with Origin: http://localhost:3000 against a
    non-matching host and Django's CSRF Origin check rejects it
    ("Origin checking failed"). First hit on logout (the first
    authenticated POST). dev/compose-only; deployed stage/prod
    route /api/* same-hostname. The curl smoke missed it (curl
    doesn't send Origin); a browser walk-through caught it.
  12. chore: Type vi.spyOn results via MockInstance to fix lint:types
    (2 files; test-only). Closes the test plan's previously-pre-existing
    lint:types failure. vitest 3 narrowed the default MockInstance
    returned by a bare vi.spyOn reference, so
    ReturnType<typeof vi.spyOn> resolves to
    MockInstance<(this: unknown, ...args: unknown[]) => unknown>,
    which the parameterized spy returned by vi.spyOn(authApi, "login")
    etc. can't assign into (function parameters are contravariant).
    Fix: type the spy variables directly as
    MockInstance<typeof target.method>, sidestepping vi.spyOn's
    own generic constraints. Three spies affected (login, signup,
    fetch); no-arg spies (csrf, me, logout) had no contravariance
    problem and were left unchanged. No runtime / behavior change.
  13. chore: Drop ApiErrorBody export to satisfy knip lint:dead
    (1 file). ApiErrorBody is used only inside client.ts to type
    the parsed error envelope before constructing an ApiError;
    nothing imports it. Knip's lint:dead job caught the dead
    export on CI. Consumers should read ApiError.code /
    .message / .fields rather than the raw body shape (which is
    exactly what the rest of the codebase does). No runtime /
    behavior change.

Decisions baked in

  • Auth strategy: Django session + cookie (Stage 1). Cognito
    JWT (Stage 2) explicitly out of scope. Sessions over tokens
    because Django admin already uses sessions.
  • Same-origin via Next rewrites in dev / compose / stage /
    prod -> no CORS, no django-cors-headers, no
    NEXT_PUBLIC_API_URL cross-origin path. CSRF via cookie +
    header. Two consequences of the rewrite layer, both handled in
    the trailing commits: (a) Next strips trailing slashes before
    rewrites run, so the proxy destination must re-append them for
    Django's trailing-slash routes (commit 8); (b) the proxy rewrites
    Host, so the browser's Origin won't match the request host
    Django sees -- the SPA origin must be in CSRF_TRUSTED_ORIGINS
    (commit 11). Neither bites deployed stage/prod (ALB path routing,
    same hostname).
  • App boundary: separate accounts Django app (not a
    subpackage in ctj_api). Wide scope: CustomUser,
    /api/users/<uuid>/, UserDetailPermission, the auth flow
    endpoints all live here. Domain (Opportunity, Project, etc.)
    stays in ctj_api. Cross-app FKs to user use
    settings.AUTH_USER_MODEL.
  • Migration strategy: nuke and regenerate. No real data to
    preserve. Anyone with a local dev DB needs make db-reset-hard.
  • DRF auth classes: pinned to [SessionAuthentication].
    Drops DRF's implicit BasicAuthentication default since
    nothing here uses it.
  • AuthContext mount point: root layout. Logged-out is a real
    state nav consumers care about (Log In link vs signed-in
    control), so every route gets it.
  • Signup auto-login: yes. Signup -> session created -> redirect
    to /. Smoother flow for the common case.
  • URL shape: group by function (/api/auth/*) not by app
    boundary (/api/accounts/auth/*). Public URL contract should
    reflect what the endpoint does, not where the view lives.

CSRF caveat

DRF's @api_view marks the resulting view csrf_exempt at the
Django middleware level; CSRF is only enforced by
SessionAuthentication's own check, which fires for authenticated
requests. Effect: signup and login do not enforce CSRF
(anonymous when called); logout, me, and any post-login mutation
do. The frontend client sends X-CSRFToken on every mutation
regardless - it's free once the cookie is set. Hardening
signup/login CSRF (via @csrf_protect over @api_view, or a
custom EnforceCsrfMixin) is a follow-up.

Because SessionAuthentication's check runs the full
CsrfViewMiddleware pass, it also does the Origin/Referer check

  • which is why authenticated mutations need the proxied SPA origin
    in CSRF_TRUSTED_ORIGINS (commit 11; see "Same-origin via Next
    rewrites" above).

Test plan

  • Backend: make local-test-backend -> 27 tests pass
  • Frontend: npx vitest run -> 37 tests pass (2 file-level
    skips + 3 test-level skips are all pre-existing)
  • make lint clean (ruff / ruff-format / prettier / eslint /
    stylelint pass)
  • mypy accounts ctj_api backend clean (36 source files)
  • bandit -r accounts ctj_api backend clean
  • npm run lint:types clean. Was previously failing with 3
    vi.spyOn MockInstance errors in
    tests/contexts/AuthContext.test.tsx (login/signup spies) and
    tests/lib/api/client.test.ts (fetch spy); fixed in commit 12
    by typing the spy variables directly as
    MockInstance<typeof target.method>. See commit 12 for the
    mechanism.
  • npm run lint:dead (knip) clean. CI flagged an unused
    export on ApiErrorBody in frontend/src/shared/lib/api/client.ts;
    fixed in commit 13 by dropping the export keyword (the type
    is internal to client.ts).
  • Clean-install audit (scratch/planning/clean-install-audit.md):
    nuked the DB volume + both project images, regenerated
    dev/dev.env from dev.env.example, make docker-up ->
    images build, all three containers up, migrations apply in
    dependency order (accounts.0001 -> ctj_api.0001 ->
    accounts.0002 -> ...). Surfaced and fixed the proxy
    trailing-slash bug (commit 8).
  • Smoke via curl against the :3000 proxy after the commit-8
    fix -- the same path the React forms' apiFetch calls take:
    • GET /api/auth/csrf/ -> 204, sets csrftoken.
    • POST /api/auth/signup/ -> 201, sets sessionid (auto-login),
      returns the new user; GET /api/auth/me/ then returns it.
    • POST /api/auth/login/ with bad password -> structured
      validation_error; correct credentials create a session;
      POST /api/auth/logout/ -> 204 (no Origin header from curl,
      so this path didn't exercise the CSRF Origin check -- see below).
    • GET /api/healthcheck/ -> 200; GET /admin/login/ -> 200;
      SPA pages (/, /login, /signup, /credits,
      /privacy-policy) -> 200.
    • createsuperuser works (docker compose run --rm django ...).
  • Browser walk-through (host dev: make local-run-backend +
    make local-run-frontend, app at localhost:3000): signup ->
    /, login -> /, login with bad password shows the server
    error banner. Surfaced two things the curl smoke couldn't:
    login produced no visible nav change (-> commit 10), and
    logout failed with CSRF Failed: Origin checking failed
    (-> commit 11). Both verified fixed after.
    • One more pass: confirm the loading -> signed-in nav flip
      and inline RHF field errors look right (cosmetic; not blocking).

Follow-ups (out of scope for this PR)

  • Proper signed-in nav: avatar / account menu / display name, once a
    Figma frame exists for it. Commit 10 ships an interim "Log out"
    button only; the logout action now has a consumer.
  • Wire the (currently inert) mobile hamburger menu in HeaderNav.
  • Hardening CSRF on anonymous signup / login (see caveat above).
  • Phase 2 work: Cognito JWT verification, PeopleDepot integration.
  • Inline field-level error display (err.fields) on the forms.
    Currently we surface err.message only.

Nickatak and others added 12 commits May 8, 2026 08:19
First post-modernization run of `pre-commit run --all-files`
surfaced cruft that accumulated before the lint stack landed (hackforla#737):
trailing newlines missing on `.dockerignore` / `.gitignore`,
CRLF line endings on `docker-compose.yml`, mixed line endings in
`backend/poetry.lock`, prettier-formatted JSON in
`frontend/knip.json` and `frontend/tsconfig.json`, etc.

Pure auto-fixes from the configured hooks:

- `end-of-file-fixer`: missing trailing newlines.
- `mixed-line-ending --fix=lf`: CRLF -> LF normalization.
- `trailing-whitespace`: stripped where present.
- `prettier`: collapsed short JSON arrays to single-line per its
  default printWidth.

No semantic changes; `make lint` and `make local-test-backend`
both green pre- and post-fix. Splitting these out before the
`accounts` app PR keeps the structural diff readable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… app

Creates a Django `accounts` app and moves identity / auth concerns
out of `ctj_api`. `ctj_api` now hosts only the recruitment-catalog
and taxonomy domain (Opportunity, Project, Role, Skill, SkillMatrix,
CommunityOfPractice). The new app earns its boundary by giving the
upcoming Stage 1 auth flow (signup, login, logout, me, csrf - lands
in a follow-up commit) a natural home and by isolating the eventual
Stage 2 work (PeopleDepot integration, Cognito ID-token mode) to a
single app surface. Done now while there's no real DB data to
migrate.

What moved (ctj_api -> accounts):

- `CustomUser` model.
- `user_detail` view + `UserDetailPermission` + `CustomUserReadSerializer`.
- `users/<uuid>/` URL route (still served at `/api/users/<uuid>/`;
  `accounts.urls` mounts at the same `/api/` prefix from `backend.urls`,
  ordered before `ctj_api.urls` so its catch-all doesn't shadow).
- `make_pm_user` / `make_regular_user` test factories.
- `test_users.py`.

Cross-app boundaries:

- `Opportunity.created_by` FK now uses `settings.AUTH_USER_MODEL`
  instead of importing `CustomUser` directly. Avoids circular
  imports (accounts.models needs `CommunityOfPractice` and
  `SkillMatrix` from `ctj_api`) and follows the Django-recommended
  pattern - the FK shape stays the same when Stage 2 swaps
  `AUTH_USER_MODEL`.
- `accounts.tests.common` imports its own factories;
  `ctj_api.tests.common` keeps domain factories;
  `test_opportunities.py` imports user factories from
  `accounts.tests.common`.

Settings:

- `INSTALLED_APPS` adds `accounts.apps.AccountsConfig` (before
  `ctj_api` for natural dependency order).
- `AUTH_USER_MODEL` flips from `"ctj_api.CustomUser"` to
  `"accounts.CustomUser"`.

Migration strategy:

- Nuked `ctj_api/migrations/0001_initial.py` and `0002_*.py`.
- Regenerated: `accounts/migrations/0001_initial.py` (CustomUser),
  `accounts/migrations/0002_initial.py` (FKs to ctj_api models),
  `ctj_api/migrations/0001_initial.py` (six domain models).
- Anyone with a local dev DB needs to nuke it (`make db-reset-hard`);
  no stage data exists.

Lint config:

- `.github/workflows/lint.yml`: add `accounts` to mypy and bandit
  invocations.
- `backend/pyproject.toml`: add `accounts/tests` to bandit
  `exclude_dirs`.
- `Makefile`: drop hardcoded `ctj_api.tests` from
  `local-test-backend` so test discovery picks up both apps.

Tests pass on a fresh test DB (10 tests across both apps).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Path B (no-CORS) infra was already wired for compose dev and
local stage in hackforla#747 (BACKEND_INTERNAL_URL + next.config.ts
rewrites), but host-based dev (`make local-run-frontend`) skipped
the env entirely - the dev frontend at :3000 had no proxy and
relative `/api/*` calls would land on the Next dev server with no
listener. Wiring the host path now matters because forms get hooked
to the backend in a follow-up commit.

Changes:

- `Makefile`: add `FRONTEND_RUN` macro that sources `dev/dev.env`
  and overrides `BACKEND_INTERNAL_URL=http://localhost:8000` (host
  Next can't reach the docker DNS name `django`). Parallel to how
  `BACKEND_RUN` overrides `SQL_HOST=localhost` for the same
  reason. `local-run-frontend` uses it.
- `frontend/next.config.ts`: rewrite the rewrites() comment - the
  prior comment claimed dev runs cross-origin via
  `NEXT_PUBLIC_API_URL` (no longer true post-hackforla#747; that var is
  unused, removable in a separate sweep). Replaced with the per-
  environment table for `BACKEND_INTERNAL_URL`.
- `dev/dev.env.example`: note the compose vs host value distinction
  inline next to the var.

Same-origin in dev / compose / stage / prod -> SPA uses relative
URLs end-to-end -> no CORS surface anywhere.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Backend half of the Stage 1 auth flow. Endpoints under `/api/auth/`
implement Django session + cookie auth via DRF
`SessionAuthentication`. Frontend wiring lands in a follow-up
commit.

Endpoints (all in `accounts/views.py` as FBVs):

- `GET  /api/auth/csrf/`   - `@ensure_csrf_cookie` -> sets the
                             `csrftoken` cookie on response. The SPA
                             calls this once on app load.
- `POST /api/auth/signup/` - validates via `RegisterSerializer`,
                             creates `CustomUser` (`username = email`,
                             `people_depot_user_id = local:<uuid>`
                             placeholder), auto-logs-in, returns 201
                             with the user record.
- `POST /api/auth/login/`  - validates via `LoginSerializer`
                             (which calls `authenticate()`), creates
                             session, returns 200.
- `POST /api/auth/logout/` - calls `logout(request)`, returns 204.
                             Idempotent for anonymous callers.
- `GET  /api/auth/me/`     - returns the current authenticated user.
                             403 envelope when anonymous.

Settings:

- `REST_FRAMEWORK.DEFAULT_AUTHENTICATION_CLASSES` pinned to
  `[SessionAuthentication]`. DRF's default also enables
  `BasicAuthentication`; nothing here uses it (Stage 1 is cookie/
  session SPA; Stage 2 is Cognito ID-token), so pinning explicitly
  keeps the auth surface tight.

Serializer cleanup:

- Dropped the `opportunities` field from `CustomUserReadSerializer`.
  It was a writable PK-related-field referencing a non-existent
  attribute on `CustomUser` - documented as broken since the shape
  PR; would have crashed `auth_me` and `user_detail` on first call.
- Uncommented `test_authenticated_user_can_view_own_record` in
  `test_users.py` (was gated on the broken field being dropped) and
  expanded it to assert response body shape.

Tests (`accounts/tests/test_auth.py`):

- 14 new tests across the five endpoints: happy paths, auth
  failures, CSRF cookie set, duplicate-email / weak-password /
  missing-field rejections, anonymous-403 on `me`,
  logout-clears-session, logout-when-anonymous-is-noop.

CSRF caveat:

- DRF's `@api_view` marks the resulting view csrf_exempt at the
  Django middleware level; CSRF is enforced only by
  `SessionAuthentication`'s own check, which fires for
  authenticated requests. Effect: signup and login do *not* enforce
  CSRF (they're anonymous when called); logout, me, and any
  authenticated mutation *do*. The frontend client still sends
  `X-CSRFToken` on every mutation - it's free once the cookie is
  set. Hardening signup/login CSRF (via `@csrf_protect` over
  `@api_view`, or a custom `EnforceCsrfMixin`) is a follow-up;
  flagged here so review knows.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Frontend half of the auth wiring. Adds a fetch wrapper, typed
endpoint wrappers, and a React context that exposes auth state +
actions to the rest of the app. Forms get hooked to these in a
follow-up commit.

Files:

- `shared/lib/api/client.ts` - `apiFetch<T>(path, options)` wrapper.
  Sets `credentials: "include"` so the session cookie rides along,
  reads `csrftoken` from `document.cookie` and sends it as
  `X-CSRFToken` on mutating methods, parses non-2xx responses
  through the `civic_exception_handler` envelope shape, and throws
  a typed `ApiError` (with `status`, `code`, `message`, `details`)
  so callers can discriminate without re-parsing JSON.
- `shared/lib/api/auth.ts` - typed wrappers for the five
  `/api/auth/*` endpoints. `User` mirrors `CustomUserReadSerializer`.
- `shared/contexts/AuthContext.tsx` - `AuthProvider` + `useAuth`
  hook. On mount: seeds CSRF cookie, then hits `/auth/me/` to
  hydrate from any persisted session (403 -> stay anonymous).
  Exposes `{ user, loading, login, signup, logout }`. Mounted in
  the root layout (`src/app/layout.tsx`) so every route - logged
  in or logged out - can read auth state.
- `useAuth` throws if used outside `AuthProvider`, matching the
  existing `useQualifiersContext` defensive pattern.

Tests:

- `tests/lib/api/client.test.ts` - 8 tests covering happy path
  (200, 204), credential / CSRF header behavior, body
  serialization, error envelope unwrapping, non-JSON-error fallback.
- `tests/contexts/AuthContext.test.tsx` - 8 tests covering mount
  hydration (authenticated and anonymous), `login()` / `signup()` /
  `logout()` state transitions, error propagation,
  outside-provider throw, and child render.

Auth context mount-point judgment call (from the spec): root
layout. Reason: even logged-out state is a real state nav
consumers care about (Login button vs avatar), and mounting at
the root means any page can read it without route-group
duplication.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the `console.log` placeholder onSubmits with real calls
through `useAuth()`. Both forms now hit the Stage 1 backend and
redirect on success.

LoginForm:
- Calls `login({ email, password })` from the auth context.
- On success: `router.push("/")` (landing).
- On `ApiError`: surfaces `err.message` in a `role="alert"` banner
  at the top of the form. Field-level details (`err.fields`) are
  not displayed inline; login errors are typically a single
  "Invalid email or password" string, not field-shaped.

SignupForm:
- Combines `firstName + lastName` -> `name` so the existing two-
  field UX maps cleanly onto the backend's single-`name` shape.
- Calls `signup({ email, password, name })`. Backend auto-logs-in,
  so the context picks up the new `user` directly and the form
  pushes to `/`.
- Same alert banner pattern for `ApiError`.

Both forms:
- Disable the submit button while the request is in flight; button
  text swaps to "Logging in..." / "Signing up...".
- New CSS: `.submit:disabled` (opacity / cursor) and `.serverError`
  (colored alert banner). Variables fall back to literal hex if the
  CSS custom property isn't set.
- `ApiError` is the only specifically-handled error type; any other
  error renders a generic "Something went wrong" message so silent
  failure modes don't slip through.

Tests:
- `tests/components/LoginForm.test.tsx` - 4 tests: success ->
  redirect, server error -> banner, button disabled while
  in flight, RHF blocks empty submission.
- `tests/components/SignupForm.test.tsx` - 3 tests: name combine +
  redirect, server error -> banner, button disabled while in flight.
- All mock `next/navigation` and `useAuth` to keep the unit tests
  hermetic.

Auth flow is now end-to-end. Manual smoke test path: visit
/signup, register, get redirected to /, navigate to /login,
log out via... pending - no logout UI yet (an avatar / nav
hookup is its own follow-up).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Aligns the developer docs with the new `accounts` app and the
five `/api/auth/*` endpoints:

- `backend.md` project layout - add the `accounts/` tree;
  reframe `ctj_api/` as the domain app (CustomUser is no longer
  there). `auth.py` placeholder moves from `ctj_api/` to
  `accounts/` to match the Stage 2 description below.
- `backend.md` API table - add the five auth endpoints with their
  methods, auth requirements, and notes.
- `backend.md` auth section - expand with the SPA cookie + CSRF
  flow narrative (4-step bootstrap, same-origin via Next rewrites,
  CSRF caveat for anonymous endpoints). Update Stage 2 reference
  from `ctj_api.auth.py` -> `accounts.auth.py`.
- `backend.md` test shape - tests live in `accounts/tests/` and
  `ctj_api/tests/` per app; user factories moved to
  `accounts.tests.common`, domain factories stay in
  `ctj_api.tests.common`.
- `backend.md` permissions section - `UserDetailPermission` now
  lives in `accounts.permissions`.
- `backend.md` lint commands - mypy and bandit invocations get
  `accounts` added to the args.
- `installation.md` local-dev auth - mention the new SPA signup
  path (`/signup` -> `POST /api/auth/signup/`); add the
  `isProjectManager` toggle hint for PM elevation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Next's default `trailingSlash: false` strips the trailing slash off
incoming requests (308 redirect) before `rewrites()` runs, so the SPA's
trailing-slash API calls (`/api/auth/csrf/`, `/api/auth/signup/`, ...)
were proxied to Django as `/api/auth/csrf`, missing every `path()` entry
and falling through to the `api_not_found` catch-all. Append the slash on
the rewrite destination and pair slash/no-slash `source` entries so the
post-308 form and any slashless caller both resolve.

The browser auth flow through `:3000` was non-functional before this. The
unit tests didn't catch it because vitest mocks `fetch` and never
exercises the Next rewrite layer; deployed stage was unaffected because
the ALB path-routes `/api/*` straight to Django without this proxy.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`quickstart-guide.md` pointed at `/api/healthcheck` (no trailing slash),
which 404s under the ADR-0011 trailing-slash routing convention; the
route is `/api/healthcheck/`. `installation.md` step 3 still said
`docker compose up --watch`, predating ADR-0014 (Make is the canonical
task runner); point it at `make docker-up` / `make docker-watch`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`HeaderNav` becomes a client component reading `useAuth()`: when a
session is active the right-hand slot is a "Log out" button that
calls the (previously unused) `logout` action; otherwise it's the
existing "Log In" link. `loading` is treated as signed-out, so a
logged-in user sees a brief "Log In" -> "Log out" flip on first
paint. Logout only clears context state -- no redirect, since
nothing is behind auth yet.

Deliberately minimal: there is no Figma frame for the signed-in nav
(the original app had no auth UI), so this is an interim placeholder
in the same slot the "Log In" link occupies -- no avatar, no
account menu, no name. The hamburger menu stays inert. 3 new tests
(`HeaderNav.test.tsx`) cover the signed-out / loading / signed-in
branches against a mocked `useAuth`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The SPA runs on :3000 and is proxied to Django via Next `rewrites()`,
which rewrite `Host` to the backend's address -- so an authenticated
POST reaches Django carrying `Origin: http://localhost:3000` against a
request host that isn't that origin, and Django's CSRF check rejects it
("Origin checking failed -- ... does not match any trusted origins").
The first request that hits this is logout (the first authenticated
POST; signup/login are CSRF-exempt -- anonymous when called -- and `me`
is a GET).

Add an env-driven `CSRF_TRUSTED_ORIGINS` with a dev default covering the
host-dev and compose-dev SPA origins; declare it in the env examples and
document it in backend.md. Deployed stage/prod route `/api/*` to Django
on the same hostname (ALB path routing), so the request host already
matches there; devops sets the var per environment. 2 new tests
exercise the real session + CSRF-token path with an `Origin` header --
the curl-based smoke missed this because curl doesn't send `Origin`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
vitest 3 narrowed the default `MockInstance` returned by a bare,
unparameterized `vi.spyOn` reference. `ReturnType<typeof vi.spyOn>`
now resolves to `MockInstance<(this: unknown, ...args: unknown[])
=> unknown>`, which the spy actually produced by `vi.spyOn(authApi,
"login")` etc. can't assign into - function parameters are
contravariant, and `LoginPayload` isn't assignable to `unknown` in
the contained call signature. vitest 2 was looser, so this only
surfaced after the runtime/dep bump in hackforla#737.

Type the spy variables directly as `MockInstance<typeof
target.method>`, sidestepping the `vi.spyOn` generic constraints
entirely. Three spies affected (login, signup, fetch); the no-arg
spies (csrf, me, logout) had no contravariance problem and are left
unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@Nickatak Nickatak marked this pull request as ready for review May 19, 2026 22:52
`ApiErrorBody` is used only inside `client.ts` (typing the parsed
error envelope before constructing `ApiError`) and was never
imported anywhere else, so the `export` keyword was dead. Knip
caught it on CI. Dropping the export is the right fix - consumers
should read `ApiError.code` / `.message` / `.fields` rather than
the raw body shape, which is exactly what the rest of the codebase
already does.

Added an internal-only comment explaining why the type isn't
exported, to head off the next "should I export this?" question.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant