feat: Stage 1 authentication#752
Open
Nickatak wants to merge 13 commits into
Open
Conversation
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>
3 tasks
`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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
feat/auth: Stage 1 authentication
Stacks on
develop(the modernization chain landed 2026-05-07through #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
/loginand/signuprendering /validating but their submit handlers
console.log'd - this PRmakes them actually authenticate against the backend.
Splits
CustomUserand the/api/users/<uuid>/endpoint out intoa new
accountsDjango app, then adds the five/api/auth/*endpoints (signup, login, logout, me, csrf) using Django session
apiFetchclient +AuthContextprovider; 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
csrftokencookie +
X-CSRFTokenheader.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_ORIGINSfix (logout failed Django's CSRF Origincheck 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 onthe 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.
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 consumesthem.) 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.
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.
email-verification step (
scratch/planning/planned_auth.mddeferred 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 atits tip. One leading
chore:commit applies tree-wide pre-commitauto-fixes that surfaced the first time
make lintranpost-modernization (separated so the feature diff stays readable).
Commits 8-11 (
fix:/docs:/ navfeat:/fix:) land thefindings 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:typesfailureby typing the
vi.spyOnspies viaMockInstance<F>directly; 13closes a
lint:dead(knip) failure by dropping a dead export on theAPI client's internal error-body type.
chore: Apply tree-wide pre-commit auto-fixes(18 files;EOF / CRLF / prettier-format only)
refactor: Split CustomUser and user resource code into a new accounts app(30 files; structural; nuked + regeneratedmigrations; existing tests still pass)
feat: Same-origin via Next rewrites for host-based dev(3 files; Makefile FRONTEND_RUN macro + comment cleanups)
feat: Add Stage 1 auth endpoints (signup, login, logout, me, csrf)(6 files; pinsDEFAULT_AUTHENTICATION_CLASSES = [SessionAuthentication];drops the broken
opportunitiesfield onCustomUserReadSerializer; 14 new tests + 1 uncomment)feat: Add frontend API client + AuthContext(6 files;apiFetch+ typedauthApi+AuthProvidermounted in rootlayout; 16 new tests)
feat: Wire LoginForm and SignupForm to the auth backend(7 files; replaces
console.logonSubmits; 7 new tests)docs: Document accounts app and Stage 1 auth flow(2 files;project layout, API table, auth narrative, test shape,
permissions paths, lint command args)
fix: Preserve trailing slashes when proxying /api and /admin to Django(1 file;next.config.tsrewrite destinations alwayscarry a trailing slash + paired slash/no-slash sources). Found
during a clean-install audit: Next's default
trailingSlash: false308-strips the slash before
rewrites()runs, so the SPA'strailing-slash API calls reached Django slashless, missed every
path(), and hit theapi_not_foundcatch-all -- the browser authflow through
:3000was non-functional. Unit tests missed it(vitest mocks
fetch); deployed stage was unaffected (ALBpath-routes
/api/*directly).docs: Fix healthcheck URL slash and stale docker-compose invocation(2 files;quickstart-guide.md/api/healthcheck->/api/healthcheck/per ADR-0011;installation.mdstep 3docker compose up --watch->make docker-upper ADR-0014)feat: Show signed-in state in the site nav(2 files;HeaderNavbecomes a"use client"component readinguseAuth()-> "Log out" button when signed in, "Log In" linkotherwise; consumes the previously-unused
logoutaction;3 new tests). Deliberately minimal interim -- no Figma frame
exists for the signed-in nav; no avatar / menu / name. See
follow-ups.
fix: Trust the proxied SPA origin for CSRF (CSRF_TRUSTED_ORIGINS)(5 files; new env-driven
CSRF_TRUSTED_ORIGINSsetting + devdefault, env examples, backend.md note, 2 new tests). The SPA
is proxied to Django on a different
Host, so an authenticatedPOST arrives with
Origin: http://localhost:3000against anon-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 (curldoesn't send
Origin); a browser walk-through caught it.chore: Type vi.spyOn results via MockInstance to fix lint:types(2 files; test-only). Closes the test plan's previously-pre-existing
lint:typesfailure. vitest 3 narrowed the defaultMockInstancereturned by a bare
vi.spyOnreference, soReturnType<typeof vi.spyOn>resolves toMockInstance<(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>, sidesteppingvi.spyOn'sown 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.
chore: Drop ApiErrorBody export to satisfy knip lint:dead(1 file).
ApiErrorBodyis used only insideclient.tsto typethe parsed error envelope before constructing an
ApiError;nothing imports it. Knip's
lint:deadjob caught the deadexporton CI. Consumers should readApiError.code/.message/.fieldsrather than the raw body shape (which isexactly what the rest of the codebase does). No runtime /
behavior change.
Decisions baked in
JWT (Stage 2) explicitly out of scope. Sessions over tokens
because Django admin already uses sessions.
prod -> no CORS, no
django-cors-headers, noNEXT_PUBLIC_API_URLcross-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'sOriginwon't match the request hostDjango sees -- the SPA origin must be in
CSRF_TRUSTED_ORIGINS(commit 11). Neither bites deployed stage/prod (ALB path routing,
same hostname).
accountsDjango app (not asubpackage in
ctj_api). Wide scope:CustomUser,/api/users/<uuid>/,UserDetailPermission, the auth flowendpoints all live here. Domain (Opportunity, Project, etc.)
stays in
ctj_api. Cross-app FKs to user usesettings.AUTH_USER_MODEL.preserve. Anyone with a local dev DB needs
make db-reset-hard.[SessionAuthentication].Drops DRF's implicit
BasicAuthenticationdefault sincenothing here uses it.
state nav consumers care about (Log In link vs signed-in
control), so every route gets it.
to
/. Smoother flow for the common case./api/auth/*) not by appboundary (
/api/accounts/auth/*). Public URL contract shouldreflect what the endpoint does, not where the view lives.
CSRF caveat
DRF's
@api_viewmarks the resulting view csrf_exempt at theDjango middleware level; CSRF is only enforced by
SessionAuthentication's own check, which fires for authenticatedrequests. Effect: signup and login do not enforce CSRF
(anonymous when called); logout, me, and any post-login mutation
do. The frontend client sends
X-CSRFTokenon every mutationregardless - it's free once the cookie is set. Hardening
signup/login CSRF (via
@csrf_protectover@api_view, or acustom
EnforceCsrfMixin) is a follow-up.Because
SessionAuthentication's check runs the fullCsrfViewMiddlewarepass, it also does theOrigin/Referercheckin
CSRF_TRUSTED_ORIGINS(commit 11; see "Same-origin via Nextrewrites" above).
Test plan
make local-test-backend-> 27 tests passnpx vitest run-> 37 tests pass (2 file-levelskips + 3 test-level skips are all pre-existing)
make lintclean (ruff / ruff-format / prettier / eslint /stylelint pass)
mypy accounts ctj_api backendclean (36 source files)bandit -r accounts ctj_api backendcleannpm run lint:typesclean. Was previously failing with 3vi.spyOnMockInstance errors intests/contexts/AuthContext.test.tsx(login/signup spies) andtests/lib/api/client.test.ts(fetch spy); fixed in commit 12by typing the spy variables directly as
MockInstance<typeof target.method>. See commit 12 for themechanism.
npm run lint:dead(knip) clean. CI flagged an unusedexportonApiErrorBodyinfrontend/src/shared/lib/api/client.ts;fixed in commit 13 by dropping the
exportkeyword (the typeis internal to
client.ts).scratch/planning/clean-install-audit.md):nuked the DB volume + both project images, regenerated
dev/dev.envfromdev.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 proxytrailing-slash bug (commit 8).
curlagainst the:3000proxy after the commit-8fix -- the same path the React forms'
apiFetchcalls take:GET /api/auth/csrf/-> 204, setscsrftoken.POST /api/auth/signup/-> 201, setssessionid(auto-login),returns the new user;
GET /api/auth/me/then returns it.POST /api/auth/login/with bad password -> structuredvalidation_error; correct credentials create a session;POST /api/auth/logout/-> 204 (noOriginheader 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.createsuperuserworks (docker compose run --rm django ...).make local-run-backend+make local-run-frontend, app atlocalhost:3000): signup ->/, login ->/, login with bad password shows the servererror 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.
loading-> signed-in nav flipand inline RHF field errors look right (cosmetic; not blocking).
Follow-ups (out of scope for this PR)
Figma frame exists for it. Commit 10 ships an interim "Log out"
button only; the
logoutaction now has a consumer.HeaderNav.err.fields) on the forms.Currently we surface
err.messageonly.