diff --git a/.gitignore b/.gitignore index 122ac54..64f0fd9 100644 --- a/.gitignore +++ b/.gitignore @@ -33,6 +33,7 @@ frontend/dist/ # OS .DS_Store +**/.DS_Store Thumbs.db # Agent/Session files @@ -65,4 +66,11 @@ backend/coverage/ # OpenCode/Sisyphus .sisyphus/ +.opencode/ + +# Playwright +playwright-report/ +test-results/ +blob-report/ + diff --git a/backend/api/migrations/0003_cheatsheet_user.py b/backend/api/migrations/0003_cheatsheet_user.py new file mode 100644 index 0000000..ae5726d --- /dev/null +++ b/backend/api/migrations/0003_cheatsheet_user.py @@ -0,0 +1,21 @@ +# Generated by Django 6.0.2 on 2026-04-21 19:29 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0002_cheatsheet_selected_formulas'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name='cheatsheet', + name='user', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='cheat_sheets', to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/backend/api/migrations/0004_cheatsheet_user_nonnull.py b/backend/api/migrations/0004_cheatsheet_user_nonnull.py new file mode 100644 index 0000000..69e8f2d --- /dev/null +++ b/backend/api/migrations/0004_cheatsheet_user_nonnull.py @@ -0,0 +1,21 @@ +# Generated by Django 6.0.4 on 2026-04-22 17:58 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0003_cheatsheet_user'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AlterField( + model_name='cheatsheet', + name='user', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='cheat_sheets', to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/backend/api/models.py b/backend/api/models.py index 500dcf2..42c1e89 100644 --- a/backend/api/models.py +++ b/backend/api/models.py @@ -1,6 +1,6 @@ +from django.conf import settings from django.db import models - class Template(models.Model): name = models.CharField(max_length=200) subject = models.CharField(max_length=100) @@ -21,6 +21,7 @@ class CheatSheet(models.Model): template = models.ForeignKey( Template, on_delete=models.SET_NULL, null=True, blank=True ) + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="cheat_sheets") columns = models.IntegerField(default=2) margins = models.CharField(max_length=20, default="0.5in") font_size = models.CharField(max_length=10, default="10pt") diff --git a/backend/api/serializers.py b/backend/api/serializers.py index 851e178..9da5bdd 100644 --- a/backend/api/serializers.py +++ b/backend/api/serializers.py @@ -84,10 +84,11 @@ class Meta: "selected_formulas", "problems", "full_latex", + "user", "created_at", "updated_at", ] - read_only_fields = ["id", "created_at", "updated_at", "full_latex"] + read_only_fields = ["id", "user", "created_at", "updated_at", "full_latex"] def get_full_latex(self, obj): """Return the fully-assembled LaTeX document string.""" diff --git a/backend/api/tests.py b/backend/api/tests.py index c6ecda3..f6003c5 100644 --- a/backend/api/tests.py +++ b/backend/api/tests.py @@ -37,10 +37,11 @@ def sample_template(db): @pytest.fixture -def sample_sheet(db, sample_template): +def sample_sheet(db, sample_template, auth_client): return CheatSheet.objects.create( title="My Test Sheet", template=sample_template, + user=auth_client.handler._force_user, # the user force_authenticate() set latex_content="Some content here", margins="0.75in", columns=2, @@ -73,6 +74,9 @@ def test_str_representation(self): class TestCheatSheetModel(TestCase): + def setUp(self): + self.user = User.objects.create_user(username="modeluser", password="pass123") + def test_build_full_latex_wraps_content(self): sheet = CheatSheet.objects.create( title="Test", @@ -80,6 +84,7 @@ def test_build_full_latex_wraps_content(self): margins="1in", columns=1, font_size="10pt", + user=self.user, ) full = sheet.build_full_latex() assert "\\begin{document}" in full @@ -92,6 +97,7 @@ def test_build_full_latex_multicolumn(self): title="Multi-col", latex_content="Col content", columns=3, + user=self.user, ) full = sheet.build_full_latex() assert "\\usepackage{multicol}" in full @@ -102,6 +108,7 @@ def test_build_full_latex_passthrough(self): sheet = CheatSheet.objects.create( title="Raw", latex_content=raw, + user=self.user, ) assert sheet.build_full_latex() == raw @@ -110,6 +117,7 @@ def test_build_full_latex_passthrough_inserts_problems_before_document_end(self) sheet = CheatSheet.objects.create( title="Raw With Problems", latex_content=raw, + user=self.user, ) PracticeProblem.objects.create( cheat_sheet=sheet, @@ -137,6 +145,7 @@ def test_build_full_latex_passthrough_inserts_problems_before_end_multicols(self sheet = CheatSheet.objects.create( title="Raw Multi", latex_content=raw, + user=self.user, ) PracticeProblem.objects.create( cheat_sheet=sheet, @@ -153,6 +162,7 @@ def test_build_full_latex_with_problems(self): sheet = CheatSheet.objects.create( title="With Problems", latex_content="Content", + user=self.user, ) PracticeProblem.objects.create( cheat_sheet=sheet, @@ -170,6 +180,7 @@ def test_build_full_latex_8pt_uses_extarticle(self): title="Small Font", latex_content="Content", font_size="8pt", + user=self.user, ) full = sheet.build_full_latex() @@ -339,6 +350,66 @@ def test_delete_cheatsheet(self, auth_client, sample_sheet): assert resp.status_code == 204 +@pytest.mark.django_db +class TestCheatSheetAccessControl: + """Ensure users cannot access or modify another user's cheat sheets.""" + + @pytest.fixture + def other_user(self, db): + return User.objects.create_user(username="otheruser", password="otherpass123") + + @pytest.fixture + def other_client(self, other_user): + client = APIClient() + client.force_authenticate(user=other_user) + return client + + def test_list_does_not_return_other_users_sheets( + self, auth_client, other_client, sample_sheet + ): + """User B should not see User A's sheets in list response.""" + resp = other_client.get("/api/cheatsheets/") + assert resp.status_code == 200 + ids = [s["id"] for s in resp.json()] + assert sample_sheet.id not in ids + + def test_retrieve_other_users_sheet_returns_404( + self, other_client, sample_sheet + ): + """User B should get 404 when retrieving User A's sheet by ID.""" + resp = other_client.get(f"/api/cheatsheets/{sample_sheet.id}/") + assert resp.status_code == 404 + + def test_update_other_users_sheet_returns_404( + self, other_client, sample_sheet + ): + """User B should get 404 when updating User A's sheet.""" + resp = other_client.patch( + f"/api/cheatsheets/{sample_sheet.id}/", + {"title": "Hacked"}, + format="json", + ) + assert resp.status_code == 404 + + def test_delete_other_users_sheet_returns_404( + self, other_client, sample_sheet + ): + """User B should get 404 when deleting User A's sheet.""" + resp = other_client.delete(f"/api/cheatsheets/{sample_sheet.id}/") + assert resp.status_code == 404 + + def test_compile_other_users_sheet_returns_404( + self, other_client, sample_sheet + ): + """User B should get 404 when compiling User A's sheet via cheat_sheet_id.""" + resp = other_client.post( + "/api/compile/", + {"cheat_sheet_id": sample_sheet.id}, + format="json", + ) + assert resp.status_code == 404 + + @pytest.mark.django_db class TestCreateFromTemplate: def test_create_from_template(self, auth_client, sample_template): diff --git a/backend/api/views.py b/backend/api/views.py index 498736f..b3c24a1 100644 --- a/backend/api/views.py +++ b/backend/api/views.py @@ -1,10 +1,10 @@ -from rest_framework.decorators import api_view, action +from rest_framework.decorators import api_view, action, permission_classes 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 rest_framework.permissions import AllowAny, IsAuthenticated from django.shortcuts import get_object_or_404 import subprocess import tempfile @@ -129,6 +129,7 @@ def generate_sheet(request): @api_view(["POST"]) +@permission_classes([IsAuthenticated]) def compile_latex(request): """ POST /api/compile/ @@ -142,7 +143,7 @@ def compile_latex(request): # If cheat_sheet_id is provided, get content from the cheat sheet if cheat_sheet_id: - cheatsheet = get_object_or_404(CheatSheet, pk=cheat_sheet_id) + cheatsheet = get_object_or_404(CheatSheet, pk=cheat_sheet_id, user=request.user) content = cheatsheet.build_full_latex() if not content: @@ -209,6 +210,13 @@ class CheatSheetViewSet(viewsets.ModelViewSet): """ queryset = CheatSheet.objects.all() serializer_class = CheatSheetSerializer + permission_classes = [IsAuthenticated] + + def get_queryset(self): + return self.queryset.filter(user=self.request.user).order_by('-updated_at') + + def perform_create(self, serializer): + serializer.save(user=self.request.user) @action(detail=False, methods=['post'], url_path='from-template') def from_template(self, request): @@ -226,6 +234,7 @@ def from_template(self, request): cheatsheet = CheatSheet.objects.create( title=title, + user=request.user, template=template, latex_content=template.latex_content, margins=template.default_margins, diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 5668ef0..0118c44 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -3,6 +3,7 @@ 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 Dashboard from './components/Dashboard'; import './App.css' import CreateCheatSheet from './components/CreateCheatSheet'; @@ -48,7 +49,7 @@ function App() { localStorage.setItem('theme', theme); }, [theme]); - const { user, logoutUser } = useContext(AuthContext); + const { user, authTokens, logoutUser } = useContext(AuthContext); const toggleTheme = () => { setTheme(prev => prev === 'dark' ? 'light' : 'dark'); @@ -57,6 +58,8 @@ function App() { const handleReset = () => { setCheatSheet(DEFAULT_SHEET); localStorage.setItem('currentCheatSheet', JSON.stringify(DEFAULT_SHEET)); + localStorage.removeItem('cheatSheetData'); + localStorage.removeItem('cheatSheetLatex'); }; useEffect(() => { @@ -90,7 +93,10 @@ function App() { const sheetId = nextSheet.id; const response = await fetch(sheetId ? `/api/cheatsheets/${sheetId}/` : '/api/cheatsheets/', { method: sheetId ? 'PATCH' : 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { + 'Content-Type': 'application/json', + ...(authTokens?.access ? { 'Authorization': `Bearer ${authTokens.access}` } : {}), + }, body: JSON.stringify({ title: nextSheet.title, latex_content: nextSheet.content, @@ -128,6 +134,22 @@ function App() { } }; + const handleEditSheet = (sheet) => { + const editSheet = { + id: sheet.id, + title: sheet.title, + content: sheet.latex_content, + columns: sheet.columns, + margins: sheet.margins, + fontSize: sheet.font_size, + selectedFormulas: sheet.selected_formulas || [], + }; + setCheatSheet(editSheet); + localStorage.setItem('currentCheatSheet', JSON.stringify(editSheet)); + localStorage.removeItem('cheatSheetData'); + localStorage.removeItem('cheatSheetLatex'); + }; + return (
@@ -142,6 +164,8 @@ function App() { {user ? ( <> Hi, {user.username || 'User'} + Create New Sheet + My Sheets ) : ( @@ -169,6 +193,11 @@ function App() { /> } /> + + + + } /> } /> } /> diff --git a/frontend/src/components/Dashboard.jsx b/frontend/src/components/Dashboard.jsx new file mode 100644 index 0000000..237456a --- /dev/null +++ b/frontend/src/components/Dashboard.jsx @@ -0,0 +1,136 @@ +import React, { useState, useEffect, useContext } from 'react'; +import { useNavigate } from 'react-router-dom'; +import AuthContext from '../context/AuthContext'; +import '../styles/Dashboard.css'; + +const Dashboard = ({ onEditSheet, onCreateNewSheet }) => { + const [sheets, setSheets] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + const { authTokens } = useContext(AuthContext); + const navigate = useNavigate(); + + useEffect(() => { + const fetchSheets = async () => { + if (!authTokens?.access) { + setLoading(false); + return; + } + try { + const response = await fetch('/api/cheatsheets/', { + headers: { + 'Authorization': `Bearer ${authTokens.access}`, + }, + }); + + if (!response.ok) { + throw new Error('Failed to load cheat sheets'); + } + + const data = await response.json(); + setSheets(data); + } catch (err) { + setError(err.message); + } finally { + setLoading(false); + } + }; + + fetchSheets(); + }, [authTokens]); + + const handleEdit = (sheet) => { + onEditSheet(sheet); + navigate('/'); + }; + + const handleDelete = async (id) => { + if (!window.confirm('Are you sure you want to delete this cheat sheet?')) return; + if (!authTokens?.access) return; + + try { + const response = await fetch(`/api/cheatsheets/${id}/`, { + method: 'DELETE', + headers: { + 'Authorization': `Bearer ${authTokens.access}`, + }, + }); + + if (!response.ok) { + throw new Error('Failed to delete cheat sheet'); + } + + setSheets((prevSheets) => prevSheets.filter((sheet) => sheet.id !== id)); + } catch (err) { + alert(err.message); + } + }; + + const handleDownload = async (sheet) => { + if (!authTokens?.access) return; + try { + const response = await fetch('/api/compile/', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${authTokens.access}`, + }, + body: JSON.stringify({ cheat_sheet_id: sheet.id }), + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.error || 'Failed to generate PDF'); + } + + const blob = await response.blob(); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `${sheet.title || 'cheat_sheet'}.pdf`; + document.body.appendChild(a); + a.click(); + a.remove(); + window.URL.revokeObjectURL(url); + } catch (err) { + alert(err.message); + } + }; + + if (loading) return
Loading your sheets...
; + if (error) return
Error: {error}
; + + return ( +
+
+

My Cheat Sheets

+ +
+ {sheets.length === 0 ? ( +
+

You haven't saved any cheat sheets yet.

+ +
+ ) : ( +
+ {sheets.map((sheet) => ( +
+

{sheet.title || 'Untitled Sheet'}

+

+ Created: {new Date(sheet.created_at).toLocaleDateString()}
+ Last modified: {new Date(sheet.updated_at).toLocaleDateString()} +

+
+ + + +
+
+ ))} +
+ )} +
+ ); +}; + +export default Dashboard; diff --git a/frontend/src/context/AuthContext.jsx b/frontend/src/context/AuthContext.jsx index 8c6d102..0dbb829 100644 --- a/frontend/src/context/AuthContext.jsx +++ b/frontend/src/context/AuthContext.jsx @@ -29,7 +29,7 @@ export const AuthProvider = ({ children }) => { if (response.ok) { setAuthTokens(data); setUser(jwtDecode(data.access)); - navigate('/'); + navigate('/dashboard'); } else { alert(data.detail || 'Invalid credentials'); } diff --git a/frontend/src/hooks/latex.js b/frontend/src/hooks/latex.js index 73d6fde..6390568 100644 --- a/frontend/src/hooks/latex.js +++ b/frontend/src/hooks/latex.js @@ -1,4 +1,5 @@ -import { useState, useRef, useEffect, useCallback } from 'react'; +import { useState, useRef, useEffect, useCallback, useContext } from 'react'; +import AuthContext from '../context/AuthContext'; const STORAGE_KEY = 'cheatSheetLatex'; const SAVE_DEBOUNCE_MS = 500; @@ -33,6 +34,7 @@ function formatCompileError(errorData = {}) { } export function useLatex(initialData) { + const { authTokens } = useContext(AuthContext); const [title, setTitle] = useState(initialData?.title ?? ''); const [content, setContent] = useState(initialData?.content ?? ''); const [contentModified, setContentModified] = useState(false); @@ -141,7 +143,10 @@ export function useLatex(initialData) { const compileLatexContent = useCallback(async (latexContent) => { const response = await fetch('/api/compile/', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { + 'Content-Type': 'application/json', + ...(authTokens ? { 'Authorization': `Bearer ${authTokens.access}` } : {}) + }, body: JSON.stringify({ content: latexContent }), }); @@ -156,7 +161,7 @@ export function useLatex(initialData) { } pdfBlobUrlRef.current = URL.createObjectURL(blob); setPdfBlob(pdfBlobUrlRef.current); - }, []); + }, [authTokens]); const handleCompileOnly = useCallback(async () => { if (isCompilingRef.current) return; @@ -261,7 +266,10 @@ export function useLatex(initialData) { try { const response = await fetch('/api/compile/', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { + 'Content-Type': 'application/json', + ...(authTokens ? { 'Authorization': `Bearer ${authTokens.access}` } : {}) + }, body: JSON.stringify({ content }), }); if (!response.ok) throw new Error('Failed to compile LaTeX'); diff --git a/frontend/src/styles/Dashboard.css b/frontend/src/styles/Dashboard.css new file mode 100644 index 0000000..be35872 --- /dev/null +++ b/frontend/src/styles/Dashboard.css @@ -0,0 +1,95 @@ +.dashboard-container { + padding: 2rem; + max-width: 1200px; + margin: 0 auto; +} + +.dashboard-container h2 { + margin-bottom: 2rem; + color: var(--text-color); +} + +.sheets-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + gap: 1.5rem; +} + +.sheet-card { + background-color: var(--card-bg); + border: 1px solid var(--border-color); + border-radius: 8px; + padding: 1.5rem; + display: flex; + flex-direction: column; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05); + transition: transform 0.2s, box-shadow 0.2s; +} + +.sheet-card:hover { + transform: translateY(-2px); + box-shadow: 0 6px 12px rgba(0, 0, 0, 0.1); +} + +.sheet-card h3 { + margin-top: 0; + margin-bottom: 0.5rem; + font-size: 1.25rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.sheet-meta { + font-size: 0.85rem; + color: var(--text-muted); + margin-bottom: 1.5rem; + flex-grow: 1; +} + +.sheet-actions { + display: flex; + justify-content: space-between; + gap: 0.5rem; +} + +.sheet-actions .btn.small { + padding: 0.4rem 0.8rem; + font-size: 0.875rem; + flex: 1; +} + +.sheet-actions .btn.danger { + background-color: #dc3545; + color: white; + border-color: #dc3545; +} + +.sheet-actions .btn.danger:hover { + background-color: #c82333; +} + +.empty-state { + text-align: center; + padding: 4rem 2rem; + background-color: var(--card-bg); + border: 1px dashed var(--border-color); + border-radius: 8px; +} + +.empty-state p { + color: var(--text-muted); + margin-bottom: 1.5rem; + font-size: 1.1rem; +} + +.dashboard-loading, .dashboard-error { + text-align: center; + padding: 4rem; + font-size: 1.2rem; + color: var(--text-muted); +} + +.dashboard-error { + color: #dc3545; +} \ No newline at end of file