From fc0531395d3ea38819673c1c98c23fca56b513fa Mon Sep 17 00:00:00 2001 From: "Marko (Hermes Implementer)" Date: Tue, 26 May 2026 04:35:27 +0000 Subject: [PATCH] feat: complete user authentication with separate login and register pages --- api/project/settings.py | 9 +- docker-compose.yml | 2 +- docs/auth-implementation.md | 224 +++++++++++++++++++++++++++++++ web/.env.example | 2 + web/src/contexts/AuthContext.tsx | 6 +- web/src/pages/LoginPage.tsx | 16 ++- 6 files changed, 254 insertions(+), 5 deletions(-) create mode 100644 docs/auth-implementation.md create mode 100644 web/.env.example diff --git a/api/project/settings.py b/api/project/settings.py index d226736..36db4c1 100644 --- a/api/project/settings.py +++ b/api/project/settings.py @@ -14,9 +14,12 @@ DEBUG = os.environ.get("DJANGO_DEBUG", "True").lower() in ("true", "1", "yes") ALLOWED_HOSTS: list[str] = ["*"] INSTALLED_APPS = [ - "django.contrib.contenttypes", + "django.contrib.admin", "django.contrib.auth", + "django.contrib.contenttypes", "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", "rest_framework", "accounts", ] @@ -27,6 +30,7 @@ MIDDLEWARE = [ "django.middleware.common.CommonMiddleware", "django.middleware.csrf.CsrfViewMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", ] @@ -42,6 +46,7 @@ TEMPLATES = [ "django.template.context_processors.debug", "django.template.context_processors.request", "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", ], }, }, @@ -99,4 +104,6 @@ TIME_ZONE = "UTC" USE_I18N = True USE_TZ = True +STATIC_URL = "static/" + DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" diff --git a/docker-compose.yml b/docker-compose.yml index d3ae098..c227958 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -39,7 +39,7 @@ services: ports: - "3000:3000" environment: - REACT_APP_API_URL: "http://localhost:8000" + VITE_API_URL: "http://localhost:8000" volumes: - ./web:/app - /app/node_modules diff --git a/docs/auth-implementation.md b/docs/auth-implementation.md new file mode 100644 index 0000000..03d5489 --- /dev/null +++ b/docs/auth-implementation.md @@ -0,0 +1,224 @@ +# 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 \ No newline at end of file diff --git a/web/.env.example b/web/.env.example new file mode 100644 index 0000000..2782dab --- /dev/null +++ b/web/.env.example @@ -0,0 +1,2 @@ +# API base URL for the Django backend +VITE_API_URL=http://localhost:8000 \ No newline at end of file diff --git a/web/src/contexts/AuthContext.tsx b/web/src/contexts/AuthContext.tsx index 55a3a6f..4daf8d1 100644 --- a/web/src/contexts/AuthContext.tsx +++ b/web/src/contexts/AuthContext.tsx @@ -98,8 +98,10 @@ export function AuthProvider({ children }: { children: ReactNode }) { const register = useCallback(async (payload: RegisterPayload) => { dispatch({ type: "AUTH_START" }); try { - const user = await registerUser(payload); - dispatch({ type: "AUTH_SUCCESS", payload: user }); + await registerUser(payload); + // Registration succeeded — the page component handles redirect to /login. + // Set isLoading=false by clearing auth state (no auto-authentication). + dispatch({ type: "LOGOUT" }); } catch (err: unknown) { const message = err instanceof Error diff --git a/web/src/pages/LoginPage.tsx b/web/src/pages/LoginPage.tsx index 1610c27..dbd115a 100644 --- a/web/src/pages/LoginPage.tsx +++ b/web/src/pages/LoginPage.tsx @@ -1,11 +1,15 @@ import { useState, type FormEvent, type ChangeEvent } from "react"; -import { useNavigate, Link } from "react-router-dom"; +import { useNavigate, useLocation, Link } from "react-router-dom"; import { useAuth } from "../contexts/AuthContext"; export default function LoginPage() { const navigate = useNavigate(); + const location = useLocation(); const { state, login, clearError } = useAuth(); + const successMessage = (location.state as { message?: string } | null) + ?.message; + const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); @@ -35,6 +39,7 @@ export default function LoginPage() {

Welcome back to Job Tracker

{state.error &&
{state.error}
} + {successMessage &&
{successMessage}
}
@@ -165,6 +170,15 @@ const styles: Record = { fontSize: "0.85rem", marginBottom: "0.5rem", }, + success: { + backgroundColor: "#f0fdf4", + color: "#166534", + border: "1px solid #bbf7d0", + borderRadius: "6px", + padding: "0.5rem 0.75rem", + fontSize: "0.85rem", + marginBottom: "0.5rem", + }, footer: { marginTop: "1.25rem", textAlign: "center",