feat: complete user authentication with separate login and register pages

This commit is contained in:
Marko (Hermes Implementer)
2026-05-26 04:35:27 +00:00
parent 6867a91b67
commit fc0531395d
6 changed files with 254 additions and 5 deletions
+8 -1
View File
@@ -14,9 +14,12 @@ DEBUG = os.environ.get("DJANGO_DEBUG", "True").lower() in ("true", "1", "yes")
ALLOWED_HOSTS: list[str] = ["*"] ALLOWED_HOSTS: list[str] = ["*"]
INSTALLED_APPS = [ INSTALLED_APPS = [
"django.contrib.contenttypes", "django.contrib.admin",
"django.contrib.auth", "django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions", "django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"rest_framework", "rest_framework",
"accounts", "accounts",
] ]
@@ -27,6 +30,7 @@ MIDDLEWARE = [
"django.middleware.common.CommonMiddleware", "django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware", "django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware",
] ]
@@ -42,6 +46,7 @@ TEMPLATES = [
"django.template.context_processors.debug", "django.template.context_processors.debug",
"django.template.context_processors.request", "django.template.context_processors.request",
"django.contrib.auth.context_processors.auth", "django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
], ],
}, },
}, },
@@ -99,4 +104,6 @@ TIME_ZONE = "UTC"
USE_I18N = True USE_I18N = True
USE_TZ = True USE_TZ = True
STATIC_URL = "static/"
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
+1 -1
View File
@@ -39,7 +39,7 @@ services:
ports: ports:
- "3000:3000" - "3000:3000"
environment: environment:
REACT_APP_API_URL: "http://localhost:8000" VITE_API_URL: "http://localhost:8000"
volumes: volumes:
- ./web:/app - ./web:/app
- /app/node_modules - /app/node_modules
+224
View File
@@ -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 <access_token>` from localStorage on all requests
- **Functions:**
- `registerUser(payload: RegisterPayload): Promise<UserProfile>`
- `loginUser(payload: LoginPayload): Promise<LoginResponse>`
### 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
+2
View File
@@ -0,0 +1,2 @@
# API base URL for the Django backend
VITE_API_URL=http://localhost:8000
+4 -2
View File
@@ -98,8 +98,10 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const register = useCallback(async (payload: RegisterPayload) => { const register = useCallback(async (payload: RegisterPayload) => {
dispatch({ type: "AUTH_START" }); dispatch({ type: "AUTH_START" });
try { try {
const user = await registerUser(payload); await registerUser(payload);
dispatch({ type: "AUTH_SUCCESS", payload: user }); // 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) { } catch (err: unknown) {
const message = const message =
err instanceof Error err instanceof Error
+15 -1
View File
@@ -1,11 +1,15 @@
import { useState, type FormEvent, type ChangeEvent } from "react"; 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"; import { useAuth } from "../contexts/AuthContext";
export default function LoginPage() { export default function LoginPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation();
const { state, login, clearError } = useAuth(); const { state, login, clearError } = useAuth();
const successMessage = (location.state as { message?: string } | null)
?.message;
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
@@ -35,6 +39,7 @@ export default function LoginPage() {
<p style={styles.subtitle}>Welcome back to Job Tracker</p> <p style={styles.subtitle}>Welcome back to Job Tracker</p>
{state.error && <div style={styles.error}>{state.error}</div>} {state.error && <div style={styles.error}>{state.error}</div>}
{successMessage && <div style={styles.success}>{successMessage}</div>}
<form onSubmit={handleSubmit} style={styles.form}> <form onSubmit={handleSubmit} style={styles.form}>
<div style={styles.field}> <div style={styles.field}>
@@ -165,6 +170,15 @@ const styles: Record<string, React.CSSProperties> = {
fontSize: "0.85rem", fontSize: "0.85rem",
marginBottom: "0.5rem", 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: { footer: {
marginTop: "1.25rem", marginTop: "1.25rem",
textAlign: "center", textAlign: "center",