6.9 KiB
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:
{
"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 lowercasepassword: minimum 8 characterspassword_confirm: must matchpasswordfirst_name,last_name: optional, max 150 characters
Success response (201 Created):
{
"id": 1,
"email": "user@example.com",
"first_name": "John",
"last_name": "Doe"
}
Error response (400 Bad Request):
{
"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:
{
"email": "user@example.com",
"password": "securePass123"
}
Validation rules:
- Both
emailandpasswordare 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):
{
"user": {
"id": 1,
"email": "user@example.com",
"first_name": "John",
"last_name": "Doe"
},
"access": "eyJ0eXAiOiJKV1Qi...",
"refresh": "eyJ0eXAiOiJKV1Qi..."
}
Error response (401 Unauthorized):
{
"non_field_errors": ["Invalid email or password."]
}
Django REST Framework Configuration
- Auth classes:
JWTAuthentication(fromrest_framework_simplejwt) - Default permissions:
AllowAny(auth endpoints are public by design) - Renderer:
JSONRendereronly (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:
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(): storesaccess_tokenandrefresh_tokeninlocalStorage, dispatchesAUTH_SUCCESS, redirects to HomePageregister(): calls registration API, dispatchesAUTH_START/AUTH_FAILUREonly — does NOT auto-authenticate; the RegisterPage handles redirect to /loginlogout(): clears localStorage tokens, dispatchesLOGOUT
Page Flows
Registration Flow
- User fills form (first_name, last_name, email, password, password_confirm)
- Client-side: email format, password min 8 chars, password match
POST /api/auth/register/- On success → redirect to
/loginwith success message - On error → display server-side validation errors
Login Flow
- User fills form (email, password)
POST /api/auth/login/- On success → store JWT tokens in localStorage, redirect to
/ - On error → display "Invalid email or password"
API Service (services/authApi.ts)
Axios-based HTTP client with:
- Base URL: from
VITE_API_URLenv 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_TYPESset to("Bearer",)following RFC 6750