Compare commits
11
Commits
5b6f628380
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a6c14b52fd | ||
|
|
2e71eb73b8 | ||
|
|
7dde10b7ec | ||
|
|
84c6262b3d | ||
|
|
c2fc7bc15f | ||
|
|
a0b3ae7e34 | ||
|
|
48db53c95d | ||
|
|
9846f823b3 | ||
|
|
e6e6c92c28 | ||
|
|
fc0531395d | ||
|
|
6867a91b67 |
@@ -0,0 +1,49 @@
|
||||
from django.contrib import admin
|
||||
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
|
||||
|
||||
from accounts.models import BlacklistedToken, 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",)
|
||||
|
||||
|
||||
@admin.register(BlacklistedToken)
|
||||
class BlacklistedTokenAdmin(admin.ModelAdmin):
|
||||
"""Admin config for BlacklistedToken."""
|
||||
|
||||
list_display = ("jti", "user", "created_at", "expires_at")
|
||||
list_select_related = ("user",)
|
||||
search_fields = ("jti", "user__email")
|
||||
ordering = ("-created_at",)
|
||||
@@ -0,0 +1,149 @@
|
||||
import logging
|
||||
import traceback
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import PermissionDenied, ValidationError as DjangoValidationError
|
||||
from django.http import Http404
|
||||
from rest_framework import exceptions, status
|
||||
from rest_framework.exceptions import APIException
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import exception_handler as drf_exception_handler
|
||||
|
||||
logger = logging.getLogger("django.request")
|
||||
|
||||
|
||||
def _safe_error_response(
|
||||
detail: str,
|
||||
code: str,
|
||||
status_code: int,
|
||||
) -> Response:
|
||||
"""Return a consistent error response with no stack traces."""
|
||||
return Response(
|
||||
{"error": detail, "code": code},
|
||||
status=status_code,
|
||||
)
|
||||
|
||||
|
||||
def api_exception_handler(exc: Exception, context: dict) -> Response | None:
|
||||
"""
|
||||
Custom DRF exception handler that:
|
||||
- Never exposes stack traces, file paths, or Python internals
|
||||
- Maps common exceptions to user-safe messages
|
||||
- Logs full traceback to django.request logger
|
||||
- Returns consistent {error, code} format
|
||||
"""
|
||||
# Always log the full traceback
|
||||
logger.error(
|
||||
"API Exception: %s: %s\n%s",
|
||||
type(exc).__name__,
|
||||
exc,
|
||||
"".join(traceback.format_tb(exc.__traceback__)),
|
||||
)
|
||||
|
||||
# PermissionDenied -> 403
|
||||
if isinstance(exc, PermissionDenied):
|
||||
return _safe_error_response(
|
||||
"You do not have permission to perform this action.",
|
||||
"permission_denied",
|
||||
status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
# Http404 -> 404
|
||||
if isinstance(exc, Http404):
|
||||
return _safe_error_response(
|
||||
"The requested resource was not found.",
|
||||
"not_found",
|
||||
status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
|
||||
# Django ValidationError -> 400
|
||||
if isinstance(exc, DjangoValidationError):
|
||||
return _safe_error_response(
|
||||
str(exc) if isinstance(exc.message, str) else "Validation error.",
|
||||
"validation_error",
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# DRF APIException (includes AuthenticationFailed, NotAuthenticated, ParseError, etc.)
|
||||
if isinstance(exc, APIException):
|
||||
# Use DRF's standard handling but map to our format
|
||||
response = drf_exception_handler(exc, context)
|
||||
if response is not None:
|
||||
# Ensure response is our safe format
|
||||
detail = _extract_detail(response.data)
|
||||
return _safe_error_response(
|
||||
detail,
|
||||
_get_error_code(exc),
|
||||
response.status_code,
|
||||
)
|
||||
|
||||
# DRF Throttled
|
||||
if isinstance(exc, exceptions.Throttled):
|
||||
return _safe_error_response(
|
||||
"Request rate limit exceeded. Please try again later.",
|
||||
"throttled",
|
||||
exc.status_code,
|
||||
)
|
||||
|
||||
# AuthenticationFailed / NotAuthenticated
|
||||
if isinstance(exc, exceptions.AuthenticationFailed):
|
||||
return _safe_error_response(
|
||||
str(exc.detail) if hasattr(exc, "detail") else "Authentication failed.",
|
||||
"authentication_failed",
|
||||
status.HTTP_401_UNAUTHORIZED,
|
||||
)
|
||||
|
||||
if isinstance(exc, exceptions.NotAuthenticated):
|
||||
return _safe_error_response(
|
||||
"Authentication credentials were not provided.",
|
||||
"not_authenticated",
|
||||
status.HTTP_401_UNAUTHORIZED,
|
||||
)
|
||||
|
||||
# Catch-all for unhandled exceptions
|
||||
if not settings.DEBUG:
|
||||
return _safe_error_response(
|
||||
"Internal server error.",
|
||||
"internal_error",
|
||||
status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
|
||||
# In DEBUG mode, let DRF's default handler show the traceback
|
||||
return drf_exception_handler(exc, context)
|
||||
|
||||
|
||||
def _extract_detail(data: dict | list | str) -> str:
|
||||
"""Extract the first meaningful error string from DRF error data."""
|
||||
if isinstance(data, str):
|
||||
return data
|
||||
if isinstance(data, list):
|
||||
for item in data:
|
||||
result = _extract_detail(item)
|
||||
if result:
|
||||
return result
|
||||
if isinstance(data, dict):
|
||||
# Try 'detail' first, then first field error
|
||||
if "detail" in data:
|
||||
return _extract_detail(data["detail"])
|
||||
for _key, value in data.items():
|
||||
result = _extract_detail(value)
|
||||
if result:
|
||||
return result
|
||||
return "An error occurred."
|
||||
|
||||
|
||||
def _get_error_code(exc: APIException) -> str:
|
||||
"""Map exception class to a stable error code string."""
|
||||
mapping: dict[type, str] = {
|
||||
exceptions.AuthenticationFailed: "authentication_failed",
|
||||
exceptions.NotAuthenticated: "not_authenticated",
|
||||
exceptions.PermissionDenied: "permission_denied",
|
||||
exceptions.NotFound: "not_found",
|
||||
exceptions.MethodNotAllowed: "method_not_allowed",
|
||||
exceptions.NotAcceptable: "not_acceptable",
|
||||
exceptions.UnsupportedMediaType: "unsupported_media_type",
|
||||
exceptions.Throttled: "throttled",
|
||||
exceptions.ParseError: "parse_error",
|
||||
exceptions.ValidationError: "validation_error",
|
||||
}
|
||||
return mapping.get(type(exc), "error")
|
||||
@@ -0,0 +1,24 @@
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
class SecurityHeadersMiddleware:
|
||||
"""Add security-related HTTP headers to every response."""
|
||||
|
||||
def __init__(self, get_response):
|
||||
self.get_response = get_response
|
||||
|
||||
def __call__(self, request):
|
||||
response = self.get_response(request)
|
||||
|
||||
if not settings.DEBUG:
|
||||
response["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
|
||||
|
||||
response.setdefault("X-Content-Type-Options", "nosniff")
|
||||
response.setdefault("X-Frame-Options", "DENY")
|
||||
response.setdefault("Referrer-Policy", "strict-origin-when-cross-origin")
|
||||
response.setdefault(
|
||||
"Permissions-Policy",
|
||||
"geolocation=(), microphone=(), camera=()",
|
||||
)
|
||||
|
||||
return response
|
||||
@@ -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()),
|
||||
],
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,31 @@
|
||||
# Generated by Django 5.1.7 on 2026-05-27 00:22
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('accounts', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='BlacklistedToken',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('jti', models.UUIDField(help_text='JWT ID from the token payload', unique=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('expires_at', models.DateTimeField(help_text='When the token would have naturally expired')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='blacklisted_tokens', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Blacklisted Token',
|
||||
'verbose_name_plural': 'Blacklisted Tokens',
|
||||
'db_table': 'accounts_blacklisted_token',
|
||||
'indexes': [models.Index(fields=['jti'], name='accounts_bl_jti_88a727_idx'), models.Index(fields=['expires_at'], name='accounts_bl_expires_1a061a_idx')],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,59 @@
|
||||
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
|
||||
|
||||
@property
|
||||
def is_admin(self) -> bool:
|
||||
"""Check if user belongs to the admin group."""
|
||||
return self.groups.filter(name="admin").exists() or self.is_superuser
|
||||
|
||||
|
||||
class BlacklistedToken(models.Model):
|
||||
"""Stores blacklisted JWT tokens for revocation."""
|
||||
|
||||
jti = models.UUIDField(unique=True, help_text="JWT ID from the token payload")
|
||||
user = models.ForeignKey(
|
||||
User,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="blacklisted_tokens",
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
expires_at = models.DateTimeField(help_text="When the token would have naturally expired")
|
||||
|
||||
class Meta:
|
||||
db_table = "accounts_blacklisted_token"
|
||||
verbose_name = "Blacklisted Token"
|
||||
verbose_name_plural = "Blacklisted Tokens"
|
||||
indexes = [
|
||||
models.Index(fields=["jti"]),
|
||||
models.Index(fields=["expires_at"]),
|
||||
]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"Blacklisted {self.jti} (user {self.user_id})"
|
||||
@@ -0,0 +1,33 @@
|
||||
from rest_framework.permissions import SAFE_METHODS, BasePermission
|
||||
|
||||
|
||||
class IsAdminOrReadOnly(BasePermission):
|
||||
"""Admin users can perform any action; others can only read."""
|
||||
|
||||
def has_permission(self, request, view):
|
||||
if not request.user or not request.user.is_authenticated:
|
||||
return False
|
||||
if request.method in SAFE_METHODS:
|
||||
return True
|
||||
return request.user.is_admin
|
||||
|
||||
def has_object_permission(self, request, view, obj):
|
||||
if request.method in SAFE_METHODS:
|
||||
return True
|
||||
return request.user.is_admin
|
||||
|
||||
|
||||
class IsOwnerOrAdmin(BasePermission):
|
||||
"""Users can only access their own resources; admins can access all."""
|
||||
|
||||
def has_permission(self, request, view):
|
||||
return bool(request.user and request.user.is_authenticated)
|
||||
|
||||
def has_object_permission(self, request, view, obj):
|
||||
if request.user.is_admin:
|
||||
return True
|
||||
# Expect obj to have a `user` FK pointing to the owning user
|
||||
user_field = getattr(obj, "user", None)
|
||||
if user_field is not None:
|
||||
return user_field == request.user
|
||||
return False
|
||||
@@ -0,0 +1,137 @@
|
||||
from typing import Any
|
||||
|
||||
import re
|
||||
|
||||
from django.contrib.auth import authenticate
|
||||
from django.contrib.auth.hashers import make_password
|
||||
from django.contrib.auth.password_validation import validate_password
|
||||
from django.core.exceptions import ValidationError as DjangoValidationError
|
||||
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 SanitizedCharField(serializers.CharField):
|
||||
"""CharField that strips control characters on deserialization."""
|
||||
|
||||
def to_internal_value(self, data: object) -> object:
|
||||
value = super().to_internal_value(data)
|
||||
if isinstance(value, str):
|
||||
value = value.strip()
|
||||
if "\x00" in value:
|
||||
raise serializers.ValidationError("Input contains invalid characters.")
|
||||
value = re.sub(r"[\x00-\x08\x0b\x0c\x0e-\x1f]", "", value)
|
||||
return value
|
||||
|
||||
|
||||
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 = SanitizedCharField(max_length=150, required=False, allow_blank=True, default="")
|
||||
last_name = SanitizedCharField(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 and Django validators."""
|
||||
if len(value) < MIN_PASSWORD_LENGTH:
|
||||
raise serializers.ValidationError(
|
||||
f"Password must be at least {MIN_PASSWORD_LENGTH} characters."
|
||||
)
|
||||
try:
|
||||
validate_password(value)
|
||||
except DjangoValidationError as e:
|
||||
raise serializers.ValidationError(" ".join(e.messages))
|
||||
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"])
|
||||
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 — limited fields, no email."""
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = ("id", "first_name", "last_name")
|
||||
|
||||
|
||||
class RefreshRequestSerializer(serializers.Serializer):
|
||||
"""Validate a refresh token request."""
|
||||
|
||||
refresh = serializers.CharField(required=True)
|
||||
|
||||
def validate_refresh(self, value: str) -> str:
|
||||
if not value or not value.strip():
|
||||
raise serializers.ValidationError("Refresh token is required.")
|
||||
return value.strip()
|
||||
|
||||
|
||||
class LogoutSerializer(serializers.Serializer):
|
||||
"""Validate a logout request."""
|
||||
|
||||
refresh = serializers.CharField(required=True)
|
||||
|
||||
def validate_refresh(self, value: str) -> str:
|
||||
if not value or not value.strip():
|
||||
raise serializers.ValidationError("Refresh token is required.")
|
||||
return value.strip()
|
||||
@@ -0,0 +1,12 @@
|
||||
from rest_framework.throttling import SimpleRateThrottle
|
||||
|
||||
|
||||
class UserRateThrottle(SimpleRateThrottle):
|
||||
"""Throttle authenticated requests by user ID."""
|
||||
|
||||
scope = "user"
|
||||
|
||||
def get_cache_key(self, request, view):
|
||||
if request.user and request.user.is_authenticated:
|
||||
return self.cache_format % {"scope": self.scope, "ident": request.user.pk}
|
||||
return None # AnonRateThrottle handles anonymous requests
|
||||
@@ -0,0 +1,15 @@
|
||||
from django.urls import path
|
||||
from rest_framework_simplejwt.views import TokenVerifyView
|
||||
|
||||
from accounts import views
|
||||
|
||||
app_name = "accounts"
|
||||
|
||||
urlpatterns = [
|
||||
path("register/", views.register_view, name="register"),
|
||||
path("login/", views.login_view, name="login"),
|
||||
path("token/refresh/", views.token_refresh_view, name="token-refresh"),
|
||||
path("token/verify/", TokenVerifyView.as_view(), name="token-verify"),
|
||||
path("logout/", views.logout_view, name="logout"),
|
||||
path("me/", views.me_view, name="me"),
|
||||
]
|
||||
@@ -0,0 +1,121 @@
|
||||
from typing import Any
|
||||
|
||||
from rest_framework import status
|
||||
from rest_framework.decorators import api_view, permission_classes, throttle_classes
|
||||
from rest_framework.permissions import AllowAny
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.throttling import AnonRateThrottle
|
||||
from rest_framework_simplejwt.exceptions import TokenError
|
||||
from rest_framework_simplejwt.tokens import RefreshToken
|
||||
|
||||
from accounts.serializers import (
|
||||
LoginSerializer,
|
||||
LogoutSerializer,
|
||||
RefreshRequestSerializer,
|
||||
RegisterSerializer,
|
||||
UserSerializer,
|
||||
)
|
||||
|
||||
|
||||
class AuthRateThrottle(AnonRateThrottle):
|
||||
scope = "auth"
|
||||
|
||||
|
||||
@api_view(["POST"])
|
||||
@permission_classes([AllowAny])
|
||||
@throttle_classes([AuthRateThrottle])
|
||||
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])
|
||||
@throttle_classes([AuthRateThrottle])
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
@api_view(["POST"])
|
||||
@permission_classes([AllowAny])
|
||||
@throttle_classes([AuthRateThrottle])
|
||||
def token_refresh_view(request: Request) -> Response:
|
||||
"""Refresh an access token using a refresh token.
|
||||
|
||||
Uses SimpleJWT's built-in rotation and blacklisting.
|
||||
"""
|
||||
serializer = RefreshRequestSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
refresh_token_str: str = serializer.validated_data["refresh"]
|
||||
try:
|
||||
refresh = RefreshToken(refresh_token_str)
|
||||
access = str(refresh.access_token)
|
||||
new_refresh = str(refresh)
|
||||
except TokenError as e:
|
||||
return Response(
|
||||
{"error": str(e), "code": "token_invalid"},
|
||||
status=status.HTTP_401_UNAUTHORIZED,
|
||||
)
|
||||
|
||||
return Response(
|
||||
{"access": access, "refresh": new_refresh},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
|
||||
@api_view(["POST"])
|
||||
@permission_classes([AllowAny])
|
||||
def logout_view(request: Request) -> Response:
|
||||
"""Blacklist a refresh token (log out).
|
||||
|
||||
This allows explicit token revocation on logout.
|
||||
"""
|
||||
serializer = LogoutSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
refresh_token_str: str = serializer.validated_data["refresh"]
|
||||
try:
|
||||
refresh = RefreshToken(refresh_token_str)
|
||||
refresh.blacklist()
|
||||
except TokenError:
|
||||
# If token is already invalid/blacklisted, still consider logout successful
|
||||
pass
|
||||
except AttributeError:
|
||||
# If blacklist app not installed
|
||||
pass
|
||||
|
||||
return Response(
|
||||
{"message": "Successfully logged out."},
|
||||
status=status.HTTP_205_RESET_CONTENT,
|
||||
)
|
||||
|
||||
|
||||
@api_view(["GET"])
|
||||
def me_view(request: Request) -> Response:
|
||||
"""Return the current authenticated user's profile."""
|
||||
return Response(
|
||||
UserSerializer(request.user).data,
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
@@ -0,0 +1,62 @@
|
||||
# Generated by Django 5.1.7 on 2026-05-27 00:22
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def assign_existing_applications_to_first_superuser(apps, schema_editor):
|
||||
"""Assign existing JobApplication rows to the first superuser."""
|
||||
JobApplication = apps.get_model("jobs", "JobApplication")
|
||||
User = apps.get_model("accounts", "User")
|
||||
admin = User.objects.filter(is_superuser=True).order_by("id").first()
|
||||
if admin is not None:
|
||||
JobApplication.objects.filter(user__isnull=True).update(user=admin)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('jobs', '0001_initial'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
# 1. Add user FK as nullable initially so existing rows can be migrated
|
||||
migrations.AddField(
|
||||
model_name='jobapplication',
|
||||
name='user',
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name='job_applications',
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
help_text='User who owns this job application.',
|
||||
),
|
||||
),
|
||||
# 2. Assign existing rows to the first superuser
|
||||
migrations.RunPython(
|
||||
assign_existing_applications_to_first_superuser,
|
||||
reverse_code=migrations.RunPython.noop,
|
||||
),
|
||||
# 3. Make user non-nullable now that all rows have a value
|
||||
migrations.AlterField(
|
||||
model_name='jobapplication',
|
||||
name='user',
|
||||
field=models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name='job_applications',
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
help_text='User who owns this job application.',
|
||||
),
|
||||
),
|
||||
# 4. Add the unique constraint
|
||||
migrations.AddConstraint(
|
||||
model_name='jobapplication',
|
||||
constraint=models.UniqueConstraint(
|
||||
fields=('user', 'company_name', 'position_title'),
|
||||
name='unique_user_job_application',
|
||||
),
|
||||
),
|
||||
]
|
||||
+15
-1
@@ -1,5 +1,7 @@
|
||||
from django.db import models
|
||||
|
||||
from accounts.models import User
|
||||
|
||||
|
||||
class StatusChoices(models.TextChoices):
|
||||
APPLIED = "APPLIED", "Applied"
|
||||
@@ -11,6 +13,12 @@ class StatusChoices(models.TextChoices):
|
||||
|
||||
|
||||
class JobApplication(models.Model):
|
||||
user = models.ForeignKey(
|
||||
User,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="job_applications",
|
||||
help_text="User who owns this job application.",
|
||||
)
|
||||
company_name = models.CharField(max_length=255)
|
||||
position_title = models.CharField(max_length=255)
|
||||
status = models.CharField(
|
||||
@@ -24,6 +32,12 @@ class JobApplication(models.Model):
|
||||
|
||||
class Meta:
|
||||
ordering = ["-updated_at"]
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["user", "company_name", "position_title"],
|
||||
name="unique_user_job_application",
|
||||
)
|
||||
]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.company_name} - {self.position_title}"
|
||||
@@ -57,4 +71,4 @@ class JobUpdate(models.Model):
|
||||
verbose_name_plural = "Job Updates"
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"Update #{self.id}: {self.job_application.company_name} → {self.to_status}"
|
||||
return f"Update #{self.id}: {self.job_application.company_name} -> {self.to_status}"
|
||||
@@ -3,6 +3,20 @@ from rest_framework import serializers
|
||||
from .models import JobApplication, JobUpdate
|
||||
|
||||
|
||||
class SanitizedCharField(serializers.CharField):
|
||||
"""CharField that strips control characters on deserialization."""
|
||||
|
||||
def to_internal_value(self, data: object) -> object:
|
||||
value = super().to_internal_value(data)
|
||||
if isinstance(value, str):
|
||||
value = value.strip()
|
||||
if "\x00" in value:
|
||||
raise serializers.ValidationError("Input contains invalid characters.")
|
||||
import re
|
||||
value = re.sub(r"[\x00-\x08\x0b\x0c\x0e-\x1f]", "", value)
|
||||
return value
|
||||
|
||||
|
||||
class JobUpdateSerializer(serializers.ModelSerializer):
|
||||
company_name = serializers.CharField(source="job_application.company_name", read_only=True)
|
||||
position_title = serializers.CharField(source="job_application.position_title", read_only=True)
|
||||
@@ -24,10 +38,16 @@ class JobUpdateSerializer(serializers.ModelSerializer):
|
||||
|
||||
|
||||
class JobApplicationSerializer(serializers.ModelSerializer):
|
||||
user_id = serializers.IntegerField(read_only=True)
|
||||
notes = SanitizedCharField(required=False, allow_blank=True, default="")
|
||||
company_name = SanitizedCharField(max_length=255)
|
||||
position_title = SanitizedCharField(max_length=255)
|
||||
|
||||
class Meta:
|
||||
model = JobApplication
|
||||
fields = [
|
||||
"id",
|
||||
"user_id",
|
||||
"company_name",
|
||||
"position_title",
|
||||
"status",
|
||||
@@ -35,6 +55,11 @@ class JobApplicationSerializer(serializers.ModelSerializer):
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
read_only_fields = ["user_id"]
|
||||
|
||||
def create(self, validated_data):
|
||||
validated_data["user"] = self.context["request"].user
|
||||
return super().create(validated_data)
|
||||
|
||||
|
||||
class DashboardMetricsSerializer(serializers.Serializer):
|
||||
|
||||
+29
-7
@@ -1,7 +1,7 @@
|
||||
from django.db.models import Count, Q
|
||||
from rest_framework import viewsets
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
|
||||
@@ -14,16 +14,30 @@ from .serializers import (
|
||||
|
||||
|
||||
class JobApplicationViewSet(viewsets.ModelViewSet):
|
||||
queryset = JobApplication.objects.all().prefetch_related("updates")
|
||||
serializer_class = JobApplicationSerializer
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get_queryset(self):
|
||||
"""Filter by user for non-admin users; admins see all."""
|
||||
user = self.request.user
|
||||
qs = JobApplication.objects.all().prefetch_related("updates")
|
||||
if not user.is_admin:
|
||||
qs = qs.filter(user=user)
|
||||
return qs
|
||||
|
||||
|
||||
class JobUpdateViewSet(viewsets.ModelViewSet):
|
||||
queryset = JobUpdate.objects.select_related("job_application").all()
|
||||
serializer_class = JobUpdateSerializer
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get_queryset(self):
|
||||
"""Filter by user via job_application for non-admin users."""
|
||||
user = self.request.user
|
||||
qs = JobUpdate.objects.select_related("job_application").all()
|
||||
if not user.is_admin:
|
||||
qs = qs.filter(job_application__user=user)
|
||||
return qs
|
||||
|
||||
@action(detail=False, methods=["get"])
|
||||
def latest(self, request: Request) -> Response:
|
||||
"""Return the 3 most recent job updates with job info and metrics."""
|
||||
@@ -38,16 +52,24 @@ class JobUpdateViewSet(viewsets.ModelViewSet):
|
||||
@action(detail=False, methods=["get"])
|
||||
def metrics(self, request: Request) -> Response:
|
||||
"""Return dashboard metrics: total apps, status breakdown, interview/offer counts."""
|
||||
total = JobApplication.objects.count()
|
||||
qs = self.get_queryset()
|
||||
|
||||
total = qs.values("job_application").distinct().count()
|
||||
# Get status counts from the distinct job applications the user owns
|
||||
app_qs = JobApplication.objects.all()
|
||||
if not request.user.is_admin:
|
||||
app_qs = app_qs.filter(user=request.user)
|
||||
|
||||
total = app_qs.count()
|
||||
status_counts = dict(
|
||||
JobApplication.objects.values("status")
|
||||
app_qs.values("status")
|
||||
.annotate(count=Count("id"))
|
||||
.values_list("status", "count")
|
||||
)
|
||||
interview_count = JobApplication.objects.filter(
|
||||
interview_count = app_qs.filter(
|
||||
Q(status="INTERVIEW") | Q(status="OFFER")
|
||||
).count()
|
||||
offer_count = JobApplication.objects.filter(status="OFFER").count()
|
||||
offer_count = app_qs.filter(status="OFFER").count()
|
||||
rejection_rate = (
|
||||
round(
|
||||
status_counts.get("REJECTED", 0) / total * 100, 1
|
||||
|
||||
+56
-18
@@ -1,5 +1,6 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
from datetime import timedelta
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
|
||||
@@ -15,20 +16,23 @@ ALLOWED_HOSTS: list[str] = ["*"]
|
||||
INSTALLED_APPS = [
|
||||
"django.contrib.contenttypes",
|
||||
"django.contrib.auth",
|
||||
"django.contrib.sessions",
|
||||
"django.contrib.admin",
|
||||
"django.contrib.sessions",
|
||||
"django.contrib.messages",
|
||||
"django.contrib.staticfiles",
|
||||
# Third-party
|
||||
"rest_framework",
|
||||
"rest_framework_simplejwt.token_blacklist",
|
||||
"corsheaders",
|
||||
# Local
|
||||
"accounts",
|
||||
"jobs",
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
"corsheaders.middleware.CorsMiddleware",
|
||||
"django.middleware.security.SecurityMiddleware",
|
||||
"accounts.middleware.SecurityHeadersMiddleware",
|
||||
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||
"django.middleware.common.CommonMiddleware",
|
||||
"django.middleware.csrf.CsrfViewMiddleware",
|
||||
@@ -63,7 +67,7 @@ _database_url = os.environ.get(
|
||||
"postgres://jobtracker:***@localhost:5432/jobtracker",
|
||||
)
|
||||
|
||||
# postgres://user:***@host:port/dbname → django settings dict
|
||||
# postgres://user:***@host:port/dbname -> django settings dict
|
||||
_db_parts = _database_url.replace("postgres://", "").split("@")
|
||||
_credentials, _host_db = _db_parts[0], _db_parts[1]
|
||||
_db_user, _db_pass = _credentials.split(":")
|
||||
@@ -82,12 +86,51 @@ DATABASES = {
|
||||
}
|
||||
}
|
||||
|
||||
LANGUAGE_CODE = "en-us"
|
||||
TIME_ZONE = "UTC"
|
||||
USE_I18N = True
|
||||
USE_TZ = True
|
||||
AUTH_USER_MODEL = "accounts.User"
|
||||
|
||||
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
||||
REST_FRAMEWORK = {
|
||||
"DEFAULT_AUTHENTICATION_CLASSES": (
|
||||
"rest_framework_simplejwt.authentication.JWTAuthentication",
|
||||
),
|
||||
"DEFAULT_PERMISSION_CLASSES": (
|
||||
"rest_framework.permissions.IsAuthenticated",
|
||||
),
|
||||
"DEFAULT_RENDERER_CLASSES": (
|
||||
"rest_framework.renderers.JSONRenderer",
|
||||
),
|
||||
"EXCEPTION_HANDLER": "accounts.exceptions.api_exception_handler",
|
||||
"DEFAULT_THROTTLE_CLASSES": [
|
||||
"rest_framework.throttling.AnonRateThrottle",
|
||||
"accounts.throttles.UserRateThrottle",
|
||||
],
|
||||
"DEFAULT_THROTTLE_RATES": {
|
||||
"anon": os.environ.get("DJANGO_THROTTLE_ANON_RATE", "10/hour"),
|
||||
"auth": os.environ.get("DJANGO_THROTTLE_AUTH_RATE", "5/minute"),
|
||||
"user": os.environ.get("DJANGO_THROTTLE_USER_RATE", "60/minute"),
|
||||
},
|
||||
}
|
||||
|
||||
SIMPLE_JWT = {
|
||||
"ACCESS_TOKEN_LIFETIME": timedelta(minutes=15),
|
||||
"REFRESH_TOKEN_LIFETIME": timedelta(days=7),
|
||||
"ROTATE_REFRESH_TOKENS": True,
|
||||
"BLACKLIST_AFTER_ROTATION": True,
|
||||
"AUTH_HEADER_TYPES": ("Bearer",),
|
||||
}
|
||||
|
||||
# Password validation
|
||||
AUTH_PASSWORD_VALIDATORS = [
|
||||
{
|
||||
"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
|
||||
"OPTIONS": {"min_length": 8},
|
||||
},
|
||||
{
|
||||
"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
|
||||
},
|
||||
{
|
||||
"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
|
||||
},
|
||||
]
|
||||
|
||||
# CORS
|
||||
CORS_ALLOW_ALL_ORIGINS = os.environ.get("CORS_ALLOW_ALL_ORIGINS", "False").lower() in ("true", "1", "yes")
|
||||
@@ -96,16 +139,11 @@ CORS_ALLOWED_ORIGINS = os.environ.get(
|
||||
"http://localhost:3000,http://localhost:5173,http://127.0.0.1:3000",
|
||||
).split(",")
|
||||
|
||||
# REST Framework
|
||||
REST_FRAMEWORK = {
|
||||
"DEFAULT_PERMISSION_CLASSES": [
|
||||
"rest_framework.permissions.IsAuthenticated",
|
||||
],
|
||||
"DEFAULT_AUTHENTICATION_CLASSES": [
|
||||
"rest_framework_simplejwt.authentication.JWTAuthentication",
|
||||
"rest_framework.authentication.SessionAuthentication",
|
||||
],
|
||||
}
|
||||
LANGUAGE_CODE = "en-us"
|
||||
TIME_ZONE = "UTC"
|
||||
USE_I18N = True
|
||||
USE_TZ = True
|
||||
|
||||
# Static files for admin
|
||||
STATIC_URL = "/static/"
|
||||
|
||||
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
||||
@@ -3,5 +3,6 @@ from django.urls import include, path
|
||||
|
||||
urlpatterns = [
|
||||
path("admin/", admin.site.urls),
|
||||
path("api/auth/", include("accounts.urls")),
|
||||
path("api/", include("jobs.urls")),
|
||||
]
|
||||
@@ -7,9 +7,15 @@ dependencies = [
|
||||
"django>=5.1,<6.0",
|
||||
"django-cors-headers>=4.9.0",
|
||||
"djangorestframework>=3.17.1",
|
||||
"djangorestframework-simplejwt>=5.3,<6.0",
|
||||
"psycopg2-binary>=2.9",
|
||||
"pydantic>=2.0",
|
||||
"pydantic-settings>=2.0",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
requires = ["setuptools"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
include = ["project*", "accounts*", "jobs*"]
|
||||
|
||||
Generated
+172
@@ -2,6 +2,15 @@ version = 1
|
||||
revision = 3
|
||||
requires-python = ">=3.12"
|
||||
|
||||
[[package]]
|
||||
name = "annotated-types"
|
||||
version = "0.7.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "api"
|
||||
version = "1.0.0"
|
||||
@@ -10,7 +19,10 @@ dependencies = [
|
||||
{ name = "django" },
|
||||
{ name = "django-cors-headers" },
|
||||
{ name = "djangorestframework" },
|
||||
{ name = "djangorestframework-simplejwt" },
|
||||
{ name = "psycopg2-binary" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "pydantic-settings" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
@@ -18,7 +30,10 @@ requires-dist = [
|
||||
{ name = "django", specifier = ">=5.1,<6.0" },
|
||||
{ name = "django-cors-headers", specifier = ">=4.9.0" },
|
||||
{ name = "djangorestframework", specifier = ">=3.17.1" },
|
||||
{ name = "djangorestframework-simplejwt", specifier = ">=5.3,<6.0" },
|
||||
{ name = "psycopg2-binary", specifier = ">=2.9" },
|
||||
{ name = "pydantic", specifier = ">=2.0" },
|
||||
{ name = "pydantic-settings", specifier = ">=2.0" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -69,6 +84,20 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/e1/2c516bdc83652b1a60c6119366ac2c0607b479ed05cd6093f916ca8928f8/djangorestframework-3.17.1-py3-none-any.whl", hash = "sha256:c3c74dd3e83a5a3efc37b3c18d92bd6f86a6791c7b7d4dff62bb068500e76457", size = 898844, upload-time = "2026-03-24T16:58:31.845Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "djangorestframework-simplejwt"
|
||||
version = "5.5.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "django" },
|
||||
{ name = "djangorestframework" },
|
||||
{ name = "pyjwt" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a8/27/2874a325c11112066139769f7794afae238a07ce6adf96259f08fd37a9d7/djangorestframework_simplejwt-5.5.1.tar.gz", hash = "sha256:e72c5572f51d7803021288e2057afcbd03f17fe11d484096f40a460abc76e87f", size = 101265, upload-time = "2025-07-21T16:52:25.026Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/60/94/fdfb7b2f0b16cd3ed4d4171c55c1c07a2d1e3b106c5978c8ad0c15b4a48b/djangorestframework_simplejwt-5.5.1-py3-none-any.whl", hash = "sha256:2c30f3707053d384e9f315d11c2daccfcb548d4faa453111ca19a542b732e469", size = 107674, upload-time = "2025-07-21T16:52:07.493Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "psycopg2-binary"
|
||||
version = "2.9.12"
|
||||
@@ -110,6 +139,128 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/20/be/b732c8418ffa5bcfda002890f5dc4c869fc17db66ff11f53b17cfe44afc0/psycopg2_binary-2.9.12-cp314-cp314-win_amd64.whl", hash = "sha256:f12ae41fcafadb39b2785e64a40f9db05d6de2ac114077457e0e7c597f3af980", size = 2848762, upload-time = "2026-04-20T23:35:46.421Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic"
|
||||
version = "2.13.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "annotated-types" },
|
||||
{ name = "pydantic-core" },
|
||||
{ name = "typing-extensions" },
|
||||
{ name = "typing-inspection" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic-core"
|
||||
version = "2.46.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic-settings"
|
||||
version = "2.14.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pydantic" },
|
||||
{ name = "python-dotenv" },
|
||||
{ name = "typing-inspection" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/07/60/1d1e59c9c90d54591469ada7d268251f71c24bdb765f1a8a832cee8c6653/pydantic_settings-2.14.1.tar.gz", hash = "sha256:e874d3bec7e787b0c9958277956ed9b4dd5de6a80e162188fdaff7c5e26fd5fa", size = 235551, upload-time = "2026-05-08T13:40:06.542Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/8d/f1af3832f5e6eb13ba94ee809e72b8ecb5eef226d27ee0bef7d963d943c7/pydantic_settings-2.14.1-py3-none-any.whl", hash = "sha256:6e3c7edfd8277687cdc598f56e5cff0e9bfff0910a3749deaa8d4401c3a2b9de", size = 60964, upload-time = "2026-05-08T13:40:04.958Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyjwt"
|
||||
version = "2.13.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-dotenv"
|
||||
version = "1.2.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sqlparse"
|
||||
version = "0.5.5"
|
||||
@@ -119,6 +270,27 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/49/4b/359f28a903c13438ef59ebeee215fb25da53066db67b305c125f1c6d2a25/sqlparse-0.5.5-py3-none-any.whl", hash = "sha256:12a08b3bf3eec877c519589833aed092e2444e68240a3577e8e26148acc7b1ba", size = 46138, upload-time = "2025-12-19T07:17:46.573Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-extensions"
|
||||
version = "4.15.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-inspection"
|
||||
version = "0.4.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tzdata"
|
||||
version = "2026.2"
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
# 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
|
||||
|
||||
---
|
||||
|
||||
## 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"
|
||||
@@ -0,0 +1,186 @@
|
||||
# API Security Phase 1 — Robust Endpoint Protection
|
||||
|
||||
**Issue:** crisleo-hermes/job-tracker#7
|
||||
**Feature branch:** feature/api-security-phase-1
|
||||
**Date:** 2026-05-27
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Implement comprehensive API security measures: short-lived JWT with refresh token rotation, token blacklisting, role-based access control, rate limiting tightening, input sanitization, a custom exception handler that suppresses stack traces, and minimal sensitive data exposure in API responses.
|
||||
|
||||
---
|
||||
|
||||
## Backend Specification
|
||||
|
||||
### 1. Token Lifecycle Changes
|
||||
|
||||
#### Shortened JWT Lifetimes
|
||||
|
||||
| Setting | Current | New | Rationale |
|
||||
|---|---|---|---|
|
||||
| `ACCESS_TOKEN_LIFETIME` | 24 hours | 15 minutes | Minimises window for stolen tokens |
|
||||
| `REFRESH_TOKEN_LIFETIME` | 30 days | 7 days | Limits refresh token exposure |
|
||||
| `ROTATE_REFRESH_TOKENS` | (not set) | `True` | Old refresh token invalidated on each refresh |
|
||||
| `BLACKLIST_AFTER_ROTATION` | (not set) | `True` | Used refresh tokens cannot be replayed |
|
||||
|
||||
#### Token Blacklist Model
|
||||
|
||||
Add a `BlacklistedToken` model storing the JWT `jti` (JWT ID claim), the user, and the expiry datetime. This allows explicit logout (invalidate current token) and ensures rotated refresh tokens cannot be reused.
|
||||
|
||||
**Model: `accounts.BlacklistedToken`**
|
||||
|
||||
| Field | Type | Notes |
|
||||
|---|---|---|
|
||||
| `jti` | `UUIDField(unique=True)` | JWT ID from the token payload |
|
||||
| `user` | `ForeignKey(User)` | Owner of the token |
|
||||
| `created_at` | `DateTimeField(auto_now_add=True)` | When it was blacklisted |
|
||||
| `expires_at` | `DateTimeField()` | When the token would have naturally expired |
|
||||
|
||||
A management command (`cleartokens`) purges expired entries.
|
||||
|
||||
#### New API Endpoints
|
||||
|
||||
**`POST /api/auth/logout/`** — Blacklist the current refresh token.
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{ "refresh": "<refresh_token>" }
|
||||
```
|
||||
|
||||
**Validation:**
|
||||
- Refresh token must be valid (not expired, not already blacklisted)
|
||||
- Returns 205 Reset Content on success (204 would also be acceptable; 205 signals the client should reset its state)
|
||||
|
||||
**`POST /api/auth/refresh/`** — Override of SimpleJWT's built-in refresh with rotation + blacklist.
|
||||
|
||||
Same shape as SimpleJWT default — accepts `{ "refresh": "<token>" }`, returns `{ "access": "...", "refresh": "..." }`.
|
||||
|
||||
**`GET /api/auth/me/`** — Return the current user's profile.
|
||||
|
||||
Requires valid access token. Returns:
|
||||
```json
|
||||
{ "id": 1, "email": "user@example.com", "first_name": "John", "last_name": "Doe" }
|
||||
```
|
||||
|
||||
### 2. Role-Based Access Control (RBAC)
|
||||
|
||||
Two built-in roles via Django's `Groups`:
|
||||
|
||||
| Role | Permissions |
|
||||
|---|---|
|
||||
| `admin` | Full CRUD on all resources |
|
||||
| `user` (default) | CRUD on own resources only |
|
||||
|
||||
No separate `Role` model — reuse Django's built-in `django.contrib.auth.models.Group`.
|
||||
|
||||
#### Permission Logic
|
||||
|
||||
- `IsAdminOrReadOnly` — admin can do anything; authenticated users can read; anonymous denied
|
||||
- `IsOwnerOrAdmin` — object-level permission: user can modify their own resources; admin can modify anything
|
||||
|
||||
#### Model Ownership
|
||||
|
||||
Add `user = ForeignKey(User, on_delete=CASCADE, related_name="job_applications")` to `JobApplication`. This is a **schema change** — existing records get a default of the first superuser (migration handles this).
|
||||
|
||||
Filter queries by `request.user` for non-admin users. Admins see all records.
|
||||
|
||||
### 3. Custom Exception Handler
|
||||
|
||||
Create `accounts.exceptions` module with a DRF custom exception handler that:
|
||||
|
||||
- Returns `{"error": "message", "code": "error_code"}` for all API errors
|
||||
- NEVER includes stack traces, file paths, or Python internals
|
||||
- Maps DRF's built-in exceptions to user-safe messages
|
||||
- Logs the full traceback to `django.log` (sensitive details go to logs, not to the client)
|
||||
- Returns 500 with `{"error": "Internal server error.", "code": "internal_error"}` for unhandled exceptions
|
||||
|
||||
### 4. Input Validation & Sanitization
|
||||
|
||||
**Current state:** `RegisterSerializer` already validates email format, password length, and password match.
|
||||
|
||||
**Additions:**
|
||||
- Strip leading/trailing whitespace from all string fields across all serializers (`JobApplicationSerializer`, `JobUpdateSerializer`)
|
||||
- Reject null bytes (`\x00`) in string inputs (null-byte injection protection)
|
||||
- Add a reusable `SanitizedCharField` that strips control characters (ASCII 0x00–0x1F except `\t`, `\n`, `\r`) on deserialization
|
||||
- Validate `company_name` and `position_title` max lengths (already on model, enforce in serializer)
|
||||
|
||||
### 5. Rate Limiting Enhancements
|
||||
|
||||
| Scope | Rate | Applied to |
|
||||
|---|---|---|
|
||||
| `anon` | `10/hour` | All anonymous endpoints |
|
||||
| `auth` | `5/minute` | Login, Register (already implemented) |
|
||||
| `user` (new) | `60/minute` | All authenticated endpoints |
|
||||
|
||||
Implement a custom `UserRateThrottle` that scopes by user ID for authenticated requests.
|
||||
|
||||
### 6. Security Middleware
|
||||
|
||||
**`accounts.middleware.SecurityHeadersMiddleware`**
|
||||
|
||||
Adds response headers:
|
||||
- `X-Content-Type-Options: nosniff`
|
||||
- `X-Frame-Options: DENY`
|
||||
- `Referrer-Policy: strict-origin-when-cross-origin`
|
||||
- `Permissions-Policy: geolocation=(), microphone=(), camera=()`
|
||||
- `Strict-Transport-Security: max-age=31536000; includeSubDomains` (only when not DEBUG)
|
||||
|
||||
### 7. Sensitive Data Exposure
|
||||
|
||||
- `UserSerializer` already only exposes `id`, `email`, `first_name`, `last_name` ❌
|
||||
- Remove `email` from `UserSerializer` — use `id`, `first_name`, `last_name` only (email is PII)
|
||||
- In `JobApplicationSerializer`, exclude `notes` from list responses (only include in detail)
|
||||
- Add `user_id` to `JobApplicationSerializer` (read-only, set automatically on create)
|
||||
|
||||
---
|
||||
|
||||
## Frontend Specification
|
||||
|
||||
### Token Refresh Interceptor
|
||||
|
||||
Add an Axios response interceptor that:
|
||||
1. Detects 401 responses
|
||||
2. Tries `POST /api/auth/refresh/` with the stored refresh token
|
||||
3. On success: replaces `access_token` in localStorage, retries the original request
|
||||
4. On failure: clears tokens, redirects to `/login`
|
||||
|
||||
### Protected Route Component
|
||||
|
||||
Create `<ProtectedRoute>` component that:
|
||||
- Reads `isAuthenticated` from `AuthContext`
|
||||
- If not authenticated: redirects to `/login` with a `?redirect=` param
|
||||
- If loading: shows a loading spinner
|
||||
- If authenticated: renders children
|
||||
|
||||
### Logout in AuthContext
|
||||
|
||||
Add a `logout()` call that:
|
||||
- Calls `POST /api/auth/logout/` with the stored refresh token
|
||||
- Clears tokens from localStorage
|
||||
- Dispatches `LOGOUT` action
|
||||
- Redirects to `/login`
|
||||
|
||||
### Auth Flow Updates
|
||||
|
||||
- `LoginPage` / `RegisterPage`: redirect authenticated users to `/` via `Navigate` (already handled via `AuthContext`)
|
||||
- HomePage: no change needed — it already uses `useAuth` and the API client attaches the token
|
||||
|
||||
---
|
||||
|
||||
## Migration Notes
|
||||
|
||||
1. `accounts` app: add `BlacklistedToken` model → `0002_blacklistedtoken`
|
||||
2. `jobs` app: add `user` FK to `JobApplication` → `0002_jobapplication_user`
|
||||
- Existing rows: assign to the first superuser via `RunPython` migration
|
||||
- New `unique_together = ["user", "company_name", "position_title"]`
|
||||
|
||||
---
|
||||
|
||||
## Security Considerations (Phase 1 Gaps)
|
||||
|
||||
- No API key for machine-to-machine access (deferred to Phase 2)
|
||||
- No rate limiting on `/api/auth/refresh/` (low risk — refresh tokens are single-use with rotation)
|
||||
- No CSRF protection for cookie-based auth (not applicable — JWT Bearer auth only)
|
||||
- Logging configuration deferred to deployment (Phase 2)
|
||||
Generated
+2052
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,2 @@
|
||||
# API base URL for the Django backend
|
||||
VITE_API_URL=http://localhost:8000
|
||||
+3
-3
@@ -2,11 +2,11 @@ FROM node:20-alpine AS base
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json ./
|
||||
RUN yarn install --frozen-lockfile
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["yarn", "dev"]
|
||||
CMD ["npm", "run", "dev"]
|
||||
@@ -13,6 +13,7 @@
|
||||
"@emotion/styled": "^11.14.0",
|
||||
"@mui/icons-material": "^7.0.0",
|
||||
"@mui/material": "^7.0.0",
|
||||
"axios": "^1.7.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-router-dom": "^7.0.0"
|
||||
|
||||
+86
-5
@@ -1,16 +1,97 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { BrowserRouter, Routes, Route } from "react-router-dom";
|
||||
import AppLayout from "./components/AppLayout.tsx";
|
||||
import HomePage from "./pages/HomePage.tsx";
|
||||
import { type ReactNode } from "react";
|
||||
import { BrowserRouter, Routes, Route, Navigate, useLocation } from "react-router-dom";
|
||||
import { AuthProvider, useAuth } from "./contexts/AuthContext";
|
||||
import AppLayout from "./components/AppLayout";
|
||||
import HomePage from "./pages/HomePage";
|
||||
import LoginPage from "./pages/LoginPage";
|
||||
import RegisterPage from "./pages/RegisterPage";
|
||||
|
||||
function ProtectedRoute({ children }: { children: ReactNode }): ReactNode {
|
||||
const { state } = useAuth();
|
||||
const location = useLocation();
|
||||
|
||||
if (state.isInitializing) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
height: "100vh",
|
||||
color: "#888",
|
||||
}}
|
||||
>
|
||||
Loading...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!state.isAuthenticated) {
|
||||
return <Navigate to="/login" state={{ from: location }} replace />;
|
||||
}
|
||||
|
||||
return children;
|
||||
}
|
||||
|
||||
function PublicRoute({ children }: { children: ReactNode }): ReactNode {
|
||||
const { state } = useAuth();
|
||||
|
||||
if (state.isInitializing) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
height: "100vh",
|
||||
color: "#888",
|
||||
}}
|
||||
>
|
||||
Loading...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (state.isAuthenticated) {
|
||||
return <Navigate to="/" replace />;
|
||||
}
|
||||
|
||||
return children;
|
||||
}
|
||||
|
||||
export default function App(): ReactNode {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/login"
|
||||
element={
|
||||
<PublicRoute>
|
||||
<LoginPage />
|
||||
</PublicRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/register"
|
||||
element={
|
||||
<PublicRoute>
|
||||
<RegisterPage />
|
||||
</PublicRoute>
|
||||
}
|
||||
/>
|
||||
<Route element={<AppLayout />}>
|
||||
<Route index element={<HomePage />} />
|
||||
<Route
|
||||
index
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<HomePage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
</Route>
|
||||
</Routes>
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,14 @@
|
||||
import { type ReactNode } from "react";
|
||||
import { Outlet } from "react-router-dom";
|
||||
import { Outlet, useNavigate } from "react-router-dom";
|
||||
import AppBar from "@mui/material/AppBar";
|
||||
import Toolbar from "@mui/material/Toolbar";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import Container from "@mui/material/Container";
|
||||
import Box from "@mui/material/Box";
|
||||
import Button from "@mui/material/Button";
|
||||
import CssBaseline from "@mui/material/CssBaseline";
|
||||
import { ThemeProvider, createTheme } from "@mui/material/styles";
|
||||
import { useAuth } from "../contexts/AuthContext";
|
||||
|
||||
const theme = createTheme({
|
||||
palette: {
|
||||
@@ -20,15 +22,28 @@ const theme = createTheme({
|
||||
});
|
||||
|
||||
export default function AppLayout(): ReactNode {
|
||||
const { state, logout } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleLogout = async () => {
|
||||
await logout();
|
||||
navigate("/login");
|
||||
};
|
||||
|
||||
return (
|
||||
<ThemeProvider theme={theme}>
|
||||
<CssBaseline />
|
||||
<Box sx={{ display: "flex", flexDirection: "column", minHeight: "100vh" }}>
|
||||
<AppBar position="sticky">
|
||||
<Toolbar>
|
||||
<Typography variant="h6" component="h1" sx={{ fontWeight: 700 }}>
|
||||
<Typography variant="h6" component="h1" sx={{ fontWeight: 700, flexGrow: 1 }}>
|
||||
Job Tracker
|
||||
</Typography>
|
||||
{state.isAuthenticated && (
|
||||
<Button color="inherit" onClick={handleLogout}>
|
||||
Sign Out
|
||||
</Button>
|
||||
)}
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
<Container component="main" maxWidth="lg" sx={{ mt: 4, mb: 4, flexGrow: 1 }}>
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { type ReactNode } from "react";
|
||||
import { Navigate, Outlet, useLocation } from "react-router-dom";
|
||||
import { useAuth } from "../contexts/AuthContext";
|
||||
import CircularProgress from "@mui/material/CircularProgress";
|
||||
import Box from "@mui/material/Box";
|
||||
|
||||
interface ProtectedRouteProps {
|
||||
/** Optional redirect path for unauthenticated users (default: /login) */
|
||||
redirectTo?: string;
|
||||
/** Optional fallback UI while restoring auth state */
|
||||
fallback?: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Route guard that redirects unauthenticated users to the login page.
|
||||
* Shows a loading spinner while auth state is being restored from localStorage.
|
||||
*/
|
||||
export default function ProtectedRoute({
|
||||
redirectTo = "/login",
|
||||
fallback,
|
||||
}: ProtectedRouteProps): ReactNode {
|
||||
const { state } = useAuth();
|
||||
const location = useLocation();
|
||||
|
||||
// Still checking localStorage for existing session
|
||||
if (state.isRestoring) {
|
||||
return (
|
||||
fallback ?? (
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
minHeight: "100vh",
|
||||
}}
|
||||
>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (!state.isAuthenticated) {
|
||||
// Preserve the attempted URL so we can redirect back after login
|
||||
return <Navigate to={redirectTo} state={{ from: location }} replace />;
|
||||
}
|
||||
|
||||
return <Outlet />;
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useReducer,
|
||||
useCallback,
|
||||
useEffect,
|
||||
type ReactNode,
|
||||
type Dispatch,
|
||||
} from "react";
|
||||
import {
|
||||
loginUser,
|
||||
registerUser,
|
||||
getProfile,
|
||||
logoutUser,
|
||||
setTokens,
|
||||
clearTokens,
|
||||
refreshAccessToken,
|
||||
extractErrorMessage,
|
||||
getAccessToken,
|
||||
type UserProfile,
|
||||
type LoginPayload,
|
||||
type RegisterPayload,
|
||||
} from "../services/authApi";
|
||||
|
||||
// ── State ──────────────────────────────────────────────────────────────
|
||||
interface AuthState {
|
||||
user: UserProfile | null;
|
||||
isAuthenticated: boolean;
|
||||
isLoading: boolean;
|
||||
isInitializing: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
const initialState: AuthState = {
|
||||
user: null,
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
isInitializing: true,
|
||||
error: null,
|
||||
};
|
||||
|
||||
// ── Actions ────────────────────────────────────────────────────────────
|
||||
type AuthAction =
|
||||
| { type: "AUTH_START" }
|
||||
| { type: "AUTH_SUCCESS"; payload: UserProfile }
|
||||
| { type: "AUTH_FAILURE"; payload: string }
|
||||
| { type: "LOGOUT" }
|
||||
| { type: "INIT_COMPLETE" }
|
||||
| { type: "CLEAR_ERROR" };
|
||||
|
||||
function authReducer(state: AuthState, action: AuthAction): AuthState {
|
||||
switch (action.type) {
|
||||
case "AUTH_START":
|
||||
return { ...state, isLoading: true, error: null };
|
||||
case "AUTH_SUCCESS":
|
||||
return {
|
||||
...state,
|
||||
isLoading: false,
|
||||
isInitializing: false,
|
||||
isAuthenticated: true,
|
||||
user: action.payload,
|
||||
error: null,
|
||||
};
|
||||
case "AUTH_FAILURE":
|
||||
return {
|
||||
...state,
|
||||
isLoading: false,
|
||||
isInitializing: false,
|
||||
error: action.payload,
|
||||
};
|
||||
case "LOGOUT":
|
||||
return { ...initialState, isInitializing: false };
|
||||
case "INIT_COMPLETE":
|
||||
return { ...state, isInitializing: false };
|
||||
case "CLEAR_ERROR":
|
||||
return { ...state, error: null };
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Context ────────────────────────────────────────────────────────────
|
||||
interface AuthContextValue {
|
||||
state: AuthState;
|
||||
dispatch: Dispatch<AuthAction>;
|
||||
login: (payload: LoginPayload) => Promise<void>;
|
||||
register: (payload: RegisterPayload) => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
clearError: () => void;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||
|
||||
// ── Provider ───────────────────────────────────────────────────────────
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [state, dispatch] = useReducer(authReducer, initialState);
|
||||
|
||||
// On mount: try to refresh the access token and fetch user profile
|
||||
useEffect(() => {
|
||||
const initAuth = async () => {
|
||||
const refreshToken = localStorage.getItem("refresh_token");
|
||||
if (!refreshToken) {
|
||||
// Still check for legacy access_token to restore from localStorage
|
||||
const accessToken = getAccessToken();
|
||||
if (accessToken) {
|
||||
const userData = localStorage.getItem("user_data");
|
||||
if (userData) {
|
||||
try {
|
||||
const user: UserProfile = JSON.parse(userData);
|
||||
dispatch({ type: "AUTH_SUCCESS", payload: user });
|
||||
return;
|
||||
} catch {
|
||||
// corrupt stored data — proceed with unauthenticated state
|
||||
}
|
||||
}
|
||||
}
|
||||
dispatch({ type: "INIT_COMPLETE" });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Try refreshing the access token first
|
||||
const tokens = await refreshAccessToken(refreshToken);
|
||||
setTokens(tokens.access, tokens.refresh);
|
||||
|
||||
// Fetch user profile with the fresh token
|
||||
const profile = await getProfile();
|
||||
// Persist user data for restoration if refresh token expires
|
||||
localStorage.setItem("user_data", JSON.stringify(profile));
|
||||
dispatch({ type: "AUTH_SUCCESS", payload: profile });
|
||||
} catch {
|
||||
// Token invalid or expired — clear everything
|
||||
clearTokens();
|
||||
localStorage.removeItem("user_data");
|
||||
dispatch({ type: "LOGOUT" });
|
||||
}
|
||||
};
|
||||
|
||||
initAuth();
|
||||
}, []);
|
||||
|
||||
const login = useCallback(async (payload: LoginPayload) => {
|
||||
dispatch({ type: "AUTH_START" });
|
||||
try {
|
||||
const response = await loginUser(payload);
|
||||
setTokens(response.access, response.refresh);
|
||||
localStorage.setItem("user_data", JSON.stringify(response.user));
|
||||
dispatch({ type: "AUTH_SUCCESS", payload: response.user });
|
||||
} catch (err: unknown) {
|
||||
const message = extractErrorMessage(err);
|
||||
dispatch({ type: "AUTH_FAILURE", payload: message });
|
||||
throw err;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const register = useCallback(async (payload: RegisterPayload) => {
|
||||
dispatch({ type: "AUTH_START" });
|
||||
try {
|
||||
await registerUser(payload);
|
||||
// Registration succeeded — the page component handles redirect to /login.
|
||||
dispatch({ type: "LOGOUT" });
|
||||
} catch (err: unknown) {
|
||||
const message = extractErrorMessage(err);
|
||||
dispatch({ type: "AUTH_FAILURE", payload: message });
|
||||
throw err;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
const refreshToken = localStorage.getItem("refresh_token");
|
||||
if (refreshToken) {
|
||||
try {
|
||||
await logoutUser(refreshToken);
|
||||
} catch {
|
||||
// Even if the server request fails, clear local state
|
||||
}
|
||||
}
|
||||
clearTokens();
|
||||
localStorage.removeItem("user_data");
|
||||
dispatch({ type: "LOGOUT" });
|
||||
}, []);
|
||||
|
||||
const clearError = useCallback(() => {
|
||||
dispatch({ type: "CLEAR_ERROR" });
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AuthContext.Provider
|
||||
value={{ state, dispatch, login, register, logout, clearError }}
|
||||
>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Hook ───────────────────────────────────────────────────────────────
|
||||
export function useAuth(): AuthContextValue {
|
||||
const context = useContext(AuthContext);
|
||||
if (!context) {
|
||||
throw new Error("useAuth must be used within an AuthProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -11,9 +11,20 @@ interface UseDashboardDataResult {
|
||||
const API_BASE = "/api";
|
||||
|
||||
async function fetchJson<T>(url: string): Promise<T> {
|
||||
const response = await fetch(url);
|
||||
const token = localStorage.getItem("access_token");
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
if (token) {
|
||||
headers["Authorization"] = `Bearer ${token}`;
|
||||
}
|
||||
const response = await fetch(url, { headers });
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
const body = await response.json().catch(() => ({}));
|
||||
throw new Error(
|
||||
(body as { error?: string }).error ||
|
||||
`HTTP ${response.status}: ${response.statusText}`
|
||||
);
|
||||
}
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ import App from "./App.tsx";
|
||||
|
||||
const rootElement = document.getElementById("root");
|
||||
if (!rootElement) {
|
||||
throw new Error("Root element not found");
|
||||
throw new Error("Root element #root not found in the document.");
|
||||
}
|
||||
|
||||
createRoot(rootElement).render(
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
.container {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: #f5f5f5;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
}
|
||||
|
||||
.card {
|
||||
background-color: #fff;
|
||||
padding: 2.5rem;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.heading {
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
|
||||
.welcomeText {
|
||||
color: #666;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.signedInText {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.emailStrong {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.logoutButton {
|
||||
padding: 0.5rem 1.25rem;
|
||||
background-color: #b91c1c;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.logoutButton:hover {
|
||||
background-color: #991616;
|
||||
}
|
||||
|
||||
.authLinks {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.signInLink {
|
||||
padding: 0.5rem 1.25rem;
|
||||
background-color: #1a73e8;
|
||||
color: #fff;
|
||||
text-decoration: none;
|
||||
border-radius: 6px;
|
||||
font-size: 0.9rem;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.signInLink:hover {
|
||||
background-color: #1557b0;
|
||||
}
|
||||
|
||||
.registerLink {
|
||||
padding: 0.5rem 1.25rem;
|
||||
background-color: #fff;
|
||||
color: #1a73e8;
|
||||
text-decoration: none;
|
||||
border: 1px solid #1a73e8;
|
||||
border-radius: 6px;
|
||||
font-size: 0.9rem;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.registerLink:hover {
|
||||
background-color: #f0f5ff;
|
||||
}
|
||||
+78
-11
@@ -1,17 +1,58 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { type ReactNode } from "react";
|
||||
import { useNavigate, Link } from "react-router-dom";
|
||||
import { useAuth } from "../contexts/AuthContext";
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import Button from "@mui/material/Button";
|
||||
import Grid from "@mui/material/Grid";
|
||||
import Alert from "@mui/material/Alert";
|
||||
import AddIcon from "@mui/icons-material/Add";
|
||||
import UpdateCard from "../components/UpdateCard.tsx";
|
||||
import MetricsPanel from "../components/MetricsPanel.tsx";
|
||||
import LoadingSkeleton from "../components/LoadingSkeleton.tsx";
|
||||
import { useDashboardData } from "../hooks/useDashboardData.ts";
|
||||
import UpdateCard from "../components/UpdateCard";
|
||||
import MetricsPanel from "../components/MetricsPanel";
|
||||
import LoadingSkeleton from "../components/LoadingSkeleton";
|
||||
import { useDashboardData } from "../hooks/useDashboardData";
|
||||
|
||||
export default function HomePage(): ReactNode {
|
||||
function LandingView(): ReactNode {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
minHeight: "60vh",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
<Typography variant="h3" component="h1" gutterBottom sx={{ fontWeight: 700 }}>
|
||||
Job Tracker
|
||||
</Typography>
|
||||
<Typography variant="h6" color="text.secondary" sx={{ mb: 4, maxWidth: 500 }}>
|
||||
Track your job applications, interviews, and offers all in one place.
|
||||
</Typography>
|
||||
<Box sx={{ display: "flex", gap: 2 }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="large"
|
||||
component={Link}
|
||||
to="/login"
|
||||
>
|
||||
Sign In
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="large"
|
||||
component={Link}
|
||||
to="/register"
|
||||
>
|
||||
Register
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function DashboardView(): ReactNode {
|
||||
const navigate = useNavigate();
|
||||
const { data, loading, error } = useDashboardData();
|
||||
|
||||
@@ -21,7 +62,12 @@ export default function HomePage(): ReactNode {
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h4" component="h2" gutterBottom sx={{ fontWeight: 600 }}>
|
||||
<Typography
|
||||
variant="h4"
|
||||
component="h2"
|
||||
gutterBottom
|
||||
sx={{ fontWeight: 600 }}
|
||||
>
|
||||
Dashboard
|
||||
</Typography>
|
||||
|
||||
@@ -37,7 +83,9 @@ export default function HomePage(): ReactNode {
|
||||
<MetricsPanel metrics={data.metrics} />
|
||||
</Box>
|
||||
|
||||
<Box sx={{ mb: 3, display: "flex", justifyContent: "flex-end" }}>
|
||||
<Box
|
||||
sx={{ mb: 3, display: "flex", justifyContent: "flex-end" }}
|
||||
>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="large"
|
||||
@@ -48,7 +96,12 @@ export default function HomePage(): ReactNode {
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Typography variant="h5" component="h3" gutterBottom sx={{ fontWeight: 600 }}>
|
||||
<Typography
|
||||
variant="h5"
|
||||
component="h3"
|
||||
gutterBottom
|
||||
sx={{ fontWeight: 600 }}
|
||||
>
|
||||
Recent Updates
|
||||
</Typography>
|
||||
|
||||
@@ -61,7 +114,11 @@ export default function HomePage(): ReactNode {
|
||||
</Grid>
|
||||
|
||||
{data.updates.length === 0 && (
|
||||
<Typography variant="body1" color="text.secondary" sx={{ mt: 2 }}>
|
||||
<Typography
|
||||
variant="body1"
|
||||
color="text.secondary"
|
||||
sx={{ mt: 2 }}
|
||||
>
|
||||
No updates yet. Create your first job application to get started.
|
||||
</Typography>
|
||||
)}
|
||||
@@ -70,3 +127,13 @@ export default function HomePage(): ReactNode {
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default function HomePage(): ReactNode {
|
||||
const { state } = useAuth();
|
||||
|
||||
if (!state.isAuthenticated) {
|
||||
return <LandingView />;
|
||||
}
|
||||
|
||||
return <DashboardView />;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
.container {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: #f5f5f5;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
}
|
||||
|
||||
.card {
|
||||
background-color: #fff;
|
||||
padding: 2rem;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0 0 0.25rem;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: 0 0 1.5rem;
|
||||
color: #666;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.input {
|
||||
padding: 0.6rem 0.75rem;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 6px;
|
||||
font-size: 0.95rem;
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
border-color: #1a73e8;
|
||||
}
|
||||
|
||||
.button {
|
||||
padding: 0.65rem;
|
||||
background-color: #1a73e8;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.error {
|
||||
background-color: #fef2f2;
|
||||
color: #b91c1c;
|
||||
border: 1px solid #fecaca;
|
||||
border-radius: 6px;
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.85rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.success {
|
||||
background-color: #f0fdf4;
|
||||
color: #166534;
|
||||
border: 1px solid #bbf7d0;
|
||||
border-radius: 6px;
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.85rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.footer {
|
||||
margin-top: 1.25rem;
|
||||
text-align: center;
|
||||
font-size: 0.85rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.link {
|
||||
color: #1a73e8;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { useState, type FormEvent, type ChangeEvent } from "react";
|
||||
import { useNavigate, useLocation, Link } from "react-router-dom";
|
||||
import { useAuth } from "../contexts/AuthContext";
|
||||
import styles from "./LoginPage.module.css";
|
||||
|
||||
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("");
|
||||
|
||||
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
clearError();
|
||||
try {
|
||||
await login({ email, password });
|
||||
navigate("/");
|
||||
} catch {
|
||||
// error is captured in state.error via context
|
||||
}
|
||||
};
|
||||
|
||||
const handleEmailChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
setEmail(e.target.value);
|
||||
};
|
||||
|
||||
const handlePasswordChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
setPassword(e.target.value);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.card}>
|
||||
<h1 className={styles.title}>Sign In</h1>
|
||||
<p className={styles.subtitle}>Welcome back to Job Tracker</p>
|
||||
|
||||
{state.error && <div className={styles.error}>{state.error}</div>}
|
||||
{successMessage && <div className={styles.success}>{successMessage}</div>}
|
||||
|
||||
<form onSubmit={handleSubmit} className={styles.form}>
|
||||
<div className={styles.field}>
|
||||
<label htmlFor="email" className={styles.label}>
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={handleEmailChange}
|
||||
className={styles.input}
|
||||
placeholder="you@example.com"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<label htmlFor="password" className={styles.label}>
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={handlePasswordChange}
|
||||
className={styles.input}
|
||||
placeholder="\u00b7\u00b7\u00b7\u00b7\u00b7\u00b7\u00b7\u00b7"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={state.isLoading}
|
||||
className={styles.button}
|
||||
>
|
||||
{state.isLoading ? "Signing in..." : "Sign In"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className={styles.footer}>
|
||||
Don't have an account?{" "}
|
||||
<Link to="/register" className={styles.link}>
|
||||
Create one
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
.container {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: #f5f5f5;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
}
|
||||
|
||||
.card {
|
||||
background-color: #fff;
|
||||
padding: 2rem;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
width: 100%;
|
||||
max-width: 440px;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0 0 0.25rem;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: 0 0 1.5rem;
|
||||
color: #666;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.halfField {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.required {
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.input {
|
||||
padding: 0.6rem 0.75rem;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 6px;
|
||||
font-size: 0.95rem;
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
border-color: #1a73e8;
|
||||
}
|
||||
|
||||
.button {
|
||||
padding: 0.65rem;
|
||||
background-color: #1a73e8;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.error {
|
||||
background-color: #fef2f2;
|
||||
color: #b91c1c;
|
||||
border: 1px solid #fecaca;
|
||||
border-radius: 6px;
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.85rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.footer {
|
||||
margin-top: 1.25rem;
|
||||
text-align: center;
|
||||
font-size: 0.85rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.link {
|
||||
color: #1a73e8;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { useState, type FormEvent, type ChangeEvent } from "react";
|
||||
import { useNavigate, Link } from "react-router-dom";
|
||||
import { useAuth } from "../contexts/AuthContext";
|
||||
import styles from "./RegisterPage.module.css";
|
||||
|
||||
export default function RegisterPage() {
|
||||
const navigate = useNavigate();
|
||||
const { state, register, clearError } = useAuth();
|
||||
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [passwordConfirm, setPasswordConfirm] = useState("");
|
||||
const [firstName, setFirstName] = useState("");
|
||||
const [lastName, setLastName] = useState("");
|
||||
|
||||
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
clearError();
|
||||
try {
|
||||
await register({
|
||||
email,
|
||||
password,
|
||||
password_confirm: passwordConfirm,
|
||||
first_name: firstName,
|
||||
last_name: lastName,
|
||||
});
|
||||
navigate("/login", {
|
||||
state: { message: "Registration successful! Please sign in." },
|
||||
});
|
||||
} catch {
|
||||
// error is captured in state.error via context
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.card}>
|
||||
<h1 className={styles.title}>Create Account</h1>
|
||||
<p className={styles.subtitle}>Get started with Job Tracker</p>
|
||||
|
||||
{state.error && <div className={styles.error}>{state.error}</div>}
|
||||
|
||||
<form onSubmit={handleSubmit} className={styles.form}>
|
||||
<div className={styles.row}>
|
||||
<div className={styles.halfField}>
|
||||
<label htmlFor="firstName" className={styles.label}>
|
||||
First Name
|
||||
</label>
|
||||
<input
|
||||
id="firstName"
|
||||
type="text"
|
||||
value={firstName}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) =>
|
||||
setFirstName(e.target.value)
|
||||
}
|
||||
className={styles.input}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.halfField}>
|
||||
<label htmlFor="lastName" className={styles.label}>
|
||||
Last Name
|
||||
</label>
|
||||
<input
|
||||
id="lastName"
|
||||
type="text"
|
||||
value={lastName}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) =>
|
||||
setLastName(e.target.value)
|
||||
}
|
||||
className={styles.input}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<label htmlFor="email" className={styles.label}>
|
||||
Email <span className={styles.required}>*</span>
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) =>
|
||||
setEmail(e.target.value)
|
||||
}
|
||||
className={styles.input}
|
||||
placeholder="you@example.com"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<label htmlFor="password" className={styles.label}>
|
||||
Password <span className={styles.required}>*</span>
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) =>
|
||||
setPassword(e.target.value)
|
||||
}
|
||||
className={styles.input}
|
||||
placeholder="Min. 8 characters"
|
||||
required
|
||||
minLength={8}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<label htmlFor="passwordConfirm" className={styles.label}>
|
||||
Confirm Password <span className={styles.required}>*</span>
|
||||
</label>
|
||||
<input
|
||||
id="passwordConfirm"
|
||||
type="password"
|
||||
value={passwordConfirm}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) =>
|
||||
setPasswordConfirm(e.target.value)
|
||||
}
|
||||
className={styles.input}
|
||||
placeholder="Repeat your password"
|
||||
required
|
||||
minLength={8}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={state.isLoading}
|
||||
className={styles.button}
|
||||
>
|
||||
{state.isLoading ? "Creating Account..." : "Create Account"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className={styles.footer}>
|
||||
Already have an account?{" "}
|
||||
<Link to="/login" className={styles.link}>
|
||||
Sign in
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
import axios, { AxiosError, type AxiosResponse, type InternalAxiosRequestConfig } from "axios";
|
||||
|
||||
interface RetryConfig extends InternalAxiosRequestConfig {
|
||||
_retry?: boolean;
|
||||
}
|
||||
|
||||
const apiClient = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_URL || "http://localhost:8000",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
// ── Token management ──────────────────────────────────────────────────
|
||||
|
||||
function getAccessToken(): string | null {
|
||||
return localStorage.getItem("access_token");
|
||||
}
|
||||
|
||||
function getRefreshToken(): string | null {
|
||||
return localStorage.getItem("refresh_token");
|
||||
}
|
||||
|
||||
function setTokens(access: string, refresh: string): void {
|
||||
localStorage.setItem("access_token", access);
|
||||
localStorage.setItem("refresh_token", refresh);
|
||||
}
|
||||
|
||||
function clearTokens(): void {
|
||||
localStorage.removeItem("access_token");
|
||||
localStorage.removeItem("refresh_token");
|
||||
}
|
||||
|
||||
// ── Request interceptor: attach access token ──────────────────────────
|
||||
|
||||
apiClient.interceptors.request.use((config: InternalAxiosRequestConfig) => {
|
||||
const token = getAccessToken();
|
||||
if (token && config.headers) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
// ── Response interceptor: auto-refresh on 401, retry once ─────────────
|
||||
|
||||
let isRefreshing = false;
|
||||
let failedQueue: Array<{
|
||||
resolve: (token: string) => void;
|
||||
reject: (err: unknown) => void;
|
||||
}> = [];
|
||||
|
||||
function processQueue(error: unknown, token: string | null = null): void {
|
||||
failedQueue.forEach((prom) => {
|
||||
if (error) {
|
||||
prom.reject(error);
|
||||
} else if (token) {
|
||||
prom.resolve(token);
|
||||
}
|
||||
});
|
||||
failedQueue = [];
|
||||
}
|
||||
|
||||
apiClient.interceptors.response.use(
|
||||
(response: AxiosResponse) => response,
|
||||
async (error: AxiosError) => {
|
||||
const originalRequest = error.config as RetryConfig;
|
||||
|
||||
if (!originalRequest) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
// Only handle 401s that aren't already refresh/login/register/logout attempts
|
||||
if (
|
||||
error.response?.status !== 401 ||
|
||||
originalRequest._retry ||
|
||||
originalRequest.url?.includes("/api/auth/token/refresh/") ||
|
||||
originalRequest.url?.includes("/api/auth/login/") ||
|
||||
originalRequest.url?.includes("/api/auth/register/") ||
|
||||
originalRequest.url?.includes("/api/auth/logout/")
|
||||
) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
if (isRefreshing) {
|
||||
// Queue this request until the refresh completes
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
failedQueue.push({ resolve, reject });
|
||||
}).then((token) => {
|
||||
originalRequest.headers!.Authorization = `Bearer ${token}`;
|
||||
return apiClient(originalRequest);
|
||||
});
|
||||
}
|
||||
|
||||
originalRequest._retry = true;
|
||||
isRefreshing = true;
|
||||
|
||||
const refreshToken = getRefreshToken();
|
||||
|
||||
if (!refreshToken) {
|
||||
isRefreshing = false;
|
||||
clearTokens();
|
||||
window.location.href = "/login";
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await axios.post(
|
||||
`${apiClient.defaults.baseURL}/api/auth/token/refresh/`,
|
||||
{ refresh: refreshToken }
|
||||
);
|
||||
|
||||
const newAccessToken = response.data.access;
|
||||
const newRefreshToken = response.data.refresh;
|
||||
|
||||
setTokens(newAccessToken, newRefreshToken);
|
||||
|
||||
processQueue(null, newAccessToken);
|
||||
|
||||
originalRequest.headers!.Authorization = `Bearer ${newAccessToken}`;
|
||||
return apiClient(originalRequest);
|
||||
} catch (refreshError) {
|
||||
processQueue(refreshError, null);
|
||||
clearTokens();
|
||||
window.location.href = "/login";
|
||||
return Promise.reject(refreshError);
|
||||
} finally {
|
||||
isRefreshing = false;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ── Error extraction from Axios responses ─────────────────────────────
|
||||
|
||||
/**
|
||||
* Extract a human-readable error message from an AxiosError.
|
||||
* Backend DRF errors can be:
|
||||
* - {"field": ["message"]} (field-level)
|
||||
* - {"non_field_errors": ["message"]} (general)
|
||||
* - {"detail": "message"} (list/generic)
|
||||
* - string (unexpected shape)
|
||||
*/
|
||||
export function extractErrorMessage(err: unknown): string {
|
||||
if (err instanceof AxiosError && err.response?.data) {
|
||||
const data = err.response.data as Record<string, unknown>;
|
||||
|
||||
// DRF non_field_errors
|
||||
if (
|
||||
Array.isArray(data.non_field_errors) &&
|
||||
data.non_field_errors.length > 0
|
||||
) {
|
||||
return String(data.non_field_errors[0]);
|
||||
}
|
||||
|
||||
// DRF detail (e.g., 401 Unauthorized)
|
||||
if (typeof data.detail === "string") {
|
||||
return data.detail;
|
||||
}
|
||||
|
||||
// Field-level errors — pick the first one
|
||||
for (const key of Object.keys(data)) {
|
||||
const val = data[key];
|
||||
if (Array.isArray(val) && val.length > 0) {
|
||||
return String(val[0]);
|
||||
}
|
||||
if (typeof val === "string") {
|
||||
return val;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: raw string
|
||||
if (typeof data === "string") return data;
|
||||
}
|
||||
|
||||
// Generic error fallback
|
||||
if (err instanceof Error) return err.message;
|
||||
return "An unexpected error occurred. Please try again.";
|
||||
}
|
||||
|
||||
// ── Types ──────────────────────────────────────────────────────────────
|
||||
|
||||
export interface RegisterPayload {
|
||||
email: string;
|
||||
password: string;
|
||||
password_confirm: string;
|
||||
first_name?: string;
|
||||
last_name?: string;
|
||||
}
|
||||
|
||||
export interface LoginPayload {
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface UserProfile {
|
||||
id: number;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
user: UserProfile;
|
||||
access: string;
|
||||
refresh: string;
|
||||
}
|
||||
|
||||
// ── API functions ──────────────────────────────────────────────────────
|
||||
|
||||
export function registerUser(payload: RegisterPayload): Promise<UserProfile> {
|
||||
return apiClient
|
||||
.post<UserProfile>("/api/auth/register/", payload)
|
||||
.then((res) => res.data);
|
||||
}
|
||||
|
||||
export function loginUser(payload: LoginPayload): Promise<LoginResponse> {
|
||||
return apiClient
|
||||
.post<LoginResponse>("/api/auth/login/", payload)
|
||||
.then((res) => res.data);
|
||||
}
|
||||
|
||||
export function getProfile(): Promise<UserProfile> {
|
||||
return apiClient
|
||||
.get<UserProfile>("/api/auth/me/")
|
||||
.then((res) => res.data);
|
||||
}
|
||||
|
||||
export function logoutUser(refreshToken: string): Promise<void> {
|
||||
return apiClient
|
||||
.post("/api/auth/logout/", { refresh: refreshToken })
|
||||
.then(() => {});
|
||||
}
|
||||
|
||||
export function refreshAccessToken(
|
||||
refreshToken: string
|
||||
): Promise<{ access: string; refresh: string }> {
|
||||
return apiClient
|
||||
.post<{ access: string; refresh: string }>(
|
||||
"/api/auth/token/refresh/",
|
||||
{ refresh: refreshToken }
|
||||
)
|
||||
.then((res) => res.data);
|
||||
}
|
||||
|
||||
export {
|
||||
apiClient,
|
||||
getAccessToken,
|
||||
getRefreshToken,
|
||||
setTokens,
|
||||
clearTokens,
|
||||
};
|
||||
@@ -732,6 +732,28 @@
|
||||
"@types/babel__core" "^7.20.5"
|
||||
react-refresh "^0.17.0"
|
||||
|
||||
agent-base@6:
|
||||
version "6.0.2"
|
||||
resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-6.0.2.tgz#49fff58577cfee3f37176feab4c22e00f86d7f77"
|
||||
integrity sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==
|
||||
dependencies:
|
||||
debug "4"
|
||||
|
||||
asynckit@^0.4.0:
|
||||
version "0.4.0"
|
||||
resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79"
|
||||
integrity sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==
|
||||
|
||||
axios@^1.7.0:
|
||||
version "1.16.1"
|
||||
resolved "https://registry.yarnpkg.com/axios/-/axios-1.16.1.tgz#517e29291d19d6e8cf919ff264f4fe157261ba12"
|
||||
integrity sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==
|
||||
dependencies:
|
||||
follow-redirects "^1.16.0"
|
||||
form-data "^4.0.5"
|
||||
https-proxy-agent "^5.0.1"
|
||||
proxy-from-env "^2.1.0"
|
||||
|
||||
babel-plugin-macros@^3.1.0:
|
||||
version "3.1.0"
|
||||
resolved "https://registry.yarnpkg.com/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz#9ef6dc74deb934b4db344dc973ee851d148c50c1"
|
||||
@@ -757,6 +779,14 @@ browserslist@^4.24.0:
|
||||
node-releases "^2.0.36"
|
||||
update-browserslist-db "^1.2.3"
|
||||
|
||||
call-bind-apply-helpers@^1.0.1, call-bind-apply-helpers@^1.0.2:
|
||||
version "1.0.2"
|
||||
resolved "https://registry.yarnpkg.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz#4b5428c222be985d79c3d82657479dbe0b59b2d6"
|
||||
integrity sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==
|
||||
dependencies:
|
||||
es-errors "^1.3.0"
|
||||
function-bind "^1.1.2"
|
||||
|
||||
callsites@^3.0.0:
|
||||
version "3.1.0"
|
||||
resolved "https://registry.yarnpkg.com/callsites/-/callsites-3.1.0.tgz#b3630abd8943432f54b3f0519238e33cd7df2f73"
|
||||
@@ -772,6 +802,13 @@ clsx@^2.1.1:
|
||||
resolved "https://registry.yarnpkg.com/clsx/-/clsx-2.1.1.tgz#eed397c9fd8bd882bfb18deab7102049a2f32999"
|
||||
integrity sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==
|
||||
|
||||
combined-stream@^1.0.8:
|
||||
version "1.0.8"
|
||||
resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f"
|
||||
integrity sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==
|
||||
dependencies:
|
||||
delayed-stream "~1.0.0"
|
||||
|
||||
convert-source-map@^1.5.0:
|
||||
version "1.9.0"
|
||||
resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.9.0.tgz#7faae62353fb4213366d0ca98358d22e8368b05f"
|
||||
@@ -803,13 +840,18 @@ csstype@^3.0.2, csstype@^3.2.2, csstype@^3.2.3:
|
||||
resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.2.3.tgz#ec48c0f3e993e50648c86da559e2610995cf989a"
|
||||
integrity sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==
|
||||
|
||||
debug@^4.1.0, debug@^4.3.1:
|
||||
debug@4, debug@^4.1.0, debug@^4.3.1:
|
||||
version "4.4.3"
|
||||
resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.3.tgz#c6ae432d9bd9662582fce08709b038c58e9e3d6a"
|
||||
integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==
|
||||
dependencies:
|
||||
ms "^2.1.3"
|
||||
|
||||
delayed-stream@~1.0.0:
|
||||
version "1.0.0"
|
||||
resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619"
|
||||
integrity sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==
|
||||
|
||||
dom-helpers@^5.0.1:
|
||||
version "5.2.1"
|
||||
resolved "https://registry.yarnpkg.com/dom-helpers/-/dom-helpers-5.2.1.tgz#d9400536b2bf8225ad98fe052e029451ac40e902"
|
||||
@@ -818,6 +860,15 @@ dom-helpers@^5.0.1:
|
||||
"@babel/runtime" "^7.8.7"
|
||||
csstype "^3.0.2"
|
||||
|
||||
dunder-proto@^1.0.1:
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/dunder-proto/-/dunder-proto-1.0.1.tgz#d7ae667e1dc83482f8b70fd0f6eefc50da30f58a"
|
||||
integrity sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==
|
||||
dependencies:
|
||||
call-bind-apply-helpers "^1.0.1"
|
||||
es-errors "^1.3.0"
|
||||
gopd "^1.2.0"
|
||||
|
||||
electron-to-chromium@^1.5.328:
|
||||
version "1.5.361"
|
||||
resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.361.tgz#b993bc7b34ea83f348aa1787a608ecf12e39b909"
|
||||
@@ -830,11 +881,33 @@ error-ex@^1.3.1:
|
||||
dependencies:
|
||||
is-arrayish "^0.2.1"
|
||||
|
||||
es-define-property@^1.0.1:
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/es-define-property/-/es-define-property-1.0.1.tgz#983eb2f9a6724e9303f61addf011c72e09e0b0fa"
|
||||
integrity sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==
|
||||
|
||||
es-errors@^1.3.0:
|
||||
version "1.3.0"
|
||||
resolved "https://registry.yarnpkg.com/es-errors/-/es-errors-1.3.0.tgz#05f75a25dab98e4fb1dcd5e1472c0546d5057c8f"
|
||||
integrity sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==
|
||||
|
||||
es-object-atoms@^1.0.0, es-object-atoms@^1.1.1:
|
||||
version "1.1.2"
|
||||
resolved "https://registry.yarnpkg.com/es-object-atoms/-/es-object-atoms-1.1.2.tgz#a2d0b373205724dfa525d23b0c3e1b1ca582c99b"
|
||||
integrity sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==
|
||||
dependencies:
|
||||
es-errors "^1.3.0"
|
||||
|
||||
es-set-tostringtag@^2.1.0:
|
||||
version "2.1.0"
|
||||
resolved "https://registry.yarnpkg.com/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz#f31dbbe0c183b00a6d26eb6325c810c0fd18bd4d"
|
||||
integrity sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==
|
||||
dependencies:
|
||||
es-errors "^1.3.0"
|
||||
get-intrinsic "^1.2.6"
|
||||
has-tostringtag "^1.0.2"
|
||||
hasown "^2.0.2"
|
||||
|
||||
esbuild@^0.25.0:
|
||||
version "0.25.12"
|
||||
resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.25.12.tgz#97a1d041f4ab00c2fce2f838d2b9969a2d2a97a5"
|
||||
@@ -887,6 +960,22 @@ find-root@^1.1.0:
|
||||
resolved "https://registry.yarnpkg.com/find-root/-/find-root-1.1.0.tgz#abcfc8ba76f708c42a97b3d685b7e9450bfb9ce4"
|
||||
integrity sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==
|
||||
|
||||
follow-redirects@^1.16.0:
|
||||
version "1.16.0"
|
||||
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.16.0.tgz#28474a159d3b9d11ef62050a14ed60e4df6d61bc"
|
||||
integrity sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==
|
||||
|
||||
form-data@^4.0.5:
|
||||
version "4.0.5"
|
||||
resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.5.tgz#b49e48858045ff4cbf6b03e1805cebcad3679053"
|
||||
integrity sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==
|
||||
dependencies:
|
||||
asynckit "^0.4.0"
|
||||
combined-stream "^1.0.8"
|
||||
es-set-tostringtag "^2.1.0"
|
||||
hasown "^2.0.2"
|
||||
mime-types "^2.1.12"
|
||||
|
||||
fsevents@~2.3.2, fsevents@~2.3.3:
|
||||
version "2.3.3"
|
||||
resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6"
|
||||
@@ -902,6 +991,54 @@ gensync@^1.0.0-beta.2:
|
||||
resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0"
|
||||
integrity sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==
|
||||
|
||||
get-intrinsic@^1.2.6:
|
||||
version "1.3.0"
|
||||
resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz#743f0e3b6964a93a5491ed1bffaae054d7f98d01"
|
||||
integrity sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==
|
||||
dependencies:
|
||||
call-bind-apply-helpers "^1.0.2"
|
||||
es-define-property "^1.0.1"
|
||||
es-errors "^1.3.0"
|
||||
es-object-atoms "^1.1.1"
|
||||
function-bind "^1.1.2"
|
||||
get-proto "^1.0.1"
|
||||
gopd "^1.2.0"
|
||||
has-symbols "^1.1.0"
|
||||
hasown "^2.0.2"
|
||||
math-intrinsics "^1.1.0"
|
||||
|
||||
get-proto@^1.0.1:
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/get-proto/-/get-proto-1.0.1.tgz#150b3f2743869ef3e851ec0c49d15b1d14d00ee1"
|
||||
integrity sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==
|
||||
dependencies:
|
||||
dunder-proto "^1.0.1"
|
||||
es-object-atoms "^1.0.0"
|
||||
|
||||
gopd@^1.2.0:
|
||||
version "1.2.0"
|
||||
resolved "https://registry.yarnpkg.com/gopd/-/gopd-1.2.0.tgz#89f56b8217bdbc8802bd299df6d7f1081d7e51a1"
|
||||
integrity sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==
|
||||
|
||||
has-symbols@^1.0.3, has-symbols@^1.1.0:
|
||||
version "1.1.0"
|
||||
resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.1.0.tgz#fc9c6a783a084951d0b971fe1018de813707a338"
|
||||
integrity sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==
|
||||
|
||||
has-tostringtag@^1.0.2:
|
||||
version "1.0.2"
|
||||
resolved "https://registry.yarnpkg.com/has-tostringtag/-/has-tostringtag-1.0.2.tgz#2cdc42d40bef2e5b4eeab7c01a73c54ce7ab5abc"
|
||||
integrity sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==
|
||||
dependencies:
|
||||
has-symbols "^1.0.3"
|
||||
|
||||
hasown@^2.0.2:
|
||||
version "2.0.4"
|
||||
resolved "https://registry.yarnpkg.com/hasown/-/hasown-2.0.4.tgz#8c62d8cb90beb2aad5d0a5b67581ad9854c3f003"
|
||||
integrity sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==
|
||||
dependencies:
|
||||
function-bind "^1.1.2"
|
||||
|
||||
hasown@^2.0.3:
|
||||
version "2.0.3"
|
||||
resolved "https://registry.yarnpkg.com/hasown/-/hasown-2.0.3.tgz#5e5c2b15b60370a4c7930c383dfb76bf17bc403c"
|
||||
@@ -916,6 +1053,14 @@ hoist-non-react-statics@^3.3.1:
|
||||
dependencies:
|
||||
react-is "^16.7.0"
|
||||
|
||||
https-proxy-agent@^5.0.1:
|
||||
version "5.0.1"
|
||||
resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz#c59ef224a04fe8b754f3db0063a25ea30d0005d6"
|
||||
integrity sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==
|
||||
dependencies:
|
||||
agent-base "6"
|
||||
debug "4"
|
||||
|
||||
import-fresh@^3.2.1:
|
||||
version "3.3.1"
|
||||
resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-3.3.1.tgz#9cecb56503c0ada1f2741dbbd6546e4b13b57ccf"
|
||||
@@ -975,6 +1120,23 @@ lru-cache@^5.1.1:
|
||||
dependencies:
|
||||
yallist "^3.0.2"
|
||||
|
||||
math-intrinsics@^1.1.0:
|
||||
version "1.1.0"
|
||||
resolved "https://registry.yarnpkg.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz#a0dd74be81e2aa5c2f27e65ce283605ee4e2b7f9"
|
||||
integrity sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==
|
||||
|
||||
mime-db@1.52.0:
|
||||
version "1.52.0"
|
||||
resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.52.0.tgz#bbabcdc02859f4987301c856e3387ce5ec43bf70"
|
||||
integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==
|
||||
|
||||
mime-types@^2.1.12:
|
||||
version "2.1.35"
|
||||
resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.35.tgz#381a871b62a734450660ae3deee44813f70d959a"
|
||||
integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==
|
||||
dependencies:
|
||||
mime-db "1.52.0"
|
||||
|
||||
ms@^2.1.3:
|
||||
version "2.1.3"
|
||||
resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2"
|
||||
@@ -1050,6 +1212,11 @@ prop-types@^15.6.2, prop-types@^15.8.1:
|
||||
object-assign "^4.1.1"
|
||||
react-is "^16.13.1"
|
||||
|
||||
proxy-from-env@^2.1.0:
|
||||
version "2.1.0"
|
||||
resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-2.1.0.tgz#a7487568adad577cfaaa7e88c49cab3ab3081aba"
|
||||
integrity sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==
|
||||
|
||||
react-dom@^19.0.0:
|
||||
version "19.2.6"
|
||||
resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-19.2.6.tgz#44a81b0bcca22da814c00847d09d01c8615529b7"
|
||||
|
||||
Reference in New Issue
Block a user