Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c2fc7bc15f | ||
|
|
a0b3ae7e34 | ||
|
|
48db53c95d | ||
|
|
5b6f628380 |
+11
-1
@@ -1,7 +1,7 @@
|
|||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
|
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
|
||||||
|
|
||||||
from accounts.models import User
|
from accounts.models import BlacklistedToken, User
|
||||||
|
|
||||||
|
|
||||||
@admin.register(User)
|
@admin.register(User)
|
||||||
@@ -37,3 +37,13 @@ class UserAdmin(BaseUserAdmin):
|
|||||||
list_display = ("email", "first_name", "last_name", "is_staff")
|
list_display = ("email", "first_name", "last_name", "is_staff")
|
||||||
search_fields = ("email", "first_name", "last_name")
|
search_fields = ("email", "first_name", "last_name")
|
||||||
ordering = ("email",)
|
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,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')],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -27,3 +27,33 @@ class User(AbstractUser):
|
|||||||
|
|
||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
return self.email
|
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
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
|
from typing import Any
|
||||||
|
|
||||||
import re
|
import re
|
||||||
|
|
||||||
from django.contrib.auth import authenticate
|
from django.contrib.auth import authenticate
|
||||||
from django.contrib.auth.hashers import make_password
|
from django.contrib.auth.hashers import make_password
|
||||||
from django.utils.translation import gettext_lazy as _
|
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 import serializers
|
||||||
from rest_framework_simplejwt.tokens import RefreshToken
|
from rest_framework_simplejwt.tokens import RefreshToken
|
||||||
|
|
||||||
@@ -12,14 +15,27 @@ EMAIL_REGEX: re.Pattern[str] = re.compile(r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[
|
|||||||
MIN_PASSWORD_LENGTH: int = 8
|
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):
|
class RegisterSerializer(serializers.Serializer):
|
||||||
"""Validate and create a new user account."""
|
"""Validate and create a new user account."""
|
||||||
|
|
||||||
email = serializers.EmailField(max_length=254)
|
email = serializers.EmailField(max_length=254)
|
||||||
password = serializers.CharField(write_only=True)
|
password = serializers.CharField(write_only=True)
|
||||||
password_confirm = 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="")
|
first_name = SanitizedCharField(max_length=150, required=False, allow_blank=True, default="")
|
||||||
last_name = serializers.CharField(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:
|
def validate_email(self, value: str) -> str:
|
||||||
"""Validate email format and check for duplicates."""
|
"""Validate email format and check for duplicates."""
|
||||||
@@ -30,11 +46,15 @@ class RegisterSerializer(serializers.Serializer):
|
|||||||
return value.lower()
|
return value.lower()
|
||||||
|
|
||||||
def validate_password(self, value: str) -> str:
|
def validate_password(self, value: str) -> str:
|
||||||
"""Enforce minimum password length."""
|
"""Enforce minimum password length and Django validators."""
|
||||||
if len(value) < MIN_PASSWORD_LENGTH:
|
if len(value) < MIN_PASSWORD_LENGTH:
|
||||||
raise serializers.ValidationError(
|
raise serializers.ValidationError(
|
||||||
f"Password must be at least {MIN_PASSWORD_LENGTH} characters."
|
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
|
return value
|
||||||
|
|
||||||
def validate(self, attrs: dict[str, object]) -> dict[str, object]:
|
def validate(self, attrs: dict[str, object]) -> dict[str, object]:
|
||||||
@@ -51,7 +71,6 @@ class RegisterSerializer(serializers.Serializer):
|
|||||||
"""Create and return the new user."""
|
"""Create and return the new user."""
|
||||||
validated_data.pop("password_confirm")
|
validated_data.pop("password_confirm")
|
||||||
validated_data["password"] = make_password(validated_data["password"])
|
validated_data["password"] = make_password(validated_data["password"])
|
||||||
# Ensure username is blank rather than None for unique constraint
|
|
||||||
validated_data.setdefault("username", "")
|
validated_data.setdefault("username", "")
|
||||||
return User.objects.create(**validated_data)
|
return User.objects.create(**validated_data)
|
||||||
|
|
||||||
@@ -89,8 +108,30 @@ class LoginSerializer(serializers.Serializer):
|
|||||||
|
|
||||||
|
|
||||||
class UserSerializer(serializers.ModelSerializer):
|
class UserSerializer(serializers.ModelSerializer):
|
||||||
"""Public user profile serializer."""
|
"""Public user profile serializer — limited fields, no email."""
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = User
|
model = User
|
||||||
fields = ("id", "email", "first_name", "last_name")
|
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
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
from django.urls import path
|
from django.urls import path
|
||||||
|
from rest_framework_simplejwt.views import TokenVerifyView
|
||||||
|
|
||||||
from accounts import views
|
from accounts import views
|
||||||
|
|
||||||
@@ -7,4 +8,8 @@ app_name = "accounts"
|
|||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("register/", views.register_view, name="register"),
|
path("register/", views.register_view, name="register"),
|
||||||
path("login/", views.login_view, name="login"),
|
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"),
|
||||||
]
|
]
|
||||||
+73
-1
@@ -6,8 +6,16 @@ from rest_framework.permissions import AllowAny
|
|||||||
from rest_framework.request import Request
|
from rest_framework.request import Request
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework.throttling import AnonRateThrottle
|
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, RegisterSerializer, UserSerializer
|
from accounts.serializers import (
|
||||||
|
LoginSerializer,
|
||||||
|
LogoutSerializer,
|
||||||
|
RefreshRequestSerializer,
|
||||||
|
RegisterSerializer,
|
||||||
|
UserSerializer,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class AuthRateThrottle(AnonRateThrottle):
|
class AuthRateThrottle(AnonRateThrottle):
|
||||||
@@ -47,3 +55,67 @@ def login_view(request: Request) -> Response:
|
|||||||
},
|
},
|
||||||
status=status.HTTP_200_OK,
|
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,17 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
from .models import JobApplication, JobUpdate
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(JobApplication)
|
||||||
|
class JobApplicationAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ["company_name", "position_title", "status", "created_at", "updated_at"]
|
||||||
|
list_filter = ["status"]
|
||||||
|
search_fields = ["company_name", "position_title"]
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(JobUpdate)
|
||||||
|
class JobUpdateAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ["job_application", "from_status", "to_status", "created_at"]
|
||||||
|
list_filter = ["to_status"]
|
||||||
|
search_fields = ["job_application__company_name"]
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class JobsConfig(AppConfig):
|
||||||
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
|
name = 'jobs'
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# Generated by Django 5.2.14 on 2026-05-26 00:55
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='JobApplication',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('company_name', models.CharField(max_length=255)),
|
||||||
|
('position_title', models.CharField(max_length=255)),
|
||||||
|
('status', models.CharField(choices=[('APPLIED', 'Applied'), ('SCREENING', 'Screening'), ('INTERVIEW', 'Interview'), ('OFFER', 'Offer'), ('REJECTED', 'Rejected'), ('WITHDRAWN', 'Withdrawn')], default='APPLIED', max_length=20)),
|
||||||
|
('notes', models.TextField(blank=True, default='')),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['-updated_at'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='JobUpdate',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('from_status', models.CharField(blank=True, choices=[('APPLIED', 'Applied'), ('SCREENING', 'Screening'), ('INTERVIEW', 'Interview'), ('OFFER', 'Offer'), ('REJECTED', 'Rejected'), ('WITHDRAWN', 'Withdrawn')], max_length=20, null=True)),
|
||||||
|
('to_status', models.CharField(choices=[('APPLIED', 'Applied'), ('SCREENING', 'Screening'), ('INTERVIEW', 'Interview'), ('OFFER', 'Offer'), ('REJECTED', 'Rejected'), ('WITHDRAWN', 'Withdrawn')], max_length=20)),
|
||||||
|
('notes', models.TextField(blank=True, default='')),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('metrics', models.JSONField(blank=True, default=dict)),
|
||||||
|
('job_application', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='updates', to='jobs.jobapplication')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Job Update',
|
||||||
|
'verbose_name_plural': 'Job Updates',
|
||||||
|
'ordering': ['-created_at'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
from django.db import models
|
||||||
|
|
||||||
|
from accounts.models import User
|
||||||
|
|
||||||
|
|
||||||
|
class StatusChoices(models.TextChoices):
|
||||||
|
APPLIED = "APPLIED", "Applied"
|
||||||
|
SCREENING = "SCREENING", "Screening"
|
||||||
|
INTERVIEW = "INTERVIEW", "Interview"
|
||||||
|
OFFER = "OFFER", "Offer"
|
||||||
|
REJECTED = "REJECTED", "Rejected"
|
||||||
|
WITHDRAWN = "WITHDRAWN", "Withdrawn"
|
||||||
|
|
||||||
|
|
||||||
|
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(
|
||||||
|
max_length=20,
|
||||||
|
choices=StatusChoices.choices,
|
||||||
|
default=StatusChoices.APPLIED,
|
||||||
|
)
|
||||||
|
notes = models.TextField(blank=True, default="")
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
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}"
|
||||||
|
|
||||||
|
|
||||||
|
class JobUpdate(models.Model):
|
||||||
|
job_application = models.ForeignKey(
|
||||||
|
JobApplication,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="updates",
|
||||||
|
)
|
||||||
|
from_status = models.CharField(
|
||||||
|
max_length=20,
|
||||||
|
choices=StatusChoices.choices,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
)
|
||||||
|
to_status = models.CharField(
|
||||||
|
max_length=20,
|
||||||
|
choices=StatusChoices.choices,
|
||||||
|
)
|
||||||
|
notes = models.TextField(blank=True, default="")
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
# Store metrics as JSON at update time for historical accuracy
|
||||||
|
metrics = models.JSONField(default=dict, blank=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["-created_at"]
|
||||||
|
verbose_name = "Job Update"
|
||||||
|
verbose_name_plural = "Job Updates"
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return f"Update #{self.id}: {self.job_application.company_name} -> {self.to_status}"
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
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)
|
||||||
|
job_id = serializers.IntegerField(source="job_application.id", read_only=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = JobUpdate
|
||||||
|
fields = [
|
||||||
|
"id",
|
||||||
|
"job_id",
|
||||||
|
"company_name",
|
||||||
|
"position_title",
|
||||||
|
"from_status",
|
||||||
|
"to_status",
|
||||||
|
"notes",
|
||||||
|
"created_at",
|
||||||
|
"metrics",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
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",
|
||||||
|
"notes",
|
||||||
|
"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):
|
||||||
|
total_applications = serializers.IntegerField()
|
||||||
|
status_breakdown = serializers.DictField(child=serializers.IntegerField())
|
||||||
|
interviews_count = serializers.IntegerField()
|
||||||
|
offers_count = serializers.IntegerField()
|
||||||
|
rejection_rate = serializers.FloatField()
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
# Create your tests here.
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
from django.urls import include, path
|
||||||
|
from rest_framework.routers import DefaultRouter
|
||||||
|
|
||||||
|
from . import views
|
||||||
|
|
||||||
|
router = DefaultRouter()
|
||||||
|
router.register(r"applications", views.JobApplicationViewSet, basename="job-application")
|
||||||
|
router.register(r"updates", views.JobUpdateViewSet, basename="job-update")
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("", include(router.urls)),
|
||||||
|
]
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
from django.db.models import Count, Q
|
||||||
|
from rest_framework import viewsets
|
||||||
|
from rest_framework.decorators import action
|
||||||
|
from rest_framework.permissions import IsAuthenticated
|
||||||
|
from rest_framework.request import Request
|
||||||
|
from rest_framework.response import Response
|
||||||
|
|
||||||
|
from .models import JobApplication, JobUpdate
|
||||||
|
from .serializers import (
|
||||||
|
DashboardMetricsSerializer,
|
||||||
|
JobApplicationSerializer,
|
||||||
|
JobUpdateSerializer,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class JobApplicationViewSet(viewsets.ModelViewSet):
|
||||||
|
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):
|
||||||
|
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."""
|
||||||
|
updates = (
|
||||||
|
self.get_queryset()
|
||||||
|
.select_related("job_application")
|
||||||
|
.order_by("-created_at")[:3]
|
||||||
|
)
|
||||||
|
serializer = self.get_serializer(updates, many=True)
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
@action(detail=False, methods=["get"])
|
||||||
|
def metrics(self, request: Request) -> Response:
|
||||||
|
"""Return dashboard metrics: total apps, status breakdown, interview/offer counts."""
|
||||||
|
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(
|
||||||
|
app_qs.values("status")
|
||||||
|
.annotate(count=Count("id"))
|
||||||
|
.values_list("status", "count")
|
||||||
|
)
|
||||||
|
interview_count = app_qs.filter(
|
||||||
|
Q(status="INTERVIEW") | Q(status="OFFER")
|
||||||
|
).count()
|
||||||
|
offer_count = app_qs.filter(status="OFFER").count()
|
||||||
|
rejection_rate = (
|
||||||
|
round(
|
||||||
|
status_counts.get("REJECTED", 0) / total * 100, 1
|
||||||
|
)
|
||||||
|
if total > 0
|
||||||
|
else 0.0
|
||||||
|
)
|
||||||
|
|
||||||
|
serializer = DashboardMetricsSerializer(
|
||||||
|
instance={
|
||||||
|
"total_applications": total,
|
||||||
|
"status_breakdown": status_counts,
|
||||||
|
"interviews_count": interview_count,
|
||||||
|
"offers_count": offer_count,
|
||||||
|
"rejection_rate": rejection_rate,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return Response(serializer.data)
|
||||||
+40
-7
@@ -14,18 +14,25 @@ DEBUG = os.environ.get("DJANGO_DEBUG", "True").lower() in ("true", "1", "yes")
|
|||||||
ALLOWED_HOSTS: list[str] = ["*"]
|
ALLOWED_HOSTS: list[str] = ["*"]
|
||||||
|
|
||||||
INSTALLED_APPS = [
|
INSTALLED_APPS = [
|
||||||
"django.contrib.admin",
|
|
||||||
"django.contrib.auth",
|
|
||||||
"django.contrib.contenttypes",
|
"django.contrib.contenttypes",
|
||||||
|
"django.contrib.auth",
|
||||||
|
"django.contrib.admin",
|
||||||
"django.contrib.sessions",
|
"django.contrib.sessions",
|
||||||
"django.contrib.messages",
|
"django.contrib.messages",
|
||||||
"django.contrib.staticfiles",
|
"django.contrib.staticfiles",
|
||||||
|
# Third-party
|
||||||
"rest_framework",
|
"rest_framework",
|
||||||
|
"rest_framework_simplejwt.token_blacklist",
|
||||||
|
"corsheaders",
|
||||||
|
# Local
|
||||||
"accounts",
|
"accounts",
|
||||||
|
"jobs",
|
||||||
]
|
]
|
||||||
|
|
||||||
MIDDLEWARE = [
|
MIDDLEWARE = [
|
||||||
|
"corsheaders.middleware.CorsMiddleware",
|
||||||
"django.middleware.security.SecurityMiddleware",
|
"django.middleware.security.SecurityMiddleware",
|
||||||
|
"accounts.middleware.SecurityHeadersMiddleware",
|
||||||
"django.contrib.sessions.middleware.SessionMiddleware",
|
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||||
"django.middleware.common.CommonMiddleware",
|
"django.middleware.common.CommonMiddleware",
|
||||||
"django.middleware.csrf.CsrfViewMiddleware",
|
"django.middleware.csrf.CsrfViewMiddleware",
|
||||||
@@ -60,7 +67,7 @@ _database_url = os.environ.get(
|
|||||||
"postgres://jobtracker:***@localhost:5432/jobtracker",
|
"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("@")
|
_db_parts = _database_url.replace("postgres://", "").split("@")
|
||||||
_credentials, _host_db = _db_parts[0], _db_parts[1]
|
_credentials, _host_db = _db_parts[0], _db_parts[1]
|
||||||
_db_user, _db_pass = _credentials.split(":")
|
_db_user, _db_pass = _credentials.split(":")
|
||||||
@@ -86,31 +93,57 @@ REST_FRAMEWORK = {
|
|||||||
"rest_framework_simplejwt.authentication.JWTAuthentication",
|
"rest_framework_simplejwt.authentication.JWTAuthentication",
|
||||||
),
|
),
|
||||||
"DEFAULT_PERMISSION_CLASSES": (
|
"DEFAULT_PERMISSION_CLASSES": (
|
||||||
"rest_framework.permissions.AllowAny",
|
"rest_framework.permissions.IsAuthenticated",
|
||||||
),
|
),
|
||||||
"DEFAULT_RENDERER_CLASSES": (
|
"DEFAULT_RENDERER_CLASSES": (
|
||||||
"rest_framework.renderers.JSONRenderer",
|
"rest_framework.renderers.JSONRenderer",
|
||||||
),
|
),
|
||||||
|
"EXCEPTION_HANDLER": "accounts.exceptions.api_exception_handler",
|
||||||
"DEFAULT_THROTTLE_CLASSES": [
|
"DEFAULT_THROTTLE_CLASSES": [
|
||||||
"rest_framework.throttling.AnonRateThrottle",
|
"rest_framework.throttling.AnonRateThrottle",
|
||||||
|
"accounts.throttles.UserRateThrottle",
|
||||||
],
|
],
|
||||||
"DEFAULT_THROTTLE_RATES": {
|
"DEFAULT_THROTTLE_RATES": {
|
||||||
"anon": os.environ.get("DJANGO_THROTTLE_ANON_RATE", "10/hour"),
|
"anon": os.environ.get("DJANGO_THROTTLE_ANON_RATE", "10/hour"),
|
||||||
"auth": os.environ.get("DJANGO_THROTTLE_AUTH_RATE", "5/minute"),
|
"auth": os.environ.get("DJANGO_THROTTLE_AUTH_RATE", "5/minute"),
|
||||||
|
"user": os.environ.get("DJANGO_THROTTLE_USER_RATE", "60/minute"),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
SIMPLE_JWT = {
|
SIMPLE_JWT = {
|
||||||
"ACCESS_TOKEN_LIFETIME": timedelta(hours=24),
|
"ACCESS_TOKEN_LIFETIME": timedelta(minutes=15),
|
||||||
"REFRESH_TOKEN_LIFETIME": timedelta(days=30),
|
"REFRESH_TOKEN_LIFETIME": timedelta(days=7),
|
||||||
|
"ROTATE_REFRESH_TOKENS": True,
|
||||||
|
"BLACKLIST_AFTER_ROTATION": True,
|
||||||
"AUTH_HEADER_TYPES": ("Bearer",),
|
"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")
|
||||||
|
CORS_ALLOWED_ORIGINS = os.environ.get(
|
||||||
|
"CORS_ALLOWED_ORIGINS",
|
||||||
|
"http://localhost:3000,http://localhost:5173,http://127.0.0.1:3000",
|
||||||
|
).split(",")
|
||||||
|
|
||||||
LANGUAGE_CODE = "en-us"
|
LANGUAGE_CODE = "en-us"
|
||||||
TIME_ZONE = "UTC"
|
TIME_ZONE = "UTC"
|
||||||
USE_I18N = True
|
USE_I18N = True
|
||||||
USE_TZ = True
|
USE_TZ = True
|
||||||
|
|
||||||
STATIC_URL = "static/"
|
STATIC_URL = "/static/"
|
||||||
|
|
||||||
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
||||||
+4
-2
@@ -1,6 +1,8 @@
|
|||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from django.urls import include, path
|
from django.urls import include, path
|
||||||
|
|
||||||
urlpatterns: list = [
|
urlpatterns = [
|
||||||
path("api/auth/", include("accounts.urls", namespace="accounts")),
|
path("admin/", admin.site.urls),
|
||||||
|
path("api/auth/", include("accounts.urls")),
|
||||||
|
path("api/", include("jobs.urls")),
|
||||||
]
|
]
|
||||||
+3
-2
@@ -5,7 +5,8 @@ description = "Job Tracker API"
|
|||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"django>=5.1,<6.0",
|
"django>=5.1,<6.0",
|
||||||
"djangorestframework>=3.15,<4.0",
|
"django-cors-headers>=4.9.0",
|
||||||
|
"djangorestframework>=3.17.1",
|
||||||
"djangorestframework-simplejwt>=5.3,<6.0",
|
"djangorestframework-simplejwt>=5.3,<6.0",
|
||||||
"psycopg2-binary>=2.9",
|
"psycopg2-binary>=2.9",
|
||||||
"pydantic>=2.0",
|
"pydantic>=2.0",
|
||||||
@@ -17,4 +18,4 @@ requires = ["setuptools"]
|
|||||||
build-backend = "setuptools.build_meta"
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
[tool.setuptools.packages.find]
|
[tool.setuptools.packages.find]
|
||||||
include = ["project*", "accounts*"]
|
include = ["project*", "accounts*", "jobs*"]
|
||||||
|
|||||||
Generated
+16
-1
@@ -17,6 +17,7 @@ version = "1.0.0"
|
|||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "django" },
|
{ name = "django" },
|
||||||
|
{ name = "django-cors-headers" },
|
||||||
{ name = "djangorestframework" },
|
{ name = "djangorestframework" },
|
||||||
{ name = "djangorestframework-simplejwt" },
|
{ name = "djangorestframework-simplejwt" },
|
||||||
{ name = "psycopg2-binary" },
|
{ name = "psycopg2-binary" },
|
||||||
@@ -27,7 +28,8 @@ dependencies = [
|
|||||||
[package.metadata]
|
[package.metadata]
|
||||||
requires-dist = [
|
requires-dist = [
|
||||||
{ name = "django", specifier = ">=5.1,<6.0" },
|
{ name = "django", specifier = ">=5.1,<6.0" },
|
||||||
{ name = "djangorestframework", specifier = ">=3.15,<4.0" },
|
{ name = "django-cors-headers", specifier = ">=4.9.0" },
|
||||||
|
{ name = "djangorestframework", specifier = ">=3.17.1" },
|
||||||
{ name = "djangorestframework-simplejwt", specifier = ">=5.3,<6.0" },
|
{ 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", specifier = ">=2.0" },
|
||||||
@@ -57,6 +59,19 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/14/44/f172870cf87aa25afef48fb72adba89ee8b77fcab6f3b23d240b923f1528/django-5.2.14-py3-none-any.whl", hash = "sha256:6f712143bd3064310d1f50fac859c3e9a274bdcfc9595339853be7779297fc76", size = 8311320, upload-time = "2026-05-05T13:57:25.795Z" },
|
{ url = "https://files.pythonhosted.org/packages/14/44/f172870cf87aa25afef48fb72adba89ee8b77fcab6f3b23d240b923f1528/django-5.2.14-py3-none-any.whl", hash = "sha256:6f712143bd3064310d1f50fac859c3e9a274bdcfc9595339853be7779297fc76", size = 8311320, upload-time = "2026-05-05T13:57:25.795Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "django-cors-headers"
|
||||||
|
version = "4.9.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "asgiref" },
|
||||||
|
{ name = "django" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/21/39/55822b15b7ec87410f34cd16ce04065ff390e50f9e29f31d6d116fc80456/django_cors_headers-4.9.0.tar.gz", hash = "sha256:fe5d7cb59fdc2c8c646ce84b727ac2bca8912a247e6e68e1fb507372178e59e8", size = 21458, upload-time = "2025-09-18T10:40:52.326Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/30/d8/19ed1e47badf477d17fb177c1c19b5a21da0fd2d9f093f23be3fb86c5fab/django_cors_headers-4.9.0-py3-none-any.whl", hash = "sha256:15c7f20727f90044dcee2216a9fd7303741a864865f0c3657e28b7056f61b449", size = 12809, upload-time = "2025-09-18T10:40:50.843Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "djangorestframework"
|
name = "djangorestframework"
|
||||||
version = "3.17.1"
|
version = "3.17.1"
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
# Spec: Home Page with MUI Components
|
||||||
|
|
||||||
|
## Ticket
|
||||||
|
Gitea: crisleo-hermes/job-tracker#2
|
||||||
|
Kanban: t_e6936299
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
Build a home page at route `/` using Material-UI (MUI) components that displays the last three job updates and dashboard metrics, with a button to create a new job application.
|
||||||
|
|
||||||
|
## Pages
|
||||||
|
|
||||||
|
### HomePage (`/`)
|
||||||
|
- AppBar with title "Job Tracker"
|
||||||
|
- Dashboard metrics cards row (total applications, interviews, offers, rejection rate)
|
||||||
|
- Last 3 job updates displayed as cards
|
||||||
|
- "Create New Job Application" button (navigates to creation flow)
|
||||||
|
- Loading skeleton while data fetches
|
||||||
|
- Error snackbar on API failure
|
||||||
|
- Responsive Grid layout
|
||||||
|
|
||||||
|
## API Endpoints Consumed
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| GET | `/api/updates/latest/` | 3 most recent JobUpdates with nested job info |
|
||||||
|
| GET | `/api/updates/metrics/` | Dashboard metrics (total, status breakdown, interviews, offers, rejection rate) |
|
||||||
|
|
||||||
|
### Response Shapes
|
||||||
|
|
||||||
|
**GET /api/updates/latest/**
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"job_id": 1,
|
||||||
|
"company_name": "Acme Corp",
|
||||||
|
"position_title": "Software Engineer",
|
||||||
|
"from_status": "APPLIED",
|
||||||
|
"to_status": "INTERVIEW",
|
||||||
|
"notes": "Moving to next round",
|
||||||
|
"created_at": "2025-01-15T10:00:00Z",
|
||||||
|
"metrics": {"response_time_days": 5}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
**GET /api/updates/metrics/**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"total_applications": 12,
|
||||||
|
"status_breakdown": {"APPLIED": 5, "INTERVIEW": 4, "OFFER": 2, "REJECTED": 1},
|
||||||
|
"interviews_count": 6,
|
||||||
|
"offers_count": 2,
|
||||||
|
"rejection_rate": 8.3
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
### `AppLayout`
|
||||||
|
- MUI `AppBar` with `Toolbar`, `Typography` ("Job Tracker")
|
||||||
|
- Wraps child content via `Outlet` from React Router
|
||||||
|
|
||||||
|
### `UpdateCard`
|
||||||
|
- MUI `Card` → `CardContent`
|
||||||
|
- Displays: company name, position title, status change (`from_status` → `to_status`), created date, metrics summary
|
||||||
|
- Props: `JobUpdate`
|
||||||
|
|
||||||
|
### `MetricsPanel`
|
||||||
|
- MUI `Grid` container with metric `Card` items
|
||||||
|
- Each metric: total applications, interviews count, offers count, rejection rate
|
||||||
|
- Props: `DashboardMetrics`
|
||||||
|
|
||||||
|
### `LoadingSkeleton`
|
||||||
|
- MUI `Skeleton` components mimicking the home page layout
|
||||||
|
|
||||||
|
## Data Fetching
|
||||||
|
|
||||||
|
Custom hook `useDashboardData` using React's `useEffect` + `useState`:
|
||||||
|
- Fetches from `/api/updates/latest/` and `/api/updates/metrics/` concurrently (`Promise.all`)
|
||||||
|
- Vite proxy: `/api` → `http://localhost:8000/api`
|
||||||
|
- States: loading, error (with error message), data
|
||||||
|
|
||||||
|
## TypeScript Types
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface JobUpdate {
|
||||||
|
id: number;
|
||||||
|
job_id: number;
|
||||||
|
company_name: string;
|
||||||
|
position_title: string;
|
||||||
|
from_status: string | null;
|
||||||
|
to_status: string;
|
||||||
|
notes: string;
|
||||||
|
created_at: string;
|
||||||
|
metrics: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DashboardMetrics {
|
||||||
|
total_applications: number;
|
||||||
|
status_breakdown: Record<string, number>;
|
||||||
|
interviews_count: number;
|
||||||
|
offers_count: number;
|
||||||
|
rejection_rate: number;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
- [x] Home page at route `/`
|
||||||
|
- [x] MUI components exclusively (AppBar, Typography, Card, Grid, Button, Skeleton, Snackbar)
|
||||||
|
- [x] Last 3 job updates: job ID, status, creation date, metrics
|
||||||
|
- [x] "Create New Job Application" button navigates to `/applications/new`
|
||||||
|
- [x] Loading indicators while fetching data (Skeleton components)
|
||||||
|
- [x] Graceful error handling (Snackbar with error message)
|
||||||
|
- [x] Responsive layout (Grid breakpoints)
|
||||||
@@ -3,6 +3,12 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&display=swap"
|
||||||
|
/>
|
||||||
<title>Job Tracker</title>
|
<title>Job Tracker</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
+6
-2
@@ -5,10 +5,14 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "tsc -b && vite build",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@emotion/react": "^11.14.0",
|
||||||
|
"@emotion/styled": "^11.14.0",
|
||||||
|
"@mui/icons-material": "^7.0.0",
|
||||||
|
"@mui/material": "^7.0.0",
|
||||||
"axios": "^1.7.0",
|
"axios": "^1.7.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
@@ -18,7 +22,7 @@
|
|||||||
"@types/react": "^19.0.0",
|
"@types/react": "^19.0.0",
|
||||||
"@types/react-dom": "^19.0.0",
|
"@types/react-dom": "^19.0.0",
|
||||||
"@vitejs/plugin-react": "^4.3.0",
|
"@vitejs/plugin-react": "^4.3.0",
|
||||||
"typescript": "^5.6.0",
|
"typescript": "^5.7.0",
|
||||||
"vite": "^6.0.0"
|
"vite": "^6.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+84
-6
@@ -1,17 +1,95 @@
|
|||||||
import { BrowserRouter, Routes, Route } from "react-router-dom";
|
import { type ReactNode } from "react";
|
||||||
import { AuthProvider } from "./contexts/AuthContext";
|
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 HomePage from "./pages/HomePage";
|
||||||
import LoginPage from "./pages/LoginPage";
|
import LoginPage from "./pages/LoginPage";
|
||||||
import RegisterPage from "./pages/RegisterPage";
|
import RegisterPage from "./pages/RegisterPage";
|
||||||
|
|
||||||
export default function App() {
|
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 (
|
return (
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<HomePage />} />
|
<Route
|
||||||
<Route path="/login" element={<LoginPage />} />
|
path="/login"
|
||||||
<Route path="/register" element={<RegisterPage />} />
|
element={
|
||||||
|
<PublicRoute>
|
||||||
|
<LoginPage />
|
||||||
|
</PublicRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/register"
|
||||||
|
element={
|
||||||
|
<PublicRoute>
|
||||||
|
<RegisterPage />
|
||||||
|
</PublicRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route element={<AppLayout />}>
|
||||||
|
<Route
|
||||||
|
index
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<HomePage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { type ReactNode } from "react";
|
||||||
|
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: {
|
||||||
|
primary: {
|
||||||
|
main: "#1976d2",
|
||||||
|
},
|
||||||
|
background: {
|
||||||
|
default: "#f5f5f5",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
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, 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 }}>
|
||||||
|
<Outlet />
|
||||||
|
</Container>
|
||||||
|
</Box>
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import Skeleton from "@mui/material/Skeleton";
|
||||||
|
import Box from "@mui/material/Box";
|
||||||
|
import Grid from "@mui/material/Grid";
|
||||||
|
|
||||||
|
export default function LoadingSkeleton(): ReactNode {
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
{/* Metrics skeleton row */}
|
||||||
|
<Grid container spacing={3} sx={{ mb: 4 }}>
|
||||||
|
{[...Array(4)].map((_, index) => (
|
||||||
|
<Grid key={index} size={{ xs: 12, sm: 6, md: 3 }}>
|
||||||
|
<Skeleton variant="rounded" height={100} />
|
||||||
|
</Grid>
|
||||||
|
))}
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{/* Button skeleton */}
|
||||||
|
<Skeleton variant="rounded" width={240} height={40} sx={{ mb: 3 }} />
|
||||||
|
|
||||||
|
{/* Update cards skeleton */}
|
||||||
|
<Grid container spacing={3}>
|
||||||
|
{[...Array(3)].map((_, index) => (
|
||||||
|
<Grid key={index} size={{ xs: 12, sm: 6, md: 4 }}>
|
||||||
|
<Skeleton variant="rounded" height={180} />
|
||||||
|
</Grid>
|
||||||
|
))}
|
||||||
|
</Grid>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import Grid from "@mui/material/Grid";
|
||||||
|
import Card from "@mui/material/Card";
|
||||||
|
import CardContent from "@mui/material/CardContent";
|
||||||
|
import Typography from "@mui/material/Typography";
|
||||||
|
import type { DashboardMetrics } from "../types/index.ts";
|
||||||
|
|
||||||
|
interface MetricsPanelProps {
|
||||||
|
metrics: DashboardMetrics;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MetricCardProps {
|
||||||
|
title: string;
|
||||||
|
value: string | number;
|
||||||
|
subtitle?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function MetricCard({ title, value, subtitle }: MetricCardProps): ReactNode {
|
||||||
|
return (
|
||||||
|
<Card variant="outlined" sx={{ height: "100%" }}>
|
||||||
|
<CardContent>
|
||||||
|
<Typography variant="h4" component="p" sx={{ fontWeight: 700 }}>
|
||||||
|
{value}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
{title}
|
||||||
|
</Typography>
|
||||||
|
{subtitle && (
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
{subtitle}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MetricsPanel({ metrics }: MetricsPanelProps): ReactNode {
|
||||||
|
return (
|
||||||
|
<Grid container spacing={3}>
|
||||||
|
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
|
||||||
|
<MetricCard title="Total Applications" value={metrics.total_applications} />
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
|
||||||
|
<MetricCard title="Interviews" value={metrics.interviews_count} />
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
|
||||||
|
<MetricCard title="Offers" value={metrics.offers_count} />
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
|
||||||
|
<MetricCard
|
||||||
|
title="Rejection Rate"
|
||||||
|
value={`${metrics.rejection_rate}%`}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import Card from "@mui/material/Card";
|
||||||
|
import CardContent from "@mui/material/CardContent";
|
||||||
|
import Typography from "@mui/material/Typography";
|
||||||
|
import Chip from "@mui/material/Chip";
|
||||||
|
import Box from "@mui/material/Box";
|
||||||
|
import type { JobUpdate } from "../types/index.ts";
|
||||||
|
|
||||||
|
interface UpdateCardProps {
|
||||||
|
update: JobUpdate;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dateString: string): string {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
return date.toLocaleDateString("en-US", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatStatus(status: string): string {
|
||||||
|
return status.charAt(0) + status.slice(1).toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusColors: Record<string, "default" | "success" | "info" | "warning" | "error"> = {
|
||||||
|
APPLIED: "default",
|
||||||
|
SCREENING: "info",
|
||||||
|
INTERVIEW: "info",
|
||||||
|
OFFER: "success",
|
||||||
|
REJECTED: "error",
|
||||||
|
WITHDRAWN: "default",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function UpdateCard({ update }: UpdateCardProps): ReactNode {
|
||||||
|
const changeLabel = update.from_status
|
||||||
|
? `${formatStatus(update.from_status)} → ${formatStatus(update.to_status)}`
|
||||||
|
: formatStatus(update.to_status);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card variant="outlined" sx={{ height: "100%" }}>
|
||||||
|
<CardContent>
|
||||||
|
<Typography variant="h6" component="h2" gutterBottom sx={{ fontWeight: 600 }}>
|
||||||
|
{update.company_name}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary" gutterBottom>
|
||||||
|
{update.position_title}
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{ mt: 1, mb: 1 }}>
|
||||||
|
<Chip
|
||||||
|
label={changeLabel}
|
||||||
|
size="small"
|
||||||
|
color={statusColors[update.to_status] ?? "default"}
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<Typography variant="caption" color="text.secondary" display="block">
|
||||||
|
Updated: {formatDate(update.created_at)}
|
||||||
|
</Typography>
|
||||||
|
{update.notes && (
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
|
||||||
|
{update.notes}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
{Object.keys(update.metrics).length > 0 && (
|
||||||
|
<Typography variant="caption" color="text.secondary" display="block" sx={{ mt: 1 }}>
|
||||||
|
Metrics: {JSON.stringify(update.metrics)}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,12 +3,18 @@ import {
|
|||||||
useContext,
|
useContext,
|
||||||
useReducer,
|
useReducer,
|
||||||
useCallback,
|
useCallback,
|
||||||
|
useEffect,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
type Dispatch,
|
type Dispatch,
|
||||||
} from "react";
|
} from "react";
|
||||||
import {
|
import {
|
||||||
loginUser,
|
loginUser,
|
||||||
registerUser,
|
registerUser,
|
||||||
|
getProfile,
|
||||||
|
logoutUser,
|
||||||
|
setTokens,
|
||||||
|
clearTokens,
|
||||||
|
refreshAccessToken,
|
||||||
type UserProfile,
|
type UserProfile,
|
||||||
type LoginPayload,
|
type LoginPayload,
|
||||||
type RegisterPayload,
|
type RegisterPayload,
|
||||||
@@ -19,6 +25,7 @@ interface AuthState {
|
|||||||
user: UserProfile | null;
|
user: UserProfile | null;
|
||||||
isAuthenticated: boolean;
|
isAuthenticated: boolean;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
|
isInitializing: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,6 +33,7 @@ const initialState: AuthState = {
|
|||||||
user: null,
|
user: null,
|
||||||
isAuthenticated: false,
|
isAuthenticated: false,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
|
isInitializing: true,
|
||||||
error: null,
|
error: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -35,6 +43,7 @@ type AuthAction =
|
|||||||
| { type: "AUTH_SUCCESS"; payload: UserProfile }
|
| { type: "AUTH_SUCCESS"; payload: UserProfile }
|
||||||
| { type: "AUTH_FAILURE"; payload: string }
|
| { type: "AUTH_FAILURE"; payload: string }
|
||||||
| { type: "LOGOUT" }
|
| { type: "LOGOUT" }
|
||||||
|
| { type: "INIT_COMPLETE" }
|
||||||
| { type: "CLEAR_ERROR" };
|
| { type: "CLEAR_ERROR" };
|
||||||
|
|
||||||
function authReducer(state: AuthState, action: AuthAction): AuthState {
|
function authReducer(state: AuthState, action: AuthAction): AuthState {
|
||||||
@@ -45,6 +54,7 @@ function authReducer(state: AuthState, action: AuthAction): AuthState {
|
|||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
|
isInitializing: false,
|
||||||
isAuthenticated: true,
|
isAuthenticated: true,
|
||||||
user: action.payload,
|
user: action.payload,
|
||||||
error: null,
|
error: null,
|
||||||
@@ -56,7 +66,9 @@ function authReducer(state: AuthState, action: AuthAction): AuthState {
|
|||||||
error: action.payload,
|
error: action.payload,
|
||||||
};
|
};
|
||||||
case "LOGOUT":
|
case "LOGOUT":
|
||||||
return { ...initialState };
|
return { ...initialState, isInitializing: false };
|
||||||
|
case "INIT_COMPLETE":
|
||||||
|
return { ...state, isInitializing: false };
|
||||||
case "CLEAR_ERROR":
|
case "CLEAR_ERROR":
|
||||||
return { ...state, error: null };
|
return { ...state, error: null };
|
||||||
default:
|
default:
|
||||||
@@ -70,7 +82,7 @@ interface AuthContextValue {
|
|||||||
dispatch: Dispatch<AuthAction>;
|
dispatch: Dispatch<AuthAction>;
|
||||||
login: (payload: LoginPayload) => Promise<void>;
|
login: (payload: LoginPayload) => Promise<void>;
|
||||||
register: (payload: RegisterPayload) => Promise<void>;
|
register: (payload: RegisterPayload) => Promise<void>;
|
||||||
logout: () => void;
|
logout: () => Promise<void>;
|
||||||
clearError: () => void;
|
clearError: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,12 +92,38 @@ const AuthContext = createContext<AuthContextValue | null>(null);
|
|||||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
const [state, dispatch] = useReducer(authReducer, initialState);
|
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) {
|
||||||
|
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();
|
||||||
|
dispatch({ type: "AUTH_SUCCESS", payload: profile });
|
||||||
|
} catch {
|
||||||
|
// Token invalid or expired — clear everything
|
||||||
|
clearTokens();
|
||||||
|
dispatch({ type: "LOGOUT" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
initAuth();
|
||||||
|
}, []);
|
||||||
|
|
||||||
const login = useCallback(async (payload: LoginPayload) => {
|
const login = useCallback(async (payload: LoginPayload) => {
|
||||||
dispatch({ type: "AUTH_START" });
|
dispatch({ type: "AUTH_START" });
|
||||||
try {
|
try {
|
||||||
const response = await loginUser(payload);
|
const response = await loginUser(payload);
|
||||||
localStorage.setItem("access_token", response.access);
|
setTokens(response.access, response.refresh);
|
||||||
localStorage.setItem("refresh_token", response.refresh);
|
|
||||||
dispatch({ type: "AUTH_SUCCESS", payload: response.user });
|
dispatch({ type: "AUTH_SUCCESS", payload: response.user });
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const message =
|
const message =
|
||||||
@@ -100,7 +138,6 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
try {
|
try {
|
||||||
await registerUser(payload);
|
await registerUser(payload);
|
||||||
// Registration succeeded — the page component handles redirect to /login.
|
// Registration succeeded — the page component handles redirect to /login.
|
||||||
// Set isLoading=false by clearing auth state (no auto-authentication).
|
|
||||||
dispatch({ type: "LOGOUT" });
|
dispatch({ type: "LOGOUT" });
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const message =
|
const message =
|
||||||
@@ -112,9 +149,16 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const logout = useCallback(() => {
|
const logout = useCallback(async () => {
|
||||||
localStorage.removeItem("access_token");
|
const refreshToken = localStorage.getItem("refresh_token");
|
||||||
localStorage.removeItem("refresh_token");
|
if (refreshToken) {
|
||||||
|
try {
|
||||||
|
await logoutUser(refreshToken);
|
||||||
|
} catch {
|
||||||
|
// Even if the server request fails, clear local state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
clearTokens();
|
||||||
dispatch({ type: "LOGOUT" });
|
dispatch({ type: "LOGOUT" });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import type { DashboardData, JobUpdate, DashboardMetrics } from "../types/index.ts";
|
||||||
|
|
||||||
|
interface UseDashboardDataResult {
|
||||||
|
data: DashboardData | null;
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
refetch: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const API_BASE = "/api";
|
||||||
|
|
||||||
|
async function fetchJson<T>(url: string): Promise<T> {
|
||||||
|
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) {
|
||||||
|
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>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDashboardData(): UseDashboardDataResult {
|
||||||
|
const [data, setData] = useState<DashboardData | null>(null);
|
||||||
|
const [loading, setLoading] = useState<boolean>(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const fetchData = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [updates, metrics] = await Promise.all([
|
||||||
|
fetchJson<JobUpdate[]>(`${API_BASE}/updates/latest/`),
|
||||||
|
fetchJson<DashboardMetrics>(`${API_BASE}/updates/metrics/`),
|
||||||
|
]);
|
||||||
|
setData({ updates, metrics });
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message =
|
||||||
|
err instanceof Error ? err.message : "An unknown error occurred";
|
||||||
|
setError(message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchData();
|
||||||
|
}, [fetchData]);
|
||||||
|
|
||||||
|
return { data, loading, error, refetch: fetchData };
|
||||||
|
}
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
import { StrictMode } from "react";
|
import { StrictMode } from "react";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import App from "./App";
|
import App from "./App.tsx";
|
||||||
|
|
||||||
const rootElement = document.getElementById("root");
|
const rootElement = document.getElementById("root");
|
||||||
if (!rootElement) {
|
if (!rootElement) {
|
||||||
|
|||||||
+134
-36
@@ -1,41 +1,139 @@
|
|||||||
import { Link } from "react-router-dom";
|
import { type ReactNode } from "react";
|
||||||
|
import { useNavigate, Link } from "react-router-dom";
|
||||||
import { useAuth } from "../contexts/AuthContext";
|
import { useAuth } from "../contexts/AuthContext";
|
||||||
import styles from "./HomePage.module.css";
|
import Box from "@mui/material/Box";
|
||||||
|
import Typography from "@mui/material/Typography";
|
||||||
export default function HomePage() {
|
import Button from "@mui/material/Button";
|
||||||
const { state, logout } = useAuth();
|
import Grid from "@mui/material/Grid";
|
||||||
|
import Alert from "@mui/material/Alert";
|
||||||
|
import AddIcon from "@mui/icons-material/Add";
|
||||||
|
import UpdateCard from "../components/UpdateCard";
|
||||||
|
import MetricsPanel from "../components/MetricsPanel";
|
||||||
|
import LoadingSkeleton from "../components/LoadingSkeleton";
|
||||||
|
import { useDashboardData } from "../hooks/useDashboardData";
|
||||||
|
|
||||||
|
function LandingView(): ReactNode {
|
||||||
return (
|
return (
|
||||||
<div className={styles.container}>
|
<Box
|
||||||
<div className={styles.card}>
|
sx={{
|
||||||
<h1 className={styles.heading}>Job Tracker</h1>
|
minHeight: "60vh",
|
||||||
<p className={styles.welcomeText}>
|
display: "flex",
|
||||||
Welcome to the Job Tracker application.
|
flexDirection: "column",
|
||||||
</p>
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
{state.isAuthenticated && state.user ? (
|
textAlign: "center",
|
||||||
<div>
|
}}
|
||||||
<p className={styles.signedInText}>
|
>
|
||||||
Signed in as{" "}
|
<Typography variant="h3" component="h1" gutterBottom sx={{ fontWeight: 700 }}>
|
||||||
<strong className={styles.emailStrong}>
|
Job Tracker
|
||||||
{state.user.email}
|
</Typography>
|
||||||
</strong>
|
<Typography variant="h6" color="text.secondary" sx={{ mb: 4, maxWidth: 500 }}>
|
||||||
</p>
|
Track your job applications, interviews, and offers all in one place.
|
||||||
<button onClick={logout} className={styles.logoutButton}>
|
</Typography>
|
||||||
Sign Out
|
<Box sx={{ display: "flex", gap: 2 }}>
|
||||||
</button>
|
<Button
|
||||||
</div>
|
variant="contained"
|
||||||
) : (
|
size="large"
|
||||||
<div className={styles.authLinks}>
|
component={Link}
|
||||||
<Link to="/login" className={styles.signInLink}>
|
to="/login"
|
||||||
Sign In
|
>
|
||||||
</Link>
|
Sign In
|
||||||
<Link to="/register" className={styles.registerLink}>
|
</Button>
|
||||||
Register
|
<Button
|
||||||
</Link>
|
variant="outlined"
|
||||||
</div>
|
size="large"
|
||||||
)}
|
component={Link}
|
||||||
</div>
|
to="/register"
|
||||||
</div>
|
>
|
||||||
|
Register
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function DashboardView(): ReactNode {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { data, loading, error } = useDashboardData();
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <LoadingSkeleton />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Typography
|
||||||
|
variant="h4"
|
||||||
|
component="h2"
|
||||||
|
gutterBottom
|
||||||
|
sx={{ fontWeight: 600 }}
|
||||||
|
>
|
||||||
|
Dashboard
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert severity="error" sx={{ mb: 3 }}>
|
||||||
|
Failed to load dashboard data: {error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{data && (
|
||||||
|
<>
|
||||||
|
<Box sx={{ mb: 4 }}>
|
||||||
|
<MetricsPanel metrics={data.metrics} />
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
sx={{ mb: 3, display: "flex", justifyContent: "flex-end" }}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
size="large"
|
||||||
|
startIcon={<AddIcon />}
|
||||||
|
onClick={() => navigate("/applications/new")}
|
||||||
|
>
|
||||||
|
Create New Job Application
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Typography
|
||||||
|
variant="h5"
|
||||||
|
component="h3"
|
||||||
|
gutterBottom
|
||||||
|
sx={{ fontWeight: 600 }}
|
||||||
|
>
|
||||||
|
Recent Updates
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Grid container spacing={3}>
|
||||||
|
{data.updates.map((update) => (
|
||||||
|
<Grid key={update.id} size={{ xs: 12, sm: 6, md: 4 }}>
|
||||||
|
<UpdateCard update={update} />
|
||||||
|
</Grid>
|
||||||
|
))}
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{data.updates.length === 0 && (
|
||||||
|
<Typography
|
||||||
|
variant="body1"
|
||||||
|
color="text.secondary"
|
||||||
|
sx={{ mt: 2 }}
|
||||||
|
>
|
||||||
|
No updates yet. Create your first job application to get started.
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function HomePage(): ReactNode {
|
||||||
|
const { state } = useAuth();
|
||||||
|
|
||||||
|
if (!state.isAuthenticated) {
|
||||||
|
return <LandingView />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <DashboardView />;
|
||||||
|
}
|
||||||
|
|||||||
+126
-1
@@ -16,6 +16,97 @@ apiClient.interceptors.request.use((config) => {
|
|||||||
return config;
|
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) => response,
|
||||||
|
async (error) => {
|
||||||
|
const originalRequest = error.config;
|
||||||
|
|
||||||
|
// Only handle 401s that aren't already refresh/login/register 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 = localStorage.getItem("refresh_token");
|
||||||
|
|
||||||
|
if (!refreshToken) {
|
||||||
|
isRefreshing = false;
|
||||||
|
localStorage.removeItem("access_token");
|
||||||
|
localStorage.removeItem("refresh_token");
|
||||||
|
// Redirect to login
|
||||||
|
window.location.href = "/login";
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios.post(
|
||||||
|
`${
|
||||||
|
import.meta.env.VITE_API_URL || "http://localhost:8000"
|
||||||
|
}/api/auth/token/refresh/`,
|
||||||
|
{ refresh: refreshToken }
|
||||||
|
);
|
||||||
|
|
||||||
|
const newAccessToken = response.data.access;
|
||||||
|
const newRefreshToken = response.data.refresh;
|
||||||
|
|
||||||
|
localStorage.setItem("access_token", newAccessToken);
|
||||||
|
localStorage.setItem("refresh_token", newRefreshToken);
|
||||||
|
|
||||||
|
processQueue(null, newAccessToken);
|
||||||
|
|
||||||
|
originalRequest.headers.Authorization = `Bearer ${newAccessToken}`;
|
||||||
|
return apiClient(originalRequest);
|
||||||
|
} catch (refreshError) {
|
||||||
|
processQueue(refreshError, null);
|
||||||
|
localStorage.removeItem("access_token");
|
||||||
|
localStorage.removeItem("refresh_token");
|
||||||
|
window.location.href = "/login";
|
||||||
|
return Promise.reject(refreshError);
|
||||||
|
} finally {
|
||||||
|
isRefreshing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Types ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface RegisterPayload {
|
export interface RegisterPayload {
|
||||||
email: string;
|
email: string;
|
||||||
password: string;
|
password: string;
|
||||||
@@ -31,7 +122,6 @@ export interface LoginPayload {
|
|||||||
|
|
||||||
export interface UserProfile {
|
export interface UserProfile {
|
||||||
id: number;
|
id: number;
|
||||||
email: string;
|
|
||||||
first_name: string;
|
first_name: string;
|
||||||
last_name: string;
|
last_name: string;
|
||||||
}
|
}
|
||||||
@@ -42,6 +132,8 @@ export interface LoginResponse {
|
|||||||
refresh: string;
|
refresh: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── API Functions ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function registerUser(payload: RegisterPayload): Promise<UserProfile> {
|
export function registerUser(payload: RegisterPayload): Promise<UserProfile> {
|
||||||
return apiClient
|
return apiClient
|
||||||
.post<UserProfile>("/api/auth/register/", payload)
|
.post<UserProfile>("/api/auth/register/", payload)
|
||||||
@@ -53,3 +145,36 @@ export function loginUser(payload: LoginPayload): Promise<LoginResponse> {
|
|||||||
.post<LoginResponse>("/api/auth/login/", payload)
|
.post<LoginResponse>("/api/auth/login/", payload)
|
||||||
.then((res) => res.data);
|
.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 function setTokens(access: string, refresh: string): void {
|
||||||
|
localStorage.setItem("access_token", access);
|
||||||
|
localStorage.setItem("refresh_token", refresh);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearTokens(): void {
|
||||||
|
localStorage.removeItem("access_token");
|
||||||
|
localStorage.removeItem("refresh_token");
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
export interface JobUpdate {
|
||||||
|
id: number;
|
||||||
|
job_id: number;
|
||||||
|
company_name: string;
|
||||||
|
position_title: string;
|
||||||
|
from_status: string | null;
|
||||||
|
to_status: string;
|
||||||
|
notes: string;
|
||||||
|
created_at: string;
|
||||||
|
metrics: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardMetrics {
|
||||||
|
total_applications: number;
|
||||||
|
status_breakdown: Record<string, number>;
|
||||||
|
interviews_count: number;
|
||||||
|
offers_count: number;
|
||||||
|
rejection_rate: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardData {
|
||||||
|
updates: JobUpdate[];
|
||||||
|
metrics: DashboardMetrics;
|
||||||
|
}
|
||||||
Vendored
+8
@@ -1 +1,9 @@
|
|||||||
/// <reference types="vite/client" />
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
interface ImportMetaEnv {
|
||||||
|
readonly VITE_API_URL: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportMeta {
|
||||||
|
readonly env: ImportMetaEnv;
|
||||||
|
}
|
||||||
|
|||||||
+2
-4
@@ -1,6 +1,5 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
|
||||||
"target": "ES2020",
|
"target": "ES2020",
|
||||||
"useDefineForClassFields": true,
|
"useDefineForClassFields": true,
|
||||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
@@ -13,10 +12,9 @@
|
|||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx",
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"noUnusedLocals": false,
|
"noUnusedLocals": true,
|
||||||
"noUnusedParameters": false,
|
"noUnusedParameters": true,
|
||||||
"noFallthroughCasesInSwitch": true,
|
"noFallthroughCasesInSwitch": true,
|
||||||
"noUncheckedSideEffectImports": true,
|
|
||||||
"forceConsistentCasingInFileNames": true
|
"forceConsistentCasingInFileNames": true
|
||||||
},
|
},
|
||||||
"include": ["src"]
|
"include": ["src"]
|
||||||
|
|||||||
@@ -9,5 +9,11 @@ export default defineConfig({
|
|||||||
watch: {
|
watch: {
|
||||||
usePolling: true,
|
usePolling: true,
|
||||||
},
|
},
|
||||||
|
proxy: {
|
||||||
|
"/api": {
|
||||||
|
target: "http://localhost:8000",
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
Reference in New Issue
Block a user