# User Authentication — Separate Login and Register Pages **Issue:** crisleo-hermes/job-tracker#3 **Feature branch:** feature/user-auth **Date:** 2026-05-26 --- ## Overview Implement separate Login and Register pages with a custom User model using email as the unique identifier. Backend exposes two endpoints (`POST /api/auth/register/` and `POST /api/auth/login/`) and frontend provides dedicated pages with form validation, error handling, and JWT token management. --- ## Backend Specification ### Models **`accounts.User`** (custom, extends `AbstractUser`) | Field | Type | Constraints | |---|---|---| | `email` | `EmailField` | `unique=True`, `max_length=254` — used as the `USERNAME_FIELD` for authentication | | `username` | `CharField` | `max_length=150`, `blank=True`, `null=True` — optional display name, NOT used for auth | | `password` | (inherited) | `CharField(max_length=128)` — stored as Django PBKDF2 hash | | `first_name` | (inherited) | `CharField(max_length=150, blank=True)` | | `last_name` | (inherited) | `CharField(max_length=150, blank=True)` | | `is_active` | (inherited) | `BooleanField(default=True)` | | `is_staff` | (inherited) | `BooleanField(default=False)` | | `date_joined` | (inherited) | `DateTimeField(auto_now_add=True)` | **Meta:** `db_table = "accounts_user"`, `verbose_name = "User"` **Manager:** `objects = UserManager()` (inherited from `AbstractUser`) ### API Endpoints #### `POST /api/auth/register/` Creates a new user account. **Request:** ```json { "email": "user@example.com", "password": "securePass123", "password_confirm": "securePass123", "first_name": "John", "last_name": "Doe" } ``` **Validation rules:** - `email`: must match `^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`, must be unique (case-insensitive check), normalized to lowercase - `password`: minimum 8 characters - `password_confirm`: must match `password` - `first_name`, `last_name`: optional, max 150 characters **Success response (201 Created):** ```json { "id": 1, "email": "user@example.com", "first_name": "John", "last_name": "Doe" } ``` **Error response (400 Bad Request):** ```json { "email": ["A user with this email already exists."], "password": ["Password must be at least 8 characters."], "password_confirm": ["Passwords do not match."] } ``` #### `POST /api/auth/login/` Authenticates existing user and returns JWT tokens. **Request:** ```json { "email": "user@example.com", "password": "securePass123" } ``` **Validation rules:** - Both `email` and `password` are required - Invalid credentials return generic "Invalid email or password" (no user enumeration) - Inactive accounts are rejected with "This account is inactive" **Success response (200 OK):** ```json { "user": { "id": 1, "email": "user@example.com", "first_name": "John", "last_name": "Doe" }, "access": "eyJ0eXAiOiJKV1Qi...", "refresh": "eyJ0eXAiOiJKV1Qi..." } ``` **Error response (401 Unauthorized):** ```json { "non_field_errors": ["Invalid email or password."] } ``` ### Django REST Framework Configuration - **Auth classes:** `JWTAuthentication` (from `rest_framework_simplejwt`) - **Default permissions:** `AllowAny` (auth endpoints are public by design) - **Renderer:** `JSONRenderer` only (no browsable API in production) - **SimpleJWT config:** Access token TTL = 24h, Refresh token TTL = 30d, Auth header type = `Bearer` --- ## Frontend Specification ### Component Tree ``` App ├── BrowserRouter │ └── AuthProvider (context) │ ├── Route "/" → HomePage │ ├── Route "/login" → LoginPage │ └── Route "/register" → RegisterPage ``` ### AuthContext (`contexts/AuthContext.tsx`) **State shape:** ```typescript interface AuthState { user: UserProfile | null; isAuthenticated: boolean; isLoading: boolean; error: string | null; } ``` **Actions (via `useReducer`):** | Action | Trigger | State change | |---|---|---| | `AUTH_START` | API call initiated | `isLoading=true`, `error=null` | | `AUTH_SUCCESS` | Login succeeds | `isLoading=false`, `isAuthenticated=true`, `user=payload` | | `AUTH_FAILURE` | API error | `isLoading=false`, `error=payload` | | `LOGOUT` | User clicks sign out | Reset to `initialState`, clear localStorage tokens | | `CLEAR_ERROR` | User dismisses / new attempt | `error=null` | **Exposed methods:** `login(payload)`, `register(payload)`, `logout()`, `clearError()` **Key behavior:** - `login()`: stores `access_token` and `refresh_token` in `localStorage`, dispatches `AUTH_SUCCESS`, redirects to HomePage - `register()`: calls registration API, dispatches `AUTH_START`/`AUTH_FAILURE` only — does NOT auto-authenticate; the RegisterPage handles redirect to /login - `logout()`: clears localStorage tokens, dispatches `LOGOUT` ### Page Flows #### Registration Flow 1. User fills form (first_name, last_name, email, password, password_confirm) 2. Client-side: email format, password min 8 chars, password match 3. `POST /api/auth/register/` 4. On success → redirect to `/login` with success message 5. On error → display server-side validation errors #### Login Flow 1. User fills form (email, password) 2. `POST /api/auth/login/` 3. On success → store JWT tokens in localStorage, redirect to `/` 4. On error → display "Invalid email or password" ### API Service (`services/authApi.ts`) Axios-based HTTP client with: - **Base URL:** from `VITE_API_URL` env var (default: `http://localhost:8000`) - **Auth interceptor:** auto-attaches `Bearer ` from localStorage on all requests - **Functions:** - `registerUser(payload: RegisterPayload): Promise` - `loginUser(payload: LoginPayload): Promise` ### Environment Variables | Variable | Default | Description | |---|---|---| | `VITE_API_URL` | `http://localhost:8000` | Django API base URL | --- ## Validation Summary | Check | Location | Implementation | |---|---|---| | Email format | Frontend + Backend | Regex on serializer (backend), `type="email"` on input (frontend) | | Email uniqueness | Backend only | `User.objects.filter(email__iexact=...).exists()` in serializer | | Password length | Backend + Frontend | `len(value) < 8` in serializer, `minLength={8}` on input (frontend) | | Password match | Backend + Frontend | Serializer `validate()` method, `===` check in client form (implicit via payload) | | Required fields | Backend + Frontend | `serializers.CharField(required=True)` + `required` attribute on inputs | --- ## Security Considerations - Passwords hashed using Django's PBKDF2HMAC (adaptive, recommended by OWASP) - No user enumeration on login (generic "Invalid email or password" message) - Inactive accounts rejected at login time - User emails normalized to lowercase before storage - JWT tokens stored in localStorage (trade-off: simple SPA integration vs XSS exposure — refresh tokens mitigate short exposure) - `AUTH_HEADER_TYPES` set to `("Bearer",)` following RFC 6750 --- ## Post-Merge Fixes (PR #14) **Issue:** Issue #3 was re-opened after initial merge. Root cause: frontend error handling was using `err.message` from AxiosError objects, which produces `"Request failed with status code 400"` instead of the actual backend validation errors. ### Changes | Area | Change | Detail | |---|---|---| | `api/accounts/urls.py` | Added `token/refresh/` endpoint | Maps to `rest_framework_simplejwt.views.TokenRefreshView` | | `web/src/services/authApi.ts` | **Error extraction** | New `extractErrorMessage()` function parses `err.response.data` — handles `non_field_errors`, `detail`, and field-level error arrays | | `web/src/services/authApi.ts` | **Token refresh interceptor** | Axios response interceptor intercepts 401, refreshes access token using stored refresh_token, replays queued requests | | `web/src/services/authApi.ts` | Export `setTokens`, `clearTokens`, `getAccessToken` | Shared token utility functions for AuthContext | | `web/src/contexts/AuthContext.tsx` | **Fixed error handling** | Uses `extractErrorMessage(err)` instead of `err instanceof Error ? err.message : ...` | | `web/src/contexts/AuthContext.tsx` | **Auth state restoration** | `RESTORE_COMPLETE` action; `isRestoring` flag prevents protected route flash on page refresh | | `web/src/contexts/AuthContext.tsx` | **Persist user data** | Stores `user_data` in localStorage alongside tokens; restores `UserProfile` on page reload | | `web/src/components/ProtectedRoute.tsx` | New component | Route guard — redirects to `/login` if unauthenticated; shows loading spinner during restoration | | `web/src/App.tsx` | Route restructure | Public routes (`/login`, `/register`) outside ProtectedRoute; protected routes (`/`) inside it | ### Error handling flow 1. Backend returns `{"email": ["A user with this email already exists."]}` (DRF standard) 2. Axios interceptor catches non-401 responses, passes error through 3. `extractErrorMessage()` iterates `response.data` keys, returns first error string 4. AuthContext dispatches `AUTH_FAILURE` with extracted message 5. LoginPage/RegisterPage renders `state.error` — user sees: "A user with this email already exists." ### Token refresh flow 1. User logs in — `access_token` and `refresh_token` stored in localStorage 2. When access token expires, next API call receives 401 3. Response interceptor catches it, calls `POST /api/auth/token/refresh/` 4. On success — new access token stored, original request retried, queued requests replayed 5. On failure — tokens cleared, all pending requests rejected with "Session expired"