feat: add user authentication with separate login and register pages

- Custom User model with email as unique identifier (AUTH_USER_MODEL)

- POST /api/auth/register/ with email validation, password min 8 chars, duplicate rejection

- POST /api/auth/login/ returning JWT (access + refresh) tokens

- Passwords hashed via Django's make_password

- React LoginPage and RegisterPage with form validation

- AuthContext with useReducer for auth state management

- Axios API client with JWT token injection

- TypeScript conversion of frontend scaffold
This commit is contained in:
Marko (Hermes Implementer)
2026-05-26 00:54:27 +00:00
parent b91b7c364e
commit 6867a91b67
28 changed files with 3313 additions and 55 deletions
View File
+39
View File
@@ -0,0 +1,39 @@
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
from accounts.models import User
@admin.register(User)
class UserAdmin(BaseUserAdmin):
"""Admin config for the custom User model."""
fieldsets = (
(None, {"fields": ("email", "password")}),
("Personal info", {"fields": ("first_name", "last_name", "username")}),
(
"Permissions",
{
"fields": (
"is_active",
"is_staff",
"is_superuser",
"groups",
"user_permissions",
),
},
),
("Important dates", {"fields": ("last_login", "date_joined")}),
)
add_fieldsets = (
(
None,
{
"classes": ("wide",),
"fields": ("email", "password1", "password2"),
},
),
)
list_display = ("email", "first_name", "last_name", "is_staff")
search_fields = ("email", "first_name", "last_name")
ordering = ("email",)
+43
View File
@@ -0,0 +1,43 @@
# Generated by Django 5.2.14 on 2026-05-26 00:49
import django.contrib.auth.models
import django.utils.timezone
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('auth', '0012_alter_user_first_name_max_length'),
]
operations = [
migrations.CreateModel(
name='User',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('password', models.CharField(max_length=128, verbose_name='password')),
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')),
('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')),
('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
('email', models.EmailField(help_text='Email address used for authentication.', max_length=254, unique=True)),
('username', models.CharField(blank=True, help_text='Optional display name. Not used for authentication.', max_length=150, null=True)),
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
],
options={
'verbose_name': 'User',
'verbose_name_plural': 'Users',
'db_table': 'accounts_user',
},
managers=[
('objects', django.contrib.auth.models.UserManager()),
],
),
]
View File
+29
View File
@@ -0,0 +1,29 @@
from django.contrib.auth.models import AbstractUser
from django.db import models
class User(AbstractUser):
"""Custom User model using email as the unique identifier."""
email = models.EmailField(
unique=True,
max_length=254,
help_text="Email address used for authentication.",
)
username = models.CharField(
max_length=150,
blank=True,
null=True,
help_text="Optional display name. Not used for authentication.",
)
USERNAME_FIELD = "email"
REQUIRED_FIELDS: list[str] = []
class Meta:
db_table = "accounts_user"
verbose_name = "User"
verbose_name_plural = "Users"
def __str__(self) -> str:
return self.email
+96
View File
@@ -0,0 +1,96 @@
import re
from django.contrib.auth import authenticate
from django.contrib.auth.hashers import make_password
from django.utils.translation import gettext_lazy as _
from rest_framework import serializers
from rest_framework_simplejwt.tokens import RefreshToken
from accounts.models import User
EMAIL_REGEX: re.Pattern[str] = re.compile(r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$")
MIN_PASSWORD_LENGTH: int = 8
class RegisterSerializer(serializers.Serializer):
"""Validate and create a new user account."""
email = serializers.EmailField(max_length=254)
password = serializers.CharField(write_only=True)
password_confirm = serializers.CharField(write_only=True)
first_name = serializers.CharField(max_length=150, required=False, allow_blank=True, default="")
last_name = serializers.CharField(max_length=150, required=False, allow_blank=True, default="")
def validate_email(self, value: str) -> str:
"""Validate email format and check for duplicates."""
if not EMAIL_REGEX.match(value):
raise serializers.ValidationError("Invalid email format.")
if User.objects.filter(email__iexact=value).exists():
raise serializers.ValidationError("A user with this email already exists.")
return value.lower()
def validate_password(self, value: str) -> str:
"""Enforce minimum password length."""
if len(value) < MIN_PASSWORD_LENGTH:
raise serializers.ValidationError(
f"Password must be at least {MIN_PASSWORD_LENGTH} characters."
)
return value
def validate(self, attrs: dict[str, object]) -> dict[str, object]:
"""Ensure password and confirmation match."""
password = attrs.get("password")
password_confirm = attrs.get("password_confirm")
if password and password_confirm and password != password_confirm:
raise serializers.ValidationError(
{"password_confirm": "Passwords do not match."}
)
return attrs
def create(self, validated_data: dict[str, object]) -> User:
"""Create and return the new user."""
validated_data.pop("password_confirm")
validated_data["password"] = make_password(validated_data["password"])
# Ensure username is blank rather than None for unique constraint
validated_data.setdefault("username", "")
return User.objects.create(**validated_data)
class LoginSerializer(serializers.Serializer):
"""Authenticate user credentials and return JWT tokens."""
email = serializers.EmailField(max_length=254)
password = serializers.CharField(write_only=True)
def validate(self, attrs: dict[str, object]) -> dict[str, object]:
"""Authenticate the user and generate tokens."""
email = attrs.get("email", "")
password = attrs.get("password", "")
if not email or not password:
raise serializers.ValidationError("Both email and password are required.")
user = authenticate(
request=self.context.get("request"),
username=email,
password=password,
)
if user is None:
raise serializers.ValidationError("Invalid email or password.")
if not user.is_active:
raise serializers.ValidationError("This account is inactive.")
refresh = RefreshToken.for_user(user)
attrs["user"] = user
attrs["access"] = str(refresh.access_token)
attrs["refresh"] = str(refresh)
return attrs
class UserSerializer(serializers.ModelSerializer):
"""Public user profile serializer."""
class Meta:
model = User
fields = ("id", "email", "first_name", "last_name")
+10
View File
@@ -0,0 +1,10 @@
from django.urls import path
from accounts import views
app_name = "accounts"
urlpatterns = [
path("register/", views.register_view, name="register"),
path("login/", views.login_view, name="login"),
]
+42
View File
@@ -0,0 +1,42 @@
from typing import Any
from rest_framework import status
from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import AllowAny
from rest_framework.request import Request
from rest_framework.response import Response
from accounts.serializers import LoginSerializer, RegisterSerializer, UserSerializer
@api_view(["POST"])
@permission_classes([AllowAny])
def register_view(request: Request) -> Response:
"""Register a new user account."""
serializer = RegisterSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
user = serializer.save()
return Response(
UserSerializer(user).data,
status=status.HTTP_201_CREATED,
)
@api_view(["POST"])
@permission_classes([AllowAny])
def login_view(request: Request) -> Response:
"""Authenticate a user and return JWT tokens."""
serializer = LoginSerializer(
data=request.data,
context={"request": request},
)
serializer.is_valid(raise_exception=True)
validated_data: dict[str, Any] = serializer.validated_data
return Response(
{
"user": UserSerializer(validated_data["user"]).data,
"access": validated_data["access"],
"refresh": validated_data["refresh"],
},
status=status.HTTP_200_OK,
)