diff --git a/backend/api/serializers.py b/backend/api/serializers.py index 7d8b0f5..851e178 100644 --- a/backend/api/serializers.py +++ b/backend/api/serializers.py @@ -1,8 +1,42 @@ # DRF serializers for the backend API will be added here. +from django.contrib.auth.models import User +from django.contrib.auth.password_validation import validate_password +from django.core.exceptions import ValidationError as DjangoValidationError from rest_framework import serializers +from rest_framework_simplejwt.serializers import TokenObtainPairSerializer from .models import Template, CheatSheet, PracticeProblem +class CustomTokenObtainPairSerializer(TokenObtainPairSerializer): + @classmethod + def get_token(cls, user): + token = super().get_token(user) + # Add custom claims + token['username'] = user.username + return token + + +class UserSerializer(serializers.ModelSerializer): + class Meta: + model = User + fields = ('id', 'username', 'password') + extra_kwargs = {'password': {'write_only': True}} + + def validate_password(self, value): + try: + validate_password(value) + except DjangoValidationError as e: + raise serializers.ValidationError(list(e.messages)) + return value + + def create(self, validated_data): + user = User.objects.create_user( + username=validated_data['username'], + password=validated_data['password'] + ) + return user + + class TemplateSerializer(serializers.ModelSerializer): class Meta: model = Template diff --git a/backend/api/tests.py b/backend/api/tests.py index ecaad92..c6ecda3 100644 --- a/backend/api/tests.py +++ b/backend/api/tests.py @@ -4,6 +4,7 @@ """ import pytest +from django.contrib.auth.models import User from django.test import TestCase from rest_framework.test import APIClient from api.models import Template, CheatSheet, PracticeProblem @@ -14,6 +15,15 @@ def api_client(): return APIClient() +@pytest.fixture +def auth_client(db): + """Authenticated API client (bypasses JWT for speed).""" + client = APIClient() + user = User.objects.create_user(username="testuser", password="testpass123") + client.force_authenticate(user=user) + return client + + @pytest.fixture def sample_template(db): return Template.objects.create( @@ -170,6 +180,86 @@ def test_build_full_latex_8pt_uses_extarticle(self): # ── API Tests ──────────────────────────────────────────────────────── +@pytest.mark.django_db +def test_register_success(api_client): + payload = { + "username": "newuser", + "password": "StrongPass123!", + } + + response = api_client.post("/api/register/", payload, format="json") + + assert response.status_code in (200, 201) + assert User.objects.filter(username="newuser").exists() + + +@pytest.mark.django_db +def test_token_obtain_success_returns_access_and_refresh(api_client): + User.objects.create_user(username="tokenuser", password="testpass123") + + response = api_client.post( + "/api/token/", + {"username": "tokenuser", "password": "testpass123"}, + format="json", + ) + + assert response.status_code == 200 + assert "access" in response.data + assert "refresh" in response.data + assert response.data["access"] + assert response.data["refresh"] + + +@pytest.mark.django_db +def test_token_refresh_success_returns_new_access_token(api_client): + User.objects.create_user(username="refreshuser", password="testpass123") + token_response = api_client.post( + "/api/token/", + {"username": "refreshuser", "password": "testpass123"}, + format="json", + ) + + assert token_response.status_code == 200 + assert "refresh" in token_response.data + + refresh_response = api_client.post( + "/api/token/refresh/", + {"refresh": token_response.data["refresh"]}, + format="json", + ) + + assert refresh_response.status_code == 200 + assert "access" in refresh_response.data + assert refresh_response.data["access"] + + +@pytest.mark.django_db +def test_token_obtain_invalid_credentials_fail(api_client): + User.objects.create_user(username="badloginuser", password="rightpass123") + + response = api_client.post( + "/api/token/", + {"username": "badloginuser", "password": "wrongpass123"}, + format="json", + ) + + assert response.status_code in (400, 401) + assert "access" not in response.data + assert "refresh" not in response.data + + +@pytest.mark.django_db +def test_token_refresh_invalid_token_fails(api_client): + response = api_client.post( + "/api/token/refresh/", + {"refresh": "invalid.refresh.token"}, + format="json", + ) + + assert response.status_code in (400, 401) + assert "access" not in response.data + + @pytest.mark.django_db class TestHealthEndpoint: def test_health_returns_ok(self, api_client): @@ -180,21 +270,21 @@ def test_health_returns_ok(self, api_client): @pytest.mark.django_db class TestTemplateAPI: - def test_list_templates(self, api_client, sample_template): - resp = api_client.get("/api/templates/") + def test_list_templates(self, auth_client, sample_template): + resp = auth_client.get("/api/templates/") assert resp.status_code == 200 data = resp.json() assert len(data) >= 1 assert data[0]["name"] == "Test Algebra" - def test_filter_templates_by_subject(self, api_client, sample_template): - resp = api_client.get("/api/templates/?subject=algebra") + def test_filter_templates_by_subject(self, auth_client, sample_template): + resp = auth_client.get("/api/templates/?subject=algebra") assert resp.status_code == 200 data = resp.json() assert len(data) >= 1 - def test_create_template(self, api_client): - resp = api_client.post( + def test_create_template(self, auth_client): + resp = auth_client.post( "/api/templates/", { "name": "New Template", @@ -208,12 +298,12 @@ def test_create_template(self, api_client): @pytest.mark.django_db class TestCheatSheetAPI: - def test_list_cheatsheets(self, api_client, sample_sheet): - resp = api_client.get("/api/cheatsheets/") + def test_list_cheatsheets(self, auth_client, sample_sheet): + resp = auth_client.get("/api/cheatsheets/") assert resp.status_code == 200 - def test_create_cheatsheet(self, api_client): - resp = api_client.post( + def test_create_cheatsheet(self, auth_client): + resp = auth_client.post( "/api/cheatsheets/", { "title": "Brand New Sheet", @@ -228,14 +318,14 @@ def test_create_cheatsheet(self, api_client): assert resp.json()["title"] == "Brand New Sheet" assert "full_latex" in resp.json() - def test_retrieve_cheatsheet_has_full_latex(self, api_client, sample_sheet): - resp = api_client.get(f"/api/cheatsheets/{sample_sheet.id}/") + def test_retrieve_cheatsheet_has_full_latex(self, auth_client, sample_sheet): + resp = auth_client.get(f"/api/cheatsheets/{sample_sheet.id}/") assert resp.status_code == 200 data = resp.json() assert "\\begin{document}" in data["full_latex"] - def test_update_cheatsheet(self, api_client, sample_sheet): - resp = api_client.patch( + def test_update_cheatsheet(self, auth_client, sample_sheet): + resp = auth_client.patch( f"/api/cheatsheets/{sample_sheet.id}/", {"margins": "0.25in", "columns": 3}, format="json", @@ -244,15 +334,15 @@ def test_update_cheatsheet(self, api_client, sample_sheet): assert resp.json()["margins"] == "0.25in" assert resp.json()["columns"] == 3 - def test_delete_cheatsheet(self, api_client, sample_sheet): - resp = api_client.delete(f"/api/cheatsheets/{sample_sheet.id}/") + def test_delete_cheatsheet(self, auth_client, sample_sheet): + resp = auth_client.delete(f"/api/cheatsheets/{sample_sheet.id}/") assert resp.status_code == 204 @pytest.mark.django_db class TestCreateFromTemplate: - def test_create_from_template(self, api_client, sample_template): - resp = api_client.post( + def test_create_from_template(self, auth_client, sample_template): + resp = auth_client.post( "/api/cheatsheets/from-template/", {"template_id": sample_template.id, "title": "My Copy"}, format="json", @@ -263,8 +353,8 @@ def test_create_from_template(self, api_client, sample_template): assert data["template"] == sample_template.id assert data["columns"] == sample_template.default_columns - def test_create_from_template_missing_id(self, api_client): - resp = api_client.post( + def test_create_from_template_missing_id(self, auth_client): + resp = auth_client.post( "/api/cheatsheets/from-template/", {"title": "Oops"}, format="json", @@ -274,8 +364,8 @@ def test_create_from_template_missing_id(self, api_client): @pytest.mark.django_db class TestPracticeProblemAPI: - def test_create_problem(self, api_client, sample_sheet): - resp = api_client.post( + def test_create_problem(self, auth_client, sample_sheet): + resp = auth_client.post( "/api/problems/", { "cheat_sheet": sample_sheet.id, @@ -287,26 +377,26 @@ def test_create_problem(self, api_client, sample_sheet): ) assert resp.status_code == 201 - def test_filter_problems_by_sheet(self, api_client, sample_problem, sample_sheet): - resp = api_client.get(f"/api/problems/?cheat_sheet={sample_sheet.id}") + def test_filter_problems_by_sheet(self, auth_client, sample_problem, sample_sheet): + resp = auth_client.get(f"/api/problems/?cheat_sheet={sample_sheet.id}") assert resp.status_code == 200 assert len(resp.json()) >= 1 @pytest.mark.django_db class TestGenerateSheetEndpoint: - def test_generate_sheet_no_formulas(self, api_client): - resp = api_client.post("/api/generate-sheet/", {"formulas": []}, format="json") + def test_generate_sheet_no_formulas(self, auth_client): + resp = auth_client.post("/api/generate-sheet/", {"formulas": []}, format="json") assert resp.status_code == 200 assert "tex_code" in resp.json() - def test_generate_sheet_missing_formulas_key(self, api_client): - resp = api_client.post("/api/generate-sheet/", {}, format="json") + def test_generate_sheet_missing_formulas_key(self, auth_client): + resp = auth_client.post("/api/generate-sheet/", {}, format="json") assert resp.status_code == 200 assert "tex_code" in resp.json() - def test_generate_sheet_valid_formula(self, api_client): - resp = api_client.post( + def test_generate_sheet_valid_formula(self, auth_client): + resp = auth_client.post( "/api/generate-sheet/", { "formulas": [ @@ -321,9 +411,9 @@ def test_generate_sheet_valid_formula(self, api_client): assert "\\section*{ALGEBRA I}" in data["tex_code"] assert "Slope Formula" in data["tex_code"] - def test_generate_sheet_preserves_order(self, api_client): + def test_generate_sheet_preserves_order(self, auth_client): """Selected formula order must be preserved in the LaTeX output.""" - resp = api_client.post( + resp = auth_client.post( "/api/generate-sheet/", { "formulas": [ @@ -340,9 +430,9 @@ def test_generate_sheet_preserves_order(self, api_client): assert slope_pos != -1 and intercept_pos != -1 assert slope_pos < intercept_pos - def test_generate_sheet_special_class_unit_circle(self, api_client): + def test_generate_sheet_special_class_unit_circle(self, auth_client): """Special class (UNIT CIRCLE) with no categories should generate valid LaTeX.""" - resp = api_client.post( + resp = auth_client.post( "/api/generate-sheet/", { "formulas": [ @@ -359,9 +449,9 @@ def test_generate_sheet_special_class_unit_circle(self, api_client): assert "\\begin{document}" in tex assert "\\end{document}" in tex - def test_generate_sheet_invalid_formula_returns_400(self, api_client): + def test_generate_sheet_invalid_formula_returns_400(self, auth_client): """Requesting a formula that does not exist should return 400.""" - resp = api_client.post( + resp = auth_client.post( "/api/generate-sheet/", { "formulas": [ @@ -372,9 +462,9 @@ def test_generate_sheet_invalid_formula_returns_400(self, api_client): ) assert resp.status_code == 400 - def test_generate_sheet_with_columns(self, api_client): + def test_generate_sheet_with_columns(self, auth_client): """Test that columns parameter produces multicols environment.""" - resp = api_client.post( + resp = auth_client.post( "/api/generate-sheet/", { "formulas": [{"class": "ALGEBRA I", "category": "Linear Equations", "name": "Slope Formula"}], @@ -387,9 +477,9 @@ def test_generate_sheet_with_columns(self, api_client): assert "\\begin{multicols}{3}" in tex assert "\\end{multicols}" in tex - def test_generate_sheet_with_font_size(self, api_client): + def test_generate_sheet_with_font_size(self, auth_client): """Test that font_size parameter affects the LaTeX output.""" - resp = api_client.post( + resp = auth_client.post( "/api/generate-sheet/", { "formulas": [{"class": "ALGEBRA I", "category": "Linear Equations", "name": "Slope Formula"}], @@ -401,9 +491,9 @@ def test_generate_sheet_with_font_size(self, api_client): tex = resp.json()["tex_code"] assert "\\tiny" in tex - def test_generate_sheet_with_margins(self, api_client): + def test_generate_sheet_with_margins(self, auth_client): """Test that margins parameter is reflected in geometry package.""" - resp = api_client.post( + resp = auth_client.post( "/api/generate-sheet/", { "formulas": [{"class": "ALGEBRA I", "category": "Linear Equations", "name": "Slope Formula"}], @@ -415,9 +505,9 @@ def test_generate_sheet_with_margins(self, api_client): tex = resp.json()["tex_code"] assert "margin=0.5in" in tex - def test_generate_sheet_with_spacing(self, api_client): + def test_generate_sheet_with_spacing(self, auth_client): """Test that spacing parameter affects titlesec spacing.""" - resp = api_client.post( + resp = auth_client.post( "/api/generate-sheet/", { "formulas": [{"class": "ALGEBRA I", "category": "Linear Equations", "name": "Slope Formula"}], @@ -429,9 +519,9 @@ def test_generate_sheet_with_spacing(self, api_client): tex = resp.json()["tex_code"] assert "titlespacing" in tex - def test_generate_sheet_invalid_font_size_defaults(self, api_client): + def test_generate_sheet_invalid_font_size_defaults(self, auth_client): """Invalid font_size should be replaced with default.""" - resp = api_client.post( + resp = auth_client.post( "/api/generate-sheet/", { "formulas": [{"class": "ALGEBRA I", "category": "Linear Equations", "name": "Slope Formula"}], @@ -443,9 +533,9 @@ def test_generate_sheet_invalid_font_size_defaults(self, api_client): tex = resp.json()["tex_code"] assert "\\documentclass[10pt" in tex - def test_generate_sheet_invalid_margins_defaults(self, api_client): + def test_generate_sheet_invalid_margins_defaults(self, auth_client): """Invalid margins should be replaced with default.""" - resp = api_client.post( + resp = auth_client.post( "/api/generate-sheet/", { "formulas": [{"class": "ALGEBRA I", "category": "Linear Equations", "name": "Slope Formula"}], @@ -457,9 +547,9 @@ def test_generate_sheet_invalid_margins_defaults(self, api_client): tex = resp.json()["tex_code"] assert "margin=0.25in" in tex - def test_generate_sheet_invalid_spacing_defaults(self, api_client): + def test_generate_sheet_invalid_spacing_defaults(self, auth_client): """Invalid spacing should be replaced with default (large preset).""" - resp = api_client.post( + resp = auth_client.post( "/api/generate-sheet/", { "formulas": [{"class": "ALGEBRA I", "category": "Linear Equations", "name": "Slope Formula"}], @@ -473,9 +563,9 @@ def test_generate_sheet_invalid_spacing_defaults(self, api_client): assert "\\titlespacing*{\\section}{0pt}{16pt}{8pt}" in tex assert "\\titlespacing*{\\subsection}{0pt}{8pt}{4pt}" in tex - def test_generate_sheet_8pt_uses_extarticle(self, api_client): + def test_generate_sheet_8pt_uses_extarticle(self, auth_client): """8pt font size should use extarticle, not article.""" - resp = api_client.post( + resp = auth_client.post( "/api/generate-sheet/", { "formulas": [{"class": "ALGEBRA I", "category": "Linear Equations", "name": "Slope Formula"}], @@ -488,9 +578,9 @@ def test_generate_sheet_8pt_uses_extarticle(self, api_client): assert "\\documentclass[8pt,fleqn]{extarticle}" in tex assert "\\documentclass[8pt,fleqn]{article}" not in tex - def test_generate_sheet_9pt_uses_extarticle(self, api_client): + def test_generate_sheet_9pt_uses_extarticle(self, auth_client): """9pt font size should use extarticle, not article.""" - resp = api_client.post( + resp = auth_client.post( "/api/generate-sheet/", { "formulas": [{"class": "ALGEBRA I", "category": "Linear Equations", "name": "Slope Formula"}], @@ -503,9 +593,9 @@ def test_generate_sheet_9pt_uses_extarticle(self, api_client): assert "\\documentclass[9pt,fleqn]{extarticle}" in tex assert "\\documentclass[9pt,fleqn]{article}" not in tex - def test_generate_sheet_10pt_uses_article(self, api_client): + def test_generate_sheet_10pt_uses_article(self, auth_client): """10pt font size should use standard article class.""" - resp = api_client.post( + resp = auth_client.post( "/api/generate-sheet/", { "formulas": [{"class": "ALGEBRA I", "category": "Linear Equations", "name": "Slope Formula"}], @@ -518,9 +608,9 @@ def test_generate_sheet_10pt_uses_article(self, api_client): assert "\\documentclass[10pt,fleqn]{article}" in tex assert "extarticle" not in tex - def test_generate_sheet_latex_injection_blocked(self, api_client): + def test_generate_sheet_latex_injection_blocked(self, auth_client): """LaTeX injection attempts in parameters should be sanitized.""" - resp = api_client.post( + resp = auth_client.post( "/api/generate-sheet/", { "formulas": [{"class": "ALGEBRA I", "category": "Linear Equations", "name": "Slope Formula"}], @@ -537,14 +627,105 @@ def test_generate_sheet_latex_injection_blocked(self, api_client): @pytest.mark.django_db class TestCompileEndpoint: - def test_compile_requires_content_or_id(self, api_client): - resp = api_client.post("/api/compile/", {}, format="json") + def test_compile_requires_content_or_id(self, auth_client): + resp = auth_client.post("/api/compile/", {}, format="json") assert resp.status_code == 400 - def test_compile_with_nonexistent_sheet(self, api_client): - resp = api_client.post( + def test_compile_with_nonexistent_sheet(self, auth_client): + resp = auth_client.post( "/api/compile/", {"cheat_sheet_id": 99999}, format="json", ) assert resp.status_code == 404 + + +# ── Auth Endpoint Tests ────────────────────────────────────────────── + + +@pytest.mark.django_db +class TestRegisterEndpoint: + def test_register_success(self, api_client): + resp = api_client.post( + "/api/register/", + {"username": "newuser", "password": "Str0ng!Pass99"}, + format="json", + ) + assert resp.status_code == 201 + data = resp.json() + assert data["username"] == "newuser" + assert "password" not in data + + def test_register_duplicate_username(self, api_client, db): + User.objects.create_user(username="existing", password="pass1234!") + resp = api_client.post( + "/api/register/", + {"username": "existing", "password": "Str0ng!Pass99"}, + format="json", + ) + assert resp.status_code == 400 + + def test_register_weak_password(self, api_client): + resp = api_client.post( + "/api/register/", + {"username": "weakuser", "password": "123"}, + format="json", + ) + assert resp.status_code == 400 + assert "password" in resp.json() + + def test_register_common_password(self, api_client): + resp = api_client.post( + "/api/register/", + {"username": "commonuser", "password": "password"}, + format="json", + ) + assert resp.status_code == 400 + + +@pytest.mark.django_db +class TestTokenEndpoints: + def test_token_obtain_success(self, api_client, db): + User.objects.create_user(username="jwtuser", password="Str0ng!Pass99") + resp = api_client.post( + "/api/token/", + {"username": "jwtuser", "password": "Str0ng!Pass99"}, + format="json", + ) + assert resp.status_code == 200 + data = resp.json() + assert "access" in data + assert "refresh" in data + + def test_token_obtain_invalid_credentials(self, api_client, db): + User.objects.create_user(username="jwtuser2", password="Str0ng!Pass99") + resp = api_client.post( + "/api/token/", + {"username": "jwtuser2", "password": "wrongpassword"}, + format="json", + ) + assert resp.status_code == 401 + + def test_token_refresh_success(self, api_client, db): + User.objects.create_user(username="refreshuser", password="Str0ng!Pass99") + token_resp = api_client.post( + "/api/token/", + {"username": "refreshuser", "password": "Str0ng!Pass99"}, + format="json", + ) + refresh_token = token_resp.json()["refresh"] + resp = api_client.post( + "/api/token/refresh/", + {"refresh": refresh_token}, + format="json", + ) + assert resp.status_code == 200 + assert "access" in resp.json() + + def test_token_refresh_invalid_token(self, api_client): + resp = api_client.post( + "/api/token/refresh/", + {"refresh": "not-a-valid-token"}, + format="json", + ) + assert resp.status_code == 401 diff --git a/backend/api/urls.py b/backend/api/urls.py index 748584e..77780a5 100644 --- a/backend/api/urls.py +++ b/backend/api/urls.py @@ -1,5 +1,6 @@ from django.urls import path, include from rest_framework.routers import DefaultRouter +from rest_framework_simplejwt.views import TokenRefreshView from . import views # Create a router and register our viewsets with it. @@ -9,11 +10,13 @@ router.register(r'problems', views.PracticeProblemViewSet, basename='problem') urlpatterns = [ + path('token/', views.CustomTokenObtainPairView.as_view(), name='token_obtain_pair'), + path('token/refresh/', TokenRefreshView.as_view(), name='token_refresh'), + path('register/', views.RegisterView.as_view(), name='register'), path("health/", views.health_check, name="health-check"), path("classes/", views.get_classes, name="get-classes"), path("generate-sheet/", views.generate_sheet, name="generate-sheet"), path("compile/", views.compile_latex, name="compile-latex"), - # Include the router URLs for CRUD operations path('', include(router.urls)), ] diff --git a/backend/api/views.py b/backend/api/views.py index 23bd3be..498736f 100644 --- a/backend/api/views.py +++ b/backend/api/views.py @@ -2,13 +2,17 @@ from rest_framework.response import Response from rest_framework import status, viewsets from django.http import FileResponse +from django.contrib.auth.models import User +from rest_framework.generics import CreateAPIView +from rest_framework.permissions import AllowAny from django.shortcuts import get_object_or_404 import subprocess import tempfile import os from .models import Template, CheatSheet, PracticeProblem -from .serializers import TemplateSerializer, CheatSheetSerializer, PracticeProblemSerializer +from .serializers import TemplateSerializer, CheatSheetSerializer, PracticeProblemSerializer, UserSerializer, CustomTokenObtainPairSerializer +from rest_framework_simplejwt.views import TokenObtainPairView from .formula_data import get_formula_data, get_classes_with_details, get_special_class_formula, is_special_class from .latex_utils import build_latex_for_formulas, LATEX_HEADER, LATEX_FOOTER @@ -41,6 +45,15 @@ def validate_layout_params(columns, font_size, margins, spacing): # API endpoints # ------------------------------------------------------------------ +class CustomTokenObtainPairView(TokenObtainPairView): + serializer_class = CustomTokenObtainPairSerializer + +class RegisterView(CreateAPIView): + queryset = User.objects.all() + permission_classes = (AllowAny,) + serializer_class = UserSerializer + + @api_view(["GET"]) def health_check(request): return Response({"status": "ok"}) diff --git a/backend/cheat_sheet/settings.py b/backend/cheat_sheet/settings.py index 2e90c30..99ec04c 100644 --- a/backend/cheat_sheet/settings.py +++ b/backend/cheat_sheet/settings.py @@ -44,6 +44,7 @@ "django.contrib.staticfiles", # Third-party "rest_framework", + "rest_framework_simplejwt", "corsheaders", # Local "api", @@ -115,6 +116,9 @@ # DRF REST_FRAMEWORK = { + "DEFAULT_AUTHENTICATION_CLASSES": ( + "rest_framework_simplejwt.authentication.JWTAuthentication", + ), "DEFAULT_PERMISSION_CLASSES": [ "rest_framework.permissions.AllowAny", ], diff --git a/backend/requirements.txt b/backend/requirements.txt index 2589325..e0dd8b6 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,5 +1,6 @@ django>=6.0,<7.0 djangorestframework>=3.15 +djangorestframework-simplejwt>=5.3.1 django-cors-headers>=4.4 python-dotenv>=1.0,<2.0 dj-database-url>=2.1 diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 6253c04..b3b0814 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -11,9 +11,11 @@ "@dnd-kit/core": "^6.1.0", "@dnd-kit/sortable": "^8.0.0", "@dnd-kit/utilities": "^3.2.2", + "jwt-decode": "^4.0.0", "react": "^18.3.1", "react-dom": "^18.3.1", - "react-pdf": "^10.4.1" + "react-pdf": "^10.4.1", + "react-router-dom": "^7.14.1" }, "devDependencies": { "@eslint/js": "^9.0.0", @@ -2197,6 +2199,19 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -3758,6 +3773,15 @@ "node": ">=4.0" } }, + "node_modules/jwt-decode": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz", + "integrity": "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -4334,6 +4358,44 @@ "node": ">=0.10.0" } }, + "node_modules/react-router": { + "version": "7.14.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.14.1.tgz", + "integrity": "sha512-5BCvFskyAAVumqhEKh/iPhLOIkfxcEUz8WqFIARCkMg8hZZzDYX9CtwxXA0e+qT8zAxmMC0x3Ckb9iMONwc5jg==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.14.1", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.14.1.tgz", + "integrity": "sha512-ZkrQuwwhGibjQLqH1eCdyiZyLWglPxzxdl5tgwgKEyCSGC76vmAjleGocRe3J/MLfzMUIKwaFJWpFVJhK3d2xA==", + "license": "MIT", + "dependencies": { + "react-router": "7.14.1" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -4531,6 +4593,12 @@ "semver": "bin/semver.js" } }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index f3ff1c7..8a0b9b6 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -14,9 +14,11 @@ "@dnd-kit/core": "^6.1.0", "@dnd-kit/sortable": "^8.0.0", "@dnd-kit/utilities": "^3.2.2", + "jwt-decode": "^4.0.0", "react": "^18.3.1", "react-dom": "^18.3.1", - "react-pdf": "^10.4.1" + "react-pdf": "^10.4.1", + "react-router-dom": "^7.14.1" }, "devDependencies": { "@eslint/js": "^9.0.0", diff --git a/frontend/src/App.css b/frontend/src/App.css index 96fd5b6..c7578cf 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -460,16 +460,24 @@ label { justify-content: center; gap: var(--space-xs); padding: 0.625rem 1.25rem; - border: 1px solid transparent; + border: 1px solid var(--border); border-radius: var(--radius-md); + background-color: var(--card-bg); + color: var(--text); cursor: pointer; font-weight: 500; font-size: 0.875rem; letter-spacing: 0.01em; + text-decoration: none; transition: all var(--transition-fast); box-shadow: var(--shadow-sm); } +.btn:hover { + background-color: var(--border-subtle); + border-color: var(--primary); +} + .btn:focus-visible { outline: none; box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.3); diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 6061728..5668ef0 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,4 +1,8 @@ -import { useState, useEffect } from 'react' +import { useState, useEffect, useContext } from 'react' +import { Routes, Route, Link, Navigate } from 'react-router-dom'; +import AuthContext from './context/AuthContext'; +import Login from './components/Login'; +import SignUp from './components/SignUp'; import './App.css' import CreateCheatSheet from './components/CreateCheatSheet'; @@ -12,6 +16,11 @@ const DEFAULT_SHEET = { selectedFormulas: [], }; +const PrivateRoute = ({ children }) => { + const { user } = useContext(AuthContext); + return user ? children : ; +}; + function App() { const normalizeTheme = (value) => { return value === 'dark' || value === 'light' ? value : 'dark'; @@ -39,6 +48,8 @@ function App() { localStorage.setItem('theme', theme); }, [theme]); + const { user, logoutUser } = useContext(AuthContext); + const toggleTheme = () => { setTheme(prev => prev === 'dark' ? 'light' : 'dark'); }; @@ -122,22 +133,45 @@ function App() {
-

Cheat Sheet Generator

+ +

Cheat Sheet Generator

+

Write cheat sheets with LaTeX support

- +
+ {user ? ( + <> + Hi, {user.username || 'User'} + + + ) : ( + <> + Log In + Sign Up + + )} + +
- {}} - /> + + + {}} + /> + + } /> + } /> + } /> +