Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a0b3ae7e34 | ||
|
|
48db53c95d | ||
|
|
9846f823b3 | ||
|
|
e6e6c92c28 | ||
|
|
fc0531395d | ||
|
|
6867a91b67 |
@@ -0,0 +1,39 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
|
||||||
|
|
||||||
|
from accounts.models import User
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(User)
|
||||||
|
class UserAdmin(BaseUserAdmin):
|
||||||
|
"""Admin config for the custom User model."""
|
||||||
|
|
||||||
|
fieldsets = (
|
||||||
|
(None, {"fields": ("email", "password")}),
|
||||||
|
("Personal info", {"fields": ("first_name", "last_name", "username")}),
|
||||||
|
(
|
||||||
|
"Permissions",
|
||||||
|
{
|
||||||
|
"fields": (
|
||||||
|
"is_active",
|
||||||
|
"is_staff",
|
||||||
|
"is_superuser",
|
||||||
|
"groups",
|
||||||
|
"user_permissions",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
("Important dates", {"fields": ("last_login", "date_joined")}),
|
||||||
|
)
|
||||||
|
add_fieldsets = (
|
||||||
|
(
|
||||||
|
None,
|
||||||
|
{
|
||||||
|
"classes": ("wide",),
|
||||||
|
"fields": ("email", "password1", "password2"),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
list_display = ("email", "first_name", "last_name", "is_staff")
|
||||||
|
search_fields = ("email", "first_name", "last_name")
|
||||||
|
ordering = ("email",)
|
||||||
@@ -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,29 @@
|
|||||||
|
from django.contrib.auth.models import AbstractUser
|
||||||
|
from django.db import models
|
||||||
|
|
||||||
|
|
||||||
|
class User(AbstractUser):
|
||||||
|
"""Custom User model using email as the unique identifier."""
|
||||||
|
|
||||||
|
email = models.EmailField(
|
||||||
|
unique=True,
|
||||||
|
max_length=254,
|
||||||
|
help_text="Email address used for authentication.",
|
||||||
|
)
|
||||||
|
username = models.CharField(
|
||||||
|
max_length=150,
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
help_text="Optional display name. Not used for authentication.",
|
||||||
|
)
|
||||||
|
|
||||||
|
USERNAME_FIELD = "email"
|
||||||
|
REQUIRED_FIELDS: list[str] = []
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
db_table = "accounts_user"
|
||||||
|
verbose_name = "User"
|
||||||
|
verbose_name_plural = "Users"
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return self.email
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import re
|
||||||
|
|
||||||
|
from django.contrib.auth import authenticate
|
||||||
|
from django.contrib.auth.hashers import make_password
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
from rest_framework import serializers
|
||||||
|
from rest_framework_simplejwt.tokens import RefreshToken
|
||||||
|
|
||||||
|
from accounts.models import User
|
||||||
|
|
||||||
|
EMAIL_REGEX: re.Pattern[str] = re.compile(r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$")
|
||||||
|
MIN_PASSWORD_LENGTH: int = 8
|
||||||
|
|
||||||
|
|
||||||
|
class RegisterSerializer(serializers.Serializer):
|
||||||
|
"""Validate and create a new user account."""
|
||||||
|
|
||||||
|
email = serializers.EmailField(max_length=254)
|
||||||
|
password = serializers.CharField(write_only=True)
|
||||||
|
password_confirm = serializers.CharField(write_only=True)
|
||||||
|
first_name = serializers.CharField(max_length=150, required=False, allow_blank=True, default="")
|
||||||
|
last_name = serializers.CharField(max_length=150, required=False, allow_blank=True, default="")
|
||||||
|
|
||||||
|
def validate_email(self, value: str) -> str:
|
||||||
|
"""Validate email format and check for duplicates."""
|
||||||
|
if not EMAIL_REGEX.match(value):
|
||||||
|
raise serializers.ValidationError("Invalid email format.")
|
||||||
|
if User.objects.filter(email__iexact=value).exists():
|
||||||
|
raise serializers.ValidationError("A user with this email already exists.")
|
||||||
|
return value.lower()
|
||||||
|
|
||||||
|
def validate_password(self, value: str) -> str:
|
||||||
|
"""Enforce minimum password length."""
|
||||||
|
if len(value) < MIN_PASSWORD_LENGTH:
|
||||||
|
raise serializers.ValidationError(
|
||||||
|
f"Password must be at least {MIN_PASSWORD_LENGTH} characters."
|
||||||
|
)
|
||||||
|
return value
|
||||||
|
|
||||||
|
def validate(self, attrs: dict[str, object]) -> dict[str, object]:
|
||||||
|
"""Ensure password and confirmation match."""
|
||||||
|
password = attrs.get("password")
|
||||||
|
password_confirm = attrs.get("password_confirm")
|
||||||
|
if password and password_confirm and password != password_confirm:
|
||||||
|
raise serializers.ValidationError(
|
||||||
|
{"password_confirm": "Passwords do not match."}
|
||||||
|
)
|
||||||
|
return attrs
|
||||||
|
|
||||||
|
def create(self, validated_data: dict[str, object]) -> User:
|
||||||
|
"""Create and return the new user."""
|
||||||
|
validated_data.pop("password_confirm")
|
||||||
|
validated_data["password"] = make_password(validated_data["password"])
|
||||||
|
# Ensure username is blank rather than None for unique constraint
|
||||||
|
validated_data.setdefault("username", "")
|
||||||
|
return User.objects.create(**validated_data)
|
||||||
|
|
||||||
|
|
||||||
|
class LoginSerializer(serializers.Serializer):
|
||||||
|
"""Authenticate user credentials and return JWT tokens."""
|
||||||
|
|
||||||
|
email = serializers.EmailField(max_length=254)
|
||||||
|
password = serializers.CharField(write_only=True)
|
||||||
|
|
||||||
|
def validate(self, attrs: dict[str, object]) -> dict[str, object]:
|
||||||
|
"""Authenticate the user and generate tokens."""
|
||||||
|
email = attrs.get("email", "")
|
||||||
|
password = attrs.get("password", "")
|
||||||
|
|
||||||
|
if not email or not password:
|
||||||
|
raise serializers.ValidationError("Both email and password are required.")
|
||||||
|
|
||||||
|
user = authenticate(
|
||||||
|
request=self.context.get("request"),
|
||||||
|
username=email,
|
||||||
|
password=password,
|
||||||
|
)
|
||||||
|
if user is None:
|
||||||
|
raise serializers.ValidationError("Invalid email or password.")
|
||||||
|
|
||||||
|
if not user.is_active:
|
||||||
|
raise serializers.ValidationError("This account is inactive.")
|
||||||
|
|
||||||
|
refresh = RefreshToken.for_user(user)
|
||||||
|
attrs["user"] = user
|
||||||
|
attrs["access"] = str(refresh.access_token)
|
||||||
|
attrs["refresh"] = str(refresh)
|
||||||
|
return attrs
|
||||||
|
|
||||||
|
|
||||||
|
class UserSerializer(serializers.ModelSerializer):
|
||||||
|
"""Public user profile serializer."""
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = User
|
||||||
|
fields = ("id", "email", "first_name", "last_name")
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
from django.urls import path
|
||||||
|
|
||||||
|
from accounts import views
|
||||||
|
|
||||||
|
app_name = "accounts"
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("register/", views.register_view, name="register"),
|
||||||
|
path("login/", views.login_view, name="login"),
|
||||||
|
]
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
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 accounts.serializers import LoginSerializer, 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,
|
||||||
|
)
|
||||||
+34
-17
@@ -1,5 +1,6 @@
|
|||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
@@ -15,14 +16,15 @@ ALLOWED_HOSTS: list[str] = ["*"]
|
|||||||
INSTALLED_APPS = [
|
INSTALLED_APPS = [
|
||||||
"django.contrib.contenttypes",
|
"django.contrib.contenttypes",
|
||||||
"django.contrib.auth",
|
"django.contrib.auth",
|
||||||
"django.contrib.sessions",
|
|
||||||
"django.contrib.admin",
|
"django.contrib.admin",
|
||||||
|
"django.contrib.sessions",
|
||||||
"django.contrib.messages",
|
"django.contrib.messages",
|
||||||
"django.contrib.staticfiles",
|
"django.contrib.staticfiles",
|
||||||
# Third-party
|
# Third-party
|
||||||
"rest_framework",
|
"rest_framework",
|
||||||
"corsheaders",
|
"corsheaders",
|
||||||
# Local
|
# Local
|
||||||
|
"accounts",
|
||||||
"jobs",
|
"jobs",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -82,12 +84,32 @@ DATABASES = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
LANGUAGE_CODE = "en-us"
|
AUTH_USER_MODEL = "accounts.User"
|
||||||
TIME_ZONE = "UTC"
|
|
||||||
USE_I18N = True
|
|
||||||
USE_TZ = True
|
|
||||||
|
|
||||||
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
REST_FRAMEWORK = {
|
||||||
|
"DEFAULT_AUTHENTICATION_CLASSES": (
|
||||||
|
"rest_framework_simplejwt.authentication.JWTAuthentication",
|
||||||
|
),
|
||||||
|
"DEFAULT_PERMISSION_CLASSES": (
|
||||||
|
"rest_framework.permissions.AllowAny",
|
||||||
|
),
|
||||||
|
"DEFAULT_RENDERER_CLASSES": (
|
||||||
|
"rest_framework.renderers.JSONRenderer",
|
||||||
|
),
|
||||||
|
"DEFAULT_THROTTLE_CLASSES": [
|
||||||
|
"rest_framework.throttling.AnonRateThrottle",
|
||||||
|
],
|
||||||
|
"DEFAULT_THROTTLE_RATES": {
|
||||||
|
"anon": os.environ.get("DJANGO_THROTTLE_ANON_RATE", "10/hour"),
|
||||||
|
"auth": os.environ.get("DJANGO_THROTTLE_AUTH_RATE", "5/minute"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
SIMPLE_JWT = {
|
||||||
|
"ACCESS_TOKEN_LIFETIME": timedelta(hours=24),
|
||||||
|
"REFRESH_TOKEN_LIFETIME": timedelta(days=30),
|
||||||
|
"AUTH_HEADER_TYPES": ("Bearer",),
|
||||||
|
}
|
||||||
|
|
||||||
# CORS
|
# CORS
|
||||||
CORS_ALLOW_ALL_ORIGINS = os.environ.get("CORS_ALLOW_ALL_ORIGINS", "False").lower() in ("true", "1", "yes")
|
CORS_ALLOW_ALL_ORIGINS = os.environ.get("CORS_ALLOW_ALL_ORIGINS", "False").lower() in ("true", "1", "yes")
|
||||||
@@ -96,16 +118,11 @@ CORS_ALLOWED_ORIGINS = os.environ.get(
|
|||||||
"http://localhost:3000,http://localhost:5173,http://127.0.0.1:3000",
|
"http://localhost:3000,http://localhost:5173,http://127.0.0.1:3000",
|
||||||
).split(",")
|
).split(",")
|
||||||
|
|
||||||
# REST Framework
|
LANGUAGE_CODE = "en-us"
|
||||||
REST_FRAMEWORK = {
|
TIME_ZONE = "UTC"
|
||||||
"DEFAULT_PERMISSION_CLASSES": [
|
USE_I18N = True
|
||||||
"rest_framework.permissions.IsAuthenticated",
|
USE_TZ = True
|
||||||
],
|
|
||||||
"DEFAULT_AUTHENTICATION_CLASSES": [
|
|
||||||
"rest_framework_simplejwt.authentication.JWTAuthentication",
|
|
||||||
"rest_framework.authentication.SessionAuthentication",
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
# Static files for admin
|
|
||||||
STATIC_URL = "/static/"
|
STATIC_URL = "/static/"
|
||||||
|
|
||||||
|
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
||||||
|
|||||||
@@ -3,5 +3,6 @@ from django.urls import include, path
|
|||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("admin/", admin.site.urls),
|
path("admin/", admin.site.urls),
|
||||||
|
path("api/auth/", include("accounts.urls")),
|
||||||
path("api/", include("jobs.urls")),
|
path("api/", include("jobs.urls")),
|
||||||
]
|
]
|
||||||
@@ -7,9 +7,15 @@ dependencies = [
|
|||||||
"django>=5.1,<6.0",
|
"django>=5.1,<6.0",
|
||||||
"django-cors-headers>=4.9.0",
|
"django-cors-headers>=4.9.0",
|
||||||
"djangorestframework>=3.17.1",
|
"djangorestframework>=3.17.1",
|
||||||
|
"djangorestframework-simplejwt>=5.3,<6.0",
|
||||||
"psycopg2-binary>=2.9",
|
"psycopg2-binary>=2.9",
|
||||||
|
"pydantic>=2.0",
|
||||||
|
"pydantic-settings>=2.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
requires = ["setuptools"]
|
requires = ["setuptools"]
|
||||||
build-backend = "setuptools.build_meta"
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[tool.setuptools.packages.find]
|
||||||
|
include = ["project*", "accounts*", "jobs*"]
|
||||||
|
|||||||
Generated
+172
@@ -2,6 +2,15 @@ version = 1
|
|||||||
revision = 3
|
revision = 3
|
||||||
requires-python = ">=3.12"
|
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]]
|
[[package]]
|
||||||
name = "api"
|
name = "api"
|
||||||
version = "1.0.0"
|
version = "1.0.0"
|
||||||
@@ -10,7 +19,10 @@ dependencies = [
|
|||||||
{ name = "django" },
|
{ name = "django" },
|
||||||
{ name = "django-cors-headers" },
|
{ name = "django-cors-headers" },
|
||||||
{ name = "djangorestframework" },
|
{ name = "djangorestframework" },
|
||||||
|
{ name = "djangorestframework-simplejwt" },
|
||||||
{ name = "psycopg2-binary" },
|
{ name = "psycopg2-binary" },
|
||||||
|
{ name = "pydantic" },
|
||||||
|
{ name = "pydantic-settings" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.metadata]
|
[package.metadata]
|
||||||
@@ -18,7 +30,10 @@ requires-dist = [
|
|||||||
{ name = "django", specifier = ">=5.1,<6.0" },
|
{ name = "django", specifier = ">=5.1,<6.0" },
|
||||||
{ name = "django-cors-headers", specifier = ">=4.9.0" },
|
{ name = "django-cors-headers", specifier = ">=4.9.0" },
|
||||||
{ name = "djangorestframework", specifier = ">=3.17.1" },
|
{ name = "djangorestframework", specifier = ">=3.17.1" },
|
||||||
|
{ name = "djangorestframework-simplejwt", specifier = ">=5.3,<6.0" },
|
||||||
{ name = "psycopg2-binary", specifier = ">=2.9" },
|
{ name = "psycopg2-binary", specifier = ">=2.9" },
|
||||||
|
{ name = "pydantic", specifier = ">=2.0" },
|
||||||
|
{ name = "pydantic-settings", specifier = ">=2.0" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "psycopg2-binary"
|
name = "psycopg2-binary"
|
||||||
version = "2.9.12"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "sqlparse"
|
name = "sqlparse"
|
||||||
version = "0.5.5"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "tzdata"
|
name = "tzdata"
|
||||||
version = "2026.2"
|
version = "2026.2"
|
||||||
|
|||||||
+1
-1
@@ -39,7 +39,7 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
environment:
|
environment:
|
||||||
REACT_APP_API_URL: "http://localhost:8000"
|
VITE_API_URL: "http://localhost:8000"
|
||||||
volumes:
|
volumes:
|
||||||
- ./web:/app
|
- ./web:/app
|
||||||
- /app/node_modules
|
- /app/node_modules
|
||||||
|
|||||||
@@ -0,0 +1,224 @@
|
|||||||
|
# User Authentication — Separate Login and Register Pages
|
||||||
|
|
||||||
|
**Issue:** crisleo-hermes/job-tracker#3
|
||||||
|
**Feature branch:** feature/user-auth
|
||||||
|
**Date:** 2026-05-26
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Implement separate Login and Register pages with a custom User model using email as the unique identifier. Backend exposes two endpoints (`POST /api/auth/register/` and `POST /api/auth/login/`) and frontend provides dedicated pages with form validation, error handling, and JWT token management.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Backend Specification
|
||||||
|
|
||||||
|
### Models
|
||||||
|
|
||||||
|
**`accounts.User`** (custom, extends `AbstractUser`)
|
||||||
|
|
||||||
|
| Field | Type | Constraints |
|
||||||
|
|---|---|---|
|
||||||
|
| `email` | `EmailField` | `unique=True`, `max_length=254` — used as the `USERNAME_FIELD` for authentication |
|
||||||
|
| `username` | `CharField` | `max_length=150`, `blank=True`, `null=True` — optional display name, NOT used for auth |
|
||||||
|
| `password` | (inherited) | `CharField(max_length=128)` — stored as Django PBKDF2 hash |
|
||||||
|
| `first_name` | (inherited) | `CharField(max_length=150, blank=True)` |
|
||||||
|
| `last_name` | (inherited) | `CharField(max_length=150, blank=True)` |
|
||||||
|
| `is_active` | (inherited) | `BooleanField(default=True)` |
|
||||||
|
| `is_staff` | (inherited) | `BooleanField(default=False)` |
|
||||||
|
| `date_joined` | (inherited) | `DateTimeField(auto_now_add=True)` |
|
||||||
|
|
||||||
|
**Meta:** `db_table = "accounts_user"`, `verbose_name = "User"`
|
||||||
|
**Manager:** `objects = UserManager()` (inherited from `AbstractUser`)
|
||||||
|
|
||||||
|
### API Endpoints
|
||||||
|
|
||||||
|
#### `POST /api/auth/register/`
|
||||||
|
|
||||||
|
Creates a new user account.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"email": "user@example.com",
|
||||||
|
"password": "securePass123",
|
||||||
|
"password_confirm": "securePass123",
|
||||||
|
"first_name": "John",
|
||||||
|
"last_name": "Doe"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Validation rules:**
|
||||||
|
- `email`: must match `^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`, must be unique (case-insensitive check), normalized to lowercase
|
||||||
|
- `password`: minimum 8 characters
|
||||||
|
- `password_confirm`: must match `password`
|
||||||
|
- `first_name`, `last_name`: optional, max 150 characters
|
||||||
|
|
||||||
|
**Success response (201 Created):**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"email": "user@example.com",
|
||||||
|
"first_name": "John",
|
||||||
|
"last_name": "Doe"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Error response (400 Bad Request):**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"email": ["A user with this email already exists."],
|
||||||
|
"password": ["Password must be at least 8 characters."],
|
||||||
|
"password_confirm": ["Passwords do not match."]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `POST /api/auth/login/`
|
||||||
|
|
||||||
|
Authenticates existing user and returns JWT tokens.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"email": "user@example.com",
|
||||||
|
"password": "securePass123"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Validation rules:**
|
||||||
|
- Both `email` and `password` are required
|
||||||
|
- Invalid credentials return generic "Invalid email or password" (no user enumeration)
|
||||||
|
- Inactive accounts are rejected with "This account is inactive"
|
||||||
|
|
||||||
|
**Success response (200 OK):**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"user": {
|
||||||
|
"id": 1,
|
||||||
|
"email": "user@example.com",
|
||||||
|
"first_name": "John",
|
||||||
|
"last_name": "Doe"
|
||||||
|
},
|
||||||
|
"access": "eyJ0eXAiOiJKV1Qi...",
|
||||||
|
"refresh": "eyJ0eXAiOiJKV1Qi..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Error response (401 Unauthorized):**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"non_field_errors": ["Invalid email or password."]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Django REST Framework Configuration
|
||||||
|
|
||||||
|
- **Auth classes:** `JWTAuthentication` (from `rest_framework_simplejwt`)
|
||||||
|
- **Default permissions:** `AllowAny` (auth endpoints are public by design)
|
||||||
|
- **Renderer:** `JSONRenderer` only (no browsable API in production)
|
||||||
|
- **SimpleJWT config:** Access token TTL = 24h, Refresh token TTL = 30d, Auth header type = `Bearer`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Frontend Specification
|
||||||
|
|
||||||
|
### Component Tree
|
||||||
|
|
||||||
|
```
|
||||||
|
App
|
||||||
|
├── BrowserRouter
|
||||||
|
│ └── AuthProvider (context)
|
||||||
|
│ ├── Route "/" → HomePage
|
||||||
|
│ ├── Route "/login" → LoginPage
|
||||||
|
│ └── Route "/register" → RegisterPage
|
||||||
|
```
|
||||||
|
|
||||||
|
### AuthContext (`contexts/AuthContext.tsx`)
|
||||||
|
|
||||||
|
**State shape:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface AuthState {
|
||||||
|
user: UserProfile | null;
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Actions (via `useReducer`):**
|
||||||
|
|
||||||
|
| Action | Trigger | State change |
|
||||||
|
|---|---|---|
|
||||||
|
| `AUTH_START` | API call initiated | `isLoading=true`, `error=null` |
|
||||||
|
| `AUTH_SUCCESS` | Login succeeds | `isLoading=false`, `isAuthenticated=true`, `user=payload` |
|
||||||
|
| `AUTH_FAILURE` | API error | `isLoading=false`, `error=payload` |
|
||||||
|
| `LOGOUT` | User clicks sign out | Reset to `initialState`, clear localStorage tokens |
|
||||||
|
| `CLEAR_ERROR` | User dismisses / new attempt | `error=null` |
|
||||||
|
|
||||||
|
**Exposed methods:** `login(payload)`, `register(payload)`, `logout()`, `clearError()`
|
||||||
|
|
||||||
|
**Key behavior:**
|
||||||
|
- `login()`: stores `access_token` and `refresh_token` in `localStorage`, dispatches `AUTH_SUCCESS`, redirects to HomePage
|
||||||
|
- `register()`: calls registration API, dispatches `AUTH_START`/`AUTH_FAILURE` only — does NOT auto-authenticate; the RegisterPage handles redirect to /login
|
||||||
|
- `logout()`: clears localStorage tokens, dispatches `LOGOUT`
|
||||||
|
|
||||||
|
### Page Flows
|
||||||
|
|
||||||
|
#### Registration Flow
|
||||||
|
1. User fills form (first_name, last_name, email, password, password_confirm)
|
||||||
|
2. Client-side: email format, password min 8 chars, password match
|
||||||
|
3. `POST /api/auth/register/`
|
||||||
|
4. On success → redirect to `/login` with success message
|
||||||
|
5. On error → display server-side validation errors
|
||||||
|
|
||||||
|
#### Login Flow
|
||||||
|
1. User fills form (email, password)
|
||||||
|
2. `POST /api/auth/login/`
|
||||||
|
3. On success → store JWT tokens in localStorage, redirect to `/`
|
||||||
|
4. On error → display "Invalid email or password"
|
||||||
|
|
||||||
|
### API Service (`services/authApi.ts`)
|
||||||
|
|
||||||
|
Axios-based HTTP client with:
|
||||||
|
- **Base URL:** from `VITE_API_URL` env var (default: `http://localhost:8000`)
|
||||||
|
- **Auth interceptor:** auto-attaches `Bearer <access_token>` from localStorage on all requests
|
||||||
|
- **Functions:**
|
||||||
|
- `registerUser(payload: RegisterPayload): Promise<UserProfile>`
|
||||||
|
- `loginUser(payload: LoginPayload): Promise<LoginResponse>`
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `VITE_API_URL` | `http://localhost:8000` | Django API base URL |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Validation Summary
|
||||||
|
|
||||||
|
| Check | Location | Implementation |
|
||||||
|
|---|---|---|
|
||||||
|
| Email format | Frontend + Backend | Regex on serializer (backend), `type="email"` on input (frontend) |
|
||||||
|
| Email uniqueness | Backend only | `User.objects.filter(email__iexact=...).exists()` in serializer |
|
||||||
|
| Password length | Backend + Frontend | `len(value) < 8` in serializer, `minLength={8}` on input (frontend) |
|
||||||
|
| Password match | Backend + Frontend | Serializer `validate()` method, `===` check in client form (implicit via payload) |
|
||||||
|
| Required fields | Backend + Frontend | `serializers.CharField(required=True)` + `required` attribute on inputs |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
- Passwords hashed using Django's PBKDF2HMAC (adaptive, recommended by OWASP)
|
||||||
|
- No user enumeration on login (generic "Invalid email or password" message)
|
||||||
|
- Inactive accounts rejected at login time
|
||||||
|
- User emails normalized to lowercase before storage
|
||||||
|
- JWT tokens stored in localStorage (trade-off: simple SPA integration vs XSS exposure — refresh tokens mitigate short exposure)
|
||||||
|
- `AUTH_HEADER_TYPES` set to `("Bearer",)` following RFC 6750
|
||||||
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
|
WORKDIR /app
|
||||||
|
|
||||||
COPY package.json ./
|
COPY package*.json ./
|
||||||
RUN yarn install --frozen-lockfile
|
RUN npm ci
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
CMD ["yarn", "dev"]
|
CMD ["npm", "run", "dev"]
|
||||||
@@ -13,6 +13,7 @@
|
|||||||
"@emotion/styled": "^11.14.0",
|
"@emotion/styled": "^11.14.0",
|
||||||
"@mui/icons-material": "^7.0.0",
|
"@mui/icons-material": "^7.0.0",
|
||||||
"@mui/material": "^7.0.0",
|
"@mui/material": "^7.0.0",
|
||||||
|
"axios": "^1.7.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-router-dom": "^7.0.0"
|
"react-router-dom": "^7.0.0"
|
||||||
|
|||||||
+15
-8
@@ -1,16 +1,23 @@
|
|||||||
import type { ReactNode } from "react";
|
import { type ReactNode } from "react";
|
||||||
import { BrowserRouter, Routes, Route } from "react-router-dom";
|
import { BrowserRouter, Routes, Route } from "react-router-dom";
|
||||||
import AppLayout from "./components/AppLayout.tsx";
|
import { AuthProvider } from "./contexts/AuthContext";
|
||||||
import HomePage from "./pages/HomePage.tsx";
|
import AppLayout from "./components/AppLayout";
|
||||||
|
import HomePage from "./pages/HomePage";
|
||||||
|
import LoginPage from "./pages/LoginPage";
|
||||||
|
import RegisterPage from "./pages/RegisterPage";
|
||||||
|
|
||||||
export default function App(): ReactNode {
|
export default function App(): ReactNode {
|
||||||
return (
|
return (
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<Routes>
|
<AuthProvider>
|
||||||
<Route element={<AppLayout />}>
|
<Routes>
|
||||||
<Route index element={<HomePage />} />
|
<Route element={<AppLayout />}>
|
||||||
</Route>
|
<Route index element={<HomePage />} />
|
||||||
</Routes>
|
<Route path="/login" element={<LoginPage />} />
|
||||||
|
<Route path="/register" element={<RegisterPage />} />
|
||||||
|
</Route>
|
||||||
|
</Routes>
|
||||||
|
</AuthProvider>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
useContext,
|
||||||
|
useReducer,
|
||||||
|
useCallback,
|
||||||
|
type ReactNode,
|
||||||
|
type Dispatch,
|
||||||
|
} from "react";
|
||||||
|
import {
|
||||||
|
loginUser,
|
||||||
|
registerUser,
|
||||||
|
type UserProfile,
|
||||||
|
type LoginPayload,
|
||||||
|
type RegisterPayload,
|
||||||
|
} from "../services/authApi";
|
||||||
|
|
||||||
|
// ── State ──────────────────────────────────────────────────────────────
|
||||||
|
interface AuthState {
|
||||||
|
user: UserProfile | null;
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialState: AuthState = {
|
||||||
|
user: null,
|
||||||
|
isAuthenticated: false,
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Actions ────────────────────────────────────────────────────────────
|
||||||
|
type AuthAction =
|
||||||
|
| { type: "AUTH_START" }
|
||||||
|
| { type: "AUTH_SUCCESS"; payload: UserProfile }
|
||||||
|
| { type: "AUTH_FAILURE"; payload: string }
|
||||||
|
| { type: "LOGOUT" }
|
||||||
|
| { 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,
|
||||||
|
isAuthenticated: true,
|
||||||
|
user: action.payload,
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
case "AUTH_FAILURE":
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
isLoading: false,
|
||||||
|
error: action.payload,
|
||||||
|
};
|
||||||
|
case "LOGOUT":
|
||||||
|
return { ...initialState };
|
||||||
|
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: () => void;
|
||||||
|
clearError: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||||
|
|
||||||
|
// ── Provider ───────────────────────────────────────────────────────────
|
||||||
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [state, dispatch] = useReducer(authReducer, initialState);
|
||||||
|
|
||||||
|
const login = useCallback(async (payload: LoginPayload) => {
|
||||||
|
dispatch({ type: "AUTH_START" });
|
||||||
|
try {
|
||||||
|
const response = await loginUser(payload);
|
||||||
|
localStorage.setItem("access_token", response.access);
|
||||||
|
localStorage.setItem("refresh_token", response.refresh);
|
||||||
|
dispatch({ type: "AUTH_SUCCESS", payload: response.user });
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message =
|
||||||
|
err instanceof Error ? err.message : "Login failed. Please try again.";
|
||||||
|
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.
|
||||||
|
// Set isLoading=false by clearing auth state (no auto-authentication).
|
||||||
|
dispatch({ type: "LOGOUT" });
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message =
|
||||||
|
err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: "Registration failed. Please try again.";
|
||||||
|
dispatch({ type: "AUTH_FAILURE", payload: message });
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const logout = useCallback(() => {
|
||||||
|
localStorage.removeItem("access_token");
|
||||||
|
localStorage.removeItem("refresh_token");
|
||||||
|
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;
|
||||||
|
}
|
||||||
+1
-1
@@ -4,7 +4,7 @@ import App from "./App.tsx";
|
|||||||
|
|
||||||
const rootElement = document.getElementById("root");
|
const rootElement = document.getElementById("root");
|
||||||
if (!rootElement) {
|
if (!rootElement) {
|
||||||
throw new Error("Root element not found");
|
throw new Error("Root element #root not found in the document.");
|
||||||
}
|
}
|
||||||
|
|
||||||
createRoot(rootElement).render(
|
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 { type ReactNode } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate, Link } from "react-router-dom";
|
||||||
|
import { useAuth } from "../contexts/AuthContext";
|
||||||
import Box from "@mui/material/Box";
|
import Box from "@mui/material/Box";
|
||||||
import Typography from "@mui/material/Typography";
|
import Typography from "@mui/material/Typography";
|
||||||
import Button from "@mui/material/Button";
|
import Button from "@mui/material/Button";
|
||||||
import Grid from "@mui/material/Grid";
|
import Grid from "@mui/material/Grid";
|
||||||
import Alert from "@mui/material/Alert";
|
import Alert from "@mui/material/Alert";
|
||||||
import AddIcon from "@mui/icons-material/Add";
|
import AddIcon from "@mui/icons-material/Add";
|
||||||
import UpdateCard from "../components/UpdateCard.tsx";
|
import UpdateCard from "../components/UpdateCard";
|
||||||
import MetricsPanel from "../components/MetricsPanel.tsx";
|
import MetricsPanel from "../components/MetricsPanel";
|
||||||
import LoadingSkeleton from "../components/LoadingSkeleton.tsx";
|
import LoadingSkeleton from "../components/LoadingSkeleton";
|
||||||
import { useDashboardData } from "../hooks/useDashboardData.ts";
|
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 navigate = useNavigate();
|
||||||
const { data, loading, error } = useDashboardData();
|
const { data, loading, error } = useDashboardData();
|
||||||
|
|
||||||
@@ -21,7 +62,12 @@ export default function HomePage(): ReactNode {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
<Typography variant="h4" component="h2" gutterBottom sx={{ fontWeight: 600 }}>
|
<Typography
|
||||||
|
variant="h4"
|
||||||
|
component="h2"
|
||||||
|
gutterBottom
|
||||||
|
sx={{ fontWeight: 600 }}
|
||||||
|
>
|
||||||
Dashboard
|
Dashboard
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
@@ -37,7 +83,9 @@ export default function HomePage(): ReactNode {
|
|||||||
<MetricsPanel metrics={data.metrics} />
|
<MetricsPanel metrics={data.metrics} />
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box sx={{ mb: 3, display: "flex", justifyContent: "flex-end" }}>
|
<Box
|
||||||
|
sx={{ mb: 3, display: "flex", justifyContent: "flex-end" }}
|
||||||
|
>
|
||||||
<Button
|
<Button
|
||||||
variant="contained"
|
variant="contained"
|
||||||
size="large"
|
size="large"
|
||||||
@@ -48,7 +96,12 @@ export default function HomePage(): ReactNode {
|
|||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Typography variant="h5" component="h3" gutterBottom sx={{ fontWeight: 600 }}>
|
<Typography
|
||||||
|
variant="h5"
|
||||||
|
component="h3"
|
||||||
|
gutterBottom
|
||||||
|
sx={{ fontWeight: 600 }}
|
||||||
|
>
|
||||||
Recent Updates
|
Recent Updates
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
@@ -61,7 +114,11 @@ export default function HomePage(): ReactNode {
|
|||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
{data.updates.length === 0 && (
|
{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.
|
No updates yet. Create your first job application to get started.
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
@@ -70,3 +127,13 @@ export default function HomePage(): ReactNode {
|
|||||||
</Box>
|
</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,55 @@
|
|||||||
|
import axios from "axios";
|
||||||
|
|
||||||
|
const apiClient = axios.create({
|
||||||
|
baseURL: import.meta.env.VITE_API_URL || "http://localhost:8000",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Attach access token to every request if present
|
||||||
|
apiClient.interceptors.request.use((config) => {
|
||||||
|
const token = localStorage.getItem("access_token");
|
||||||
|
if (token && config.headers) {
|
||||||
|
config.headers.Authorization = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
});
|
||||||
|
|
||||||
|
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;
|
||||||
|
email: string;
|
||||||
|
first_name: string;
|
||||||
|
last_name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoginResponse {
|
||||||
|
user: UserProfile;
|
||||||
|
access: string;
|
||||||
|
refresh: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user